@synopackageland/cli 0.7.1 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/assets/default-icon/README.md +19 -0
  3. package/assets/default-icon/default-icon-small.svg +9 -0
  4. package/assets/default-icon/default-icon.svg +10 -0
  5. package/assets/default-icon/icon_16.png +0 -0
  6. package/assets/default-icon/icon_24.png +0 -0
  7. package/assets/default-icon/icon_256.png +0 -0
  8. package/assets/default-icon/icon_32.png +0 -0
  9. package/assets/default-icon/icon_48.png +0 -0
  10. package/assets/default-icon/icon_64.png +0 -0
  11. package/assets/default-icon/icon_72.png +0 -0
  12. package/dist/build-spk/dsm-icons.d.ts +31 -2
  13. package/dist/build-spk/dsm-icons.js +135 -56
  14. package/dist/build-spk/generator.js +17 -3
  15. package/dist/build-spk/png.d.ts +14 -0
  16. package/dist/build-spk/png.js +161 -0
  17. package/dist/build-spk/ui-texts.d.ts +23 -0
  18. package/dist/build-spk/ui-texts.js +46 -0
  19. package/dist/cli.js +26 -2
  20. package/dist/contract/i18n.d.ts +5 -0
  21. package/dist/contract/i18n.js +45 -0
  22. package/dist/contract/icon.d.ts +5 -0
  23. package/dist/contract/icon.js +27 -0
  24. package/dist/contract/validate.js +4 -0
  25. package/dist/contract/web-launch.js +3 -2
  26. package/dist/error-registry.js +3 -0
  27. package/dist/standalone/dsm-window.d.ts +2 -0
  28. package/dist/standalone/dsm-window.js +19 -6
  29. package/dist/standalone/i18n.d.ts +12 -0
  30. package/dist/standalone/i18n.js +30 -0
  31. package/dist/standalone/inspect-spk.d.ts +5 -0
  32. package/dist/standalone/inspect-spk.js +36 -1
  33. package/dist/standalone/manifest.d.ts +5 -6
  34. package/dist/standalone/manifest.js +21 -34
  35. package/dist/standalone/native-layout.d.ts +2 -0
  36. package/dist/standalone/native-layout.js +22 -1
  37. package/dist/standalone/spk.js +7 -4
  38. package/dist/templates/binary/dsm-package.yaml +3 -0
  39. package/dist/templates/compose/synology-app.yaml +3 -0
  40. package/dist/templates/native-go/dsm-package.yaml +3 -0
  41. package/package.json +2 -1
  42. package/schema/synology-app.schema.json +22 -0
  43. package/templates/binary/dsm-package.yaml +3 -0
  44. package/templates/compose/synology-app.yaml +3 -0
  45. package/templates/native-go/dsm-package.yaml +3 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,107 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.3 (2026-10-11)
4
+
5
+ Rebuild every package built with 0.7.2 that has `i18n.info` translations. In
6
+ 0.7.2 its DSM main-menu entry, desktop shortcut and window title showed the raw
7
+ key `synopkgland:app_title` in every language.
8
+
9
+ ### Translated DSM title fix
10
+
11
+ - `ui/config` entries that use the `synopkgland:app_title` and `app_desc`
12
+ strings now declare `"texts": "texts"`: the main-menu `app` entry, its window
13
+ `lib` entry and the `.url` entry of a `synology-app.yaml` app. DSM loads
14
+ `ui/texts/<DSM language>/strings` only for entries that declare `texts`, so
15
+ 0.7.2 titles never resolved (seen on DSM 7.4 and 7.1.1). Without `i18n.info`
16
+ `ui/config` is unchanged.
17
+
18
+ ### Correction to 0.7.2
19
+
20
+ - 0.7.2 said `synology-app.yaml` `i18n.info` localizes the DSM window title.
21
+ It localizes the DSM main-menu and desktop label only. `launch: dsm-window`
22
+ adds a main-menu and desktop entry that DSM always opens in a new browser
23
+ tab, as it does for every URL entry; the app has no DSM window and no window
24
+ title. This is not caused by DSM's CSRF protection setting. The container app
25
+ guide and the `synology-app.yaml` reference now say so.
26
+
27
+ ### 繁體中文摘要
28
+
29
+ - 修正 0.7.2 的在地化標題:有 `i18n.info` 翻譯時,`ui/config` 中使用 `synopkgland:` 字串的
30
+ `app`、`lib` 與 `.url` 項目都加上 `"texts": "texts"`。0.7.2 少了這個欄位,DSM 不會載入
31
+ `ui/texts/<語言>/strings`,主選單、桌面捷徑與視窗標題在各語言都顯示 `synopkgland:app_title`。
32
+ - 用 0.7.2 建置且含 `i18n.info` 的套件,請以 0.7.3 重新建置並遞增 build 後重新發佈;沒有
33
+ `i18n.info` 的套件不受影響。
34
+ - 更正 0.7.2:`synology-app.yaml` 的 `i18n.info` 只翻譯 DSM 主選單與桌面標籤,沒有視窗標題。
35
+ `launch: dsm-window` 的項目一律由 DSM 在新的瀏覽器分頁開啟(DSM 對所有網址項目都如此),
36
+ 與 CSRF 防護設定無關;容器 App 指南與 `synology-app.yaml` 參考已更正。
37
+
38
+ ## 0.7.2 (2026-10-11)
39
+
40
+ Rebuild packages to pick up the new default icon. Packages without `icon` now
41
+ show the SDK default icon instead of a solid blue square.
42
+
43
+ ### Package icon
44
+
45
+ - `dsm-package.yaml` and `synology-app.yaml` accept `icon`: a square PNG of at
46
+ least 256×256 inside the project, or a folder of `icon_<size>.png` (16, 24,
47
+ 32, 48, 64, 72, 256; `icon_256.png` required, missing sizes scaled from it).
48
+ `build` writes `PACKAGE_ICON.PNG` (64×64), `PACKAGE_ICON_256.PNG` and every
49
+ DSM window size `ui/images/app_<size>.png` from it. SVG is not accepted, so
50
+ the CLI gains no image dependency. `check` refuses an icon outside the
51
+ project, a non-PNG, a non-square or smaller image, and one whose Package
52
+ Center icons exceed the package source's 256 KiB limit
53
+ (`STANDALONE_ICON_INVALID`, `CONTRACT_ICON_INVALID`).
54
+
55
+ - A native layout may set `icon` too; `build` then writes both Package Center
56
+ icons and refuses a metadata folder that also ships them. Without `icon` the
57
+ layout keeps owning its icons.
58
+
59
+ - `check` reports which icon a build uses: `圖示:自訂 <path>`, or `圖示:SDK
60
+ 預設圖示` with an info hint about `icon:` (not an error). It warns when an
61
+ icon is a single colour. `check --json` reports it in `data.icon`.
62
+
63
+ - `inspect` reports `icon` as `default`, `custom` (with the SHA-256 of
64
+ `PACKAGE_ICON_256.PNG`) or `none`, and lists a Package Center icon over
65
+ 256 KiB, of the wrong size or not a PNG as a finding.
66
+
67
+ - The SDK default icon replaces the solid blue square in Package Center, the
68
+ DSM main menu and the window. Sources are in `assets/default-icon/`.
69
+
70
+ ### Translated DSM title
71
+
72
+ - With `i18n.info` translations, the DSM main-menu entry and window title
73
+ follow the DSM language. `ui/config` refers to `synopkgland:app_title` and
74
+ `app_desc` in `ui/texts/<DSM language>/strings`, written for English and each
75
+ translated language; a language without its own text shows the English one.
76
+ Without translations `ui/config` keeps the literal English title.
77
+
78
+ - `synology-app.yaml` accepts the same `i18n.info` block. It writes INFO
79
+ `displayname_<lang>` / `description_<lang>` and, with `launch: dsm-window`,
80
+ localizes the DSM window entry. Once a translation exists, the English
81
+ `displayname` and `description` must also be one line without `"` or `\`
82
+ (`CONTRACT_I18N_INVALID`).
83
+
84
+ ### Delegate window
85
+
86
+ - A DSM window built for a `scope: install-selected` delegate no longer
87
+ contains the dead `if (panels && false)` check. The window leaves out the
88
+ approval panel code instead, and other scopes generate `if (panels)`. The
89
+ behavior is unchanged: this scope is approved only in the app's
90
+ **Administrator setup** panel, because that approval carries the scope the
91
+ administrator picks and the window panel has no scope picker. The delegate
92
+ guide now says so.
93
+
94
+ ### 繁體中文摘要
95
+
96
+ - 升級後重新建置套件即可換上新的 SDK 預設圖示(不再是純藍方塊)。executor digest 不變,不需重新核准。
97
+ - `dsm-package.yaml`、`synology-app.yaml` 與 native layout 新增 `icon`:專案內至少 256×256 的正方形
98
+ PNG,或含 `icon_256.png` 的 `icon_<size>.png` 目錄;不接受 SVG。`check` 顯示使用的圖示並檢查格式與
99
+ 256 KiB 上限,`inspect` 回報 `default`/`custom`/`none`。
100
+ - 有 `i18n.info` 翻譯時,DSM 主選單與視窗標題會跟著 DSM 語言切換;缺翻譯的語言顯示英文。
101
+ `synology-app.yaml` 也支援 `i18n.info`。
102
+ - `scope: install-selected` 的 DSM 視窗不再產生 `if (panels && false)` 死碼;此 scope 仍只在 App 的
103
+ 「管理員設定」核准。
104
+
3
105
  ## 0.7.1 (2026-10-10)
4
106
 
5
107
  Rebuild packages to pick up template, hook and executor changes. The executor
@@ -0,0 +1,19 @@
1
+ # SDK default package icon
2
+
3
+ Used by `synopkgland build` when `dsm-package.yaml` or `synology-app.yaml`
4
+ sets no `icon`. It marks an App that has not chosen its own icon yet: a
5
+ Package Land parcel on the cream brand tile, not a broken image.
6
+
7
+ This folder is itself an icon directory (`icon: <dir>`): `icon_<size>.png`
8
+ files are used as drawn and `icon_256.png` is scaled for any missing size.
9
+
10
+ - `default-icon.svg` draws 48, 64, 72 and 256.
11
+ - `default-icon-small.svg` is a 16 px pixel grid for 16, 24 and 32.
12
+
13
+ Regenerate after editing an SVG (rsvg-convert is a maintainer tool only;
14
+ the CLI never rasterises SVG):
15
+
16
+ ```sh
17
+ for s in 16 24 32; do rsvg-convert -w $s -h $s default-icon-small.svg -o icon_$s.png; done
18
+ for s in 48 64 72 256; do rsvg-convert -w $s -h $s default-icon.svg -o icon_$s.png; done
19
+ ```
@@ -0,0 +1,9 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" shape-rendering="crispEdges">
2
+ <rect x="0" y="0" width="16" height="16" rx="3.5" fill="#fbf5e8"/>
3
+ <rect x="3" y="2" width="2" height="2" fill="#1f78e5"/>
4
+ <rect x="7" y="1" width="2" height="3" fill="#f6b324"/>
5
+ <rect x="11" y="2" width="2" height="2" fill="#1f78e5"/>
6
+ <rect x="2" y="4" width="12" height="3" fill="#135bb8"/>
7
+ <rect x="3" y="7" width="10" height="7" fill="#1f78e5"/>
8
+ <rect x="6" y="9" width="4" height="1" fill="#fbf5e8"/>
9
+ </svg>
@@ -0,0 +1,10 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">
2
+ <rect x="6" y="6" width="244" height="244" rx="56" fill="#fbf5e8" stroke="#e4d8bd" stroke-width="4"/>
3
+ <path d="M212 22l6 14 14 6-14 6-6 14-6-14-14-6 14-6z" fill="#f6b324"/>
4
+ <rect x="62" y="58" width="30" height="30" rx="6" fill="#1f78e5"/>
5
+ <rect x="113" y="50" width="30" height="38" rx="6" fill="#f6b324"/>
6
+ <rect x="164" y="58" width="30" height="30" rx="6" fill="#1f78e5"/>
7
+ <rect x="48" y="80" width="160" height="44" rx="12" fill="#135bb8"/>
8
+ <path d="M58 118h140v66a24 24 0 0 1-24 24H82a24 24 0 0 1-24-24z" fill="#1f78e5"/>
9
+ <rect x="100" y="140" width="56" height="16" rx="8" fill="#fbf5e8"/>
10
+ </svg>
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -1,2 +1,31 @@
1
- export declare function writeDsmWindowIcons(uiDir: string): void;
2
- export declare function writePackageCenterIcons(spkRoot: string): void;
1
+ /** Sizes DSM loads for `images/app_{0}.png` (main menu, desktop, window, taskbar). */
2
+ export declare const WINDOW_ICON_SIZES: readonly [16, 24, 32, 48, 64, 72, 256];
3
+ /** The package source drops a release whose PACKAGE_ICON is larger than this. */
4
+ export declare const MAX_ICON_BYTES: number;
5
+ export interface PackageIcons {
6
+ source: "custom" | "default";
7
+ /** Encoded PNG per DSM window size; PACKAGE_ICON.PNG is 64 and PACKAGE_ICON_256.PNG is 256. */
8
+ window: Map<number, Buffer>;
9
+ packageIcon: Buffer;
10
+ packageIcon256: Buffer;
11
+ /** Every pixel of the 256 px icon is the same colour. */
12
+ solid: boolean;
13
+ }
14
+ /** Ships next to dist/ and src/ alike: <package>/assets/default-icon. */
15
+ export declare const DEFAULT_ICON_DIR: string;
16
+ /** A project icon: one square PNG of at least 256 px, or a directory of icon_<size>.png. */
17
+ export declare function renderPackageIcons(path: string): PackageIcons;
18
+ /** The SDK default icon, for projects that set no `icon`. */
19
+ export declare function defaultPackageIcons(): PackageIcons;
20
+ /** The project's icon when it sets one, otherwise the SDK default. */
21
+ export declare function packageIcons(icon: string | undefined): PackageIcons;
22
+ /** What check reports: which icon a build would use, relative to the project. */
23
+ export interface IconReport {
24
+ /** `layout`: a native layout without `icon`, whose metadata owns PACKAGE_ICON*.PNG. */
25
+ source: PackageIcons["source"] | "layout";
26
+ path?: string;
27
+ solid: boolean;
28
+ }
29
+ export declare function iconReport(root: string, icon: string | undefined): IconReport;
30
+ export declare function writeDsmWindowIcons(uiDir: string, icons: PackageIcons): void;
31
+ export declare function writePackageCenterIcons(spkRoot: string, icons: PackageIcons): void;
@@ -1,67 +1,146 @@
1
- import { deflateSync } from "node:zlib";
2
- import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
3
- import { join } from "node:path";
4
- const ICON_SIZES = [16, 24, 32, 48, 64, 72, 256];
5
- const ICON_RGB = [0x1a, 0x73, 0xe8];
6
- function crc32(data) {
7
- let crc = 0xffffffff;
8
- for (const byte of data) {
9
- crc ^= byte;
10
- for (let i = 0; i < 8; i++) {
11
- crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
12
- }
1
+ import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
+ import { basename, dirname, join, relative } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { decodePng, encodePng } from "./png.js";
5
+ /** Sizes DSM loads for `images/app_{0}.png` (main menu, desktop, window, taskbar). */
6
+ export const WINDOW_ICON_SIZES = [16, 24, 32, 48, 64, 72, 256];
7
+ /** The package source drops a release whose PACKAGE_ICON is larger than this. */
8
+ export const MAX_ICON_BYTES = 256 * 1024;
9
+ const ICON_FILE = /^icon_(\d+)\.png$/;
10
+ /** Ships next to dist/ and src/ alike: <package>/assets/default-icon. */
11
+ export const DEFAULT_ICON_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "assets", "default-icon");
12
+ function readPng(path, name) {
13
+ try {
14
+ return decodePng(readFileSync(path));
15
+ }
16
+ catch (error) {
17
+ throw new Error(`${name}:${error.message}`);
13
18
  }
14
- return (crc ^ 0xffffffff) >>> 0;
15
- }
16
- function pngChunk(type, data) {
17
- const length = Buffer.alloc(4);
18
- length.writeUInt32BE(data.length);
19
- const typeBuf = Buffer.from(type, "ascii");
20
- const crc = Buffer.alloc(4);
21
- crc.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])));
22
- return Buffer.concat([length, typeBuf, data, crc]);
23
19
  }
24
- function solidPng(size) {
25
- const stride = size * 3 + 1;
26
- const raw = Buffer.alloc(stride * size);
20
+ /** Area-average downscale in premultiplied alpha, so transparent edges stay clean. */
21
+ function scale(image, size) {
22
+ if (image.width === size && image.height === size)
23
+ return image;
24
+ const { width, height, rgba } = image;
25
+ const premultiplied = new Float64Array(width * height * 4);
26
+ for (let i = 0; i < width * height; i++) {
27
+ const alpha = rgba[i * 4 + 3] / 255;
28
+ for (let c = 0; c < 3; c++)
29
+ premultiplied[i * 4 + c] = rgba[i * 4 + c] * alpha;
30
+ premultiplied[i * 4 + 3] = rgba[i * 4 + 3];
31
+ }
32
+ const spans = (from) => Array.from({ length: size }, (_, d) => {
33
+ const start = (d * from) / size, end = ((d + 1) * from) / size;
34
+ const weights = [];
35
+ for (let s = Math.floor(start); s < end; s++)
36
+ weights.push([s, Math.min(end, s + 1) - Math.max(start, s)]);
37
+ return weights.map(([s, w]) => [s, w / (end - start)]);
38
+ });
39
+ const columns = spans(width), rows = spans(height);
40
+ const across = new Float64Array(size * height * 4);
41
+ for (let y = 0; y < height; y++) {
42
+ for (let x = 0; x < size; x++) {
43
+ for (const [s, w] of columns[x]) {
44
+ for (let c = 0; c < 4; c++)
45
+ across[(y * size + x) * 4 + c] += premultiplied[(y * width + s) * 4 + c] * w;
46
+ }
47
+ }
48
+ }
49
+ const out = Buffer.alloc(size * size * 4);
27
50
  for (let y = 0; y < size; y++) {
28
- const row = y * stride;
29
- raw[row] = 0;
30
51
  for (let x = 0; x < size; x++) {
31
- const i = row + 1 + x * 3;
32
- raw[i] = ICON_RGB[0];
33
- raw[i + 1] = ICON_RGB[1];
34
- raw[i + 2] = ICON_RGB[2];
52
+ const pixel = [0, 0, 0, 0];
53
+ for (const [s, w] of rows[y])
54
+ for (let c = 0; c < 4; c++)
55
+ pixel[c] += across[(s * size + x) * 4 + c] * w;
56
+ const alpha = Math.round(pixel[3]);
57
+ const at = (y * size + x) * 4;
58
+ for (let c = 0; c < 3; c++)
59
+ out[at + c] = alpha ? Math.min(255, Math.round((pixel[c] * 255) / pixel[3])) : 0;
60
+ out[at + 3] = alpha;
35
61
  }
36
62
  }
37
- const ihdr = Buffer.alloc(13);
38
- ihdr.writeUInt32BE(size, 0);
39
- ihdr.writeUInt32BE(size, 4);
40
- ihdr[8] = 8;
41
- ihdr[9] = 2;
42
- return Buffer.concat([
43
- Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
44
- pngChunk("IHDR", ihdr),
45
- pngChunk("IDAT", deflateSync(raw, { level: 9 })),
46
- pngChunk("IEND", Buffer.alloc(0)),
47
- ]);
63
+ return { width: size, height: size, rgba: out };
48
64
  }
49
- export function writeDsmWindowIcons(uiDir) {
50
- const imagesDir = join(uiDir, "images");
51
- mkdirSync(imagesDir, { recursive: true });
52
- for (const size of ICON_SIZES) {
53
- const path = join(imagesDir, `app_${size}.png`);
54
- writeFileSync(path, solidPng(size));
55
- chmodSync(path, 0o644);
65
+ function isSolid(image) {
66
+ const first = image.rgba.readUInt32BE(0);
67
+ for (let i = 4; i < image.rgba.length; i += 4)
68
+ if (image.rgba.readUInt32BE(i) !== first)
69
+ return false;
70
+ return true;
71
+ }
72
+ /** `icon_<size>.png` drawn per size; icon_256.png is required and scaled for the rest. */
73
+ function readIconDirectory(dir) {
74
+ const drawn = new Map();
75
+ for (const name of readdirSync(dir).sort()) {
76
+ const match = ICON_FILE.exec(name);
77
+ if (!match)
78
+ continue;
79
+ const size = Number(match[1]);
80
+ if (!WINDOW_ICON_SIZES.includes(size)) {
81
+ throw new Error(`${name}:不支援的尺寸;只接受 ${WINDOW_ICON_SIZES.map((s) => `icon_${s}.png`).join("、")}。`);
82
+ }
83
+ const image = readPng(join(dir, name), name);
84
+ if (image.width !== size || image.height !== size) {
85
+ throw new Error(`${name}:尺寸是 ${image.width}×${image.height},應為 ${size}×${size}。`);
86
+ }
87
+ drawn.set(size, image);
56
88
  }
89
+ if (!drawn.has(256))
90
+ throw new Error(`圖示目錄 ${basename(dir)}/ 缺少 icon_256.png(256×256 PNG,其他尺寸可省略,由它縮小產生)。`);
91
+ return drawn;
57
92
  }
58
- export function writePackageCenterIcons(spkRoot) {
59
- for (const [name, size] of [
60
- ["PACKAGE_ICON.PNG", 64],
61
- ["PACKAGE_ICON_256.PNG", 256],
62
- ]) {
63
- const path = join(spkRoot, name);
64
- writeFileSync(path, solidPng(size));
65
- chmodSync(path, 0o644);
93
+ function readIconFile(path) {
94
+ const image = readPng(path, basename(path));
95
+ if (image.width !== image.height)
96
+ throw new Error(`${basename(path)}:尺寸 ${image.width}×${image.height} 不是正方形。`);
97
+ if (image.width < 256)
98
+ throw new Error(`${basename(path)}:尺寸 ${image.width}×${image.height},至少 256×256。`);
99
+ return new Map([[256, scale(image, 256)]]);
100
+ }
101
+ function render(drawn, source) {
102
+ const big = drawn.get(256);
103
+ const window = new Map();
104
+ for (const size of WINDOW_ICON_SIZES) {
105
+ const png = encodePng(size, size, scale(drawn.get(size) ?? big, size).rgba);
106
+ if (png.length > MAX_ICON_BYTES) {
107
+ throw new Error(`${size}×${size} 圖示編碼後 ${png.length} bytes,超過 package source 的 256 KiB 上限;請減少雜訊或細節。`);
108
+ }
109
+ window.set(size, png);
66
110
  }
111
+ return { source, window, packageIcon: window.get(64), packageIcon256: window.get(256), solid: isSolid(big) };
112
+ }
113
+ /** A project icon: one square PNG of at least 256 px, or a directory of icon_<size>.png. */
114
+ export function renderPackageIcons(path) {
115
+ if (!existsSync(path))
116
+ throw new Error(`找不到圖示 ${basename(path)}。`);
117
+ return render(statSync(path).isDirectory() ? readIconDirectory(path) : readIconFile(path), "custom");
118
+ }
119
+ let defaults;
120
+ /** The SDK default icon, for projects that set no `icon`. */
121
+ export function defaultPackageIcons() {
122
+ defaults ??= render(readIconDirectory(DEFAULT_ICON_DIR), "default");
123
+ return defaults;
124
+ }
125
+ /** The project's icon when it sets one, otherwise the SDK default. */
126
+ export function packageIcons(icon) {
127
+ return icon ? renderPackageIcons(icon) : defaultPackageIcons();
128
+ }
129
+ export function iconReport(root, icon) {
130
+ const icons = packageIcons(icon);
131
+ return { source: icons.source, ...(icon ? { path: relative(root, icon) } : {}), solid: icons.solid };
132
+ }
133
+ function writeIcon(path, png) {
134
+ writeFileSync(path, png);
135
+ chmodSync(path, 0o644);
136
+ }
137
+ export function writeDsmWindowIcons(uiDir, icons) {
138
+ const imagesDir = join(uiDir, "images");
139
+ mkdirSync(imagesDir, { recursive: true });
140
+ for (const [size, png] of icons.window)
141
+ writeIcon(join(imagesDir, `app_${size}.png`), png);
142
+ }
143
+ export function writePackageCenterIcons(spkRoot, icons) {
144
+ writeIcon(join(spkRoot, "PACKAGE_ICON.PNG"), icons.packageIcon);
145
+ writeIcon(join(spkRoot, "PACKAGE_ICON_256.PNG"), icons.packageIcon256);
67
146
  }
@@ -11,7 +11,8 @@ import { buildGatewayBinary, gatewayConfig, writeGatewayPayload } from "./gatewa
11
11
  import { classifyComposeRoute, loadCompose } from "../compose/analyze.js";
12
12
  import { scanComposePrivilege } from "../compose/privilege.js";
13
13
  import { validateProject } from "../validate.js";
14
- import { writeDsmWindowIcons, writePackageCenterIcons } from "./dsm-icons.js";
14
+ import { packageIcons, writeDsmWindowIcons, writePackageCenterIcons } from "./dsm-icons.js";
15
+ import { contractIconPath } from "../contract/icon.js";
15
16
  import { infoArchValue, isContainerArchFamily } from "./dsm-arch.js";
16
17
  import { assertContainerMinDsm, assertPortalMinDsm, resolveMinDsm } from "./dsm-version.js";
17
18
  import { createReproducibleTar } from "./reproducible-archive.js";
@@ -22,6 +23,9 @@ import { renderInstallWizardFiles } from "./install-wizard.js";
22
23
  import { contractPortal, PORTAL_JSON_RELPATH, PORTAL_MUSTACHE_RELPATH, renderPortalJson, renderPortalLifecycle, renderPortalMustache, renderPortalWizardStep } from "../portal/package.js";
23
24
  import { renderWizardFiles, writeWizardUiFiles } from "./wizard-i18n.js";
24
25
  import { sharedFolderCombobox } from "./share-combobox.js";
26
+ import { writeUiTexts } from "./ui-texts.js";
27
+ import { DSM_LANGS, LOCALES } from "../standalone/i18n.js";
28
+ import { contractAppTitle } from "../contract/i18n.js";
25
29
  import { renderHeadlessResourceMarker, renderResourceConfig, renderResourceWorkerStartStopStatus, } from "./resource-worker.js";
26
30
  const FILE_MODE = 0o644;
27
31
  const SCRIPT_MODE = 0o755;
@@ -89,6 +93,7 @@ export async function buildSpk(options) {
89
93
  const isDynamicRoute = route?.route === "dynamic";
90
94
  const isResourceWorkerRoute = route?.route === "static-zero-config" ||
91
95
  isDynamicRoute;
96
+ const icons = packageIcons(contractIconPath(contract, options.project.root));
92
97
  const staging = mkdtempSync(join(tmpdir(), "syno-spk-"));
93
98
  const gatewayWork = mkdtempSync(join(tmpdir(), "syno-gateway-"));
94
99
  try {
@@ -108,7 +113,7 @@ export async function buildSpk(options) {
108
113
  gatewayBinary = built.binary;
109
114
  }
110
115
  writeFileSync(join(staging, "INFO"), renderInfo(contract, options.archFamily, isResourceWorkerRoute), "utf8");
111
- writePackageCenterIcons(staging);
116
+ writePackageCenterIcons(staging, icons);
112
117
  mkdirSync(join(staging, "conf"), { recursive: true });
113
118
  writeFileSync(join(staging, "conf", "privilege"), renderPrivilege(), "utf8");
114
119
  writeFileSync(join(staging, "privilege"), renderPrivilege(), "utf8");
@@ -177,7 +182,8 @@ export async function buildSpk(options) {
177
182
  const uiDir = join(staging, "package", "ui");
178
183
  mkdirSync(uiDir, { recursive: true });
179
184
  writeFileSync(join(uiDir, "config"), renderDsmWindowConfig(contract), "utf8");
180
- writeDsmWindowIcons(uiDir);
185
+ writeDsmWindowIcons(uiDir, icons);
186
+ writeUiTexts(uiDir, contractAppTitle(contract));
181
187
  }
182
188
  const portal = contractPortal(contract);
183
189
  if (portal) {
@@ -223,6 +229,14 @@ function renderInfo(contract, archFamily, isResourceWorker = false) {
223
229
  add("displayname", String(contract.displayname));
224
230
  add("description", String(contract.description));
225
231
  add("maintainer", String(contract.maintainer));
232
+ const info = contractAppTitle(contract).info;
233
+ for (const locale of LOCALES) {
234
+ const text = info?.[locale];
235
+ if (text?.displayname)
236
+ add(`displayname_${DSM_LANGS[locale]}`, text.displayname);
237
+ if (text?.description)
238
+ add(`description_${DSM_LANGS[locale]}`, text.description);
239
+ }
226
240
  for (const [key, value] of metadataInfoFields(contract.metadata))
227
241
  add(key, value);
228
242
  add("arch", archFamily === undefined ? "noarch" : infoArchValue(archFamily));
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Just enough PNG for package icons, without an image dependency: decode any
3
+ * non-interlaced PNG to 8-bit RGBA, and encode 8-bit RGBA deterministically
4
+ * with only the critical chunks.
5
+ */
6
+ export interface RgbaImage {
7
+ width: number;
8
+ height: number;
9
+ /** Row-major, straight (not premultiplied) alpha. */
10
+ rgba: Buffer;
11
+ }
12
+ export declare function decodePng(bytes: Buffer): RgbaImage;
13
+ /** 8-bit RGBA, adaptive per-row filters, no ancillary chunks: same pixels, same bytes. */
14
+ export declare function encodePng(width: number, height: number, rgba: Buffer): Buffer;
@@ -0,0 +1,161 @@
1
+ import { crc32, deflateSync, inflateSync } from "node:zlib";
2
+ const SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
3
+ const MAX_SIDE = 4096;
4
+ const CHANNELS = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 };
5
+ const DEPTHS = { 0: [1, 2, 4, 8, 16], 2: [8, 16], 3: [1, 2, 4, 8], 4: [8, 16], 6: [8, 16] };
6
+ function corrupt(why) {
7
+ throw new Error(`PNG 損壞:${why}`);
8
+ }
9
+ function chunk(type, data) {
10
+ const head = Buffer.alloc(8);
11
+ head.writeUInt32BE(data.length);
12
+ head.write(type, 4, "ascii");
13
+ const crc = Buffer.alloc(4);
14
+ crc.writeUInt32BE(crc32(data, crc32(head.subarray(4))));
15
+ return Buffer.concat([head, data, crc]);
16
+ }
17
+ function paeth(a, b, c) {
18
+ const p = a + b - c;
19
+ const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
20
+ return pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
21
+ }
22
+ export function decodePng(bytes) {
23
+ if (bytes.length < 8 || !bytes.subarray(0, 8).equals(SIGNATURE))
24
+ throw new Error("不是 PNG 檔(檔頭簽章不符)。");
25
+ let offset = 8;
26
+ let header;
27
+ let palette, alpha, ended = false;
28
+ const data = [];
29
+ while (!ended) {
30
+ if (offset + 12 > bytes.length)
31
+ corrupt("檔案被截斷");
32
+ const length = bytes.readUInt32BE(offset);
33
+ const type = bytes.toString("latin1", offset + 4, offset + 8);
34
+ const end = offset + 12 + length;
35
+ if (end > bytes.length)
36
+ corrupt("檔案被截斷");
37
+ const body = bytes.subarray(offset + 8, offset + 8 + length);
38
+ if (crc32(body, crc32(bytes.subarray(offset + 4, offset + 8))) !== bytes.readUInt32BE(end - 4))
39
+ corrupt(`${type} CRC 不符`);
40
+ if (!header && type !== "IHDR")
41
+ corrupt("第一個 chunk 必須是 IHDR");
42
+ if (type === "IHDR") {
43
+ if (header || length !== 13)
44
+ corrupt("IHDR 無效");
45
+ header = { width: body.readUInt32BE(0), height: body.readUInt32BE(4), depth: body[8], type: body[9] };
46
+ if (!DEPTHS[header.type]?.includes(header.depth) || body[10] !== 0 || body[11] !== 0)
47
+ corrupt("不支援的色彩型態或位元深度");
48
+ if (body[12] !== 0)
49
+ throw new Error("不支援交錯(interlaced)PNG;請另存為非交錯 PNG。");
50
+ if (header.width < 1 || header.height < 1 || header.width > MAX_SIDE || header.height > MAX_SIDE) {
51
+ throw new Error(`PNG 尺寸 ${header.width}×${header.height} 超出範圍(每邊 1–${MAX_SIDE} px)。`);
52
+ }
53
+ }
54
+ else if (type === "PLTE")
55
+ palette = body;
56
+ else if (type === "tRNS")
57
+ alpha = body;
58
+ else if (type === "IDAT")
59
+ data.push(body);
60
+ else if (type === "IEND")
61
+ ended = true;
62
+ offset = end;
63
+ }
64
+ if (!header || !data.length)
65
+ corrupt("缺少影像資料");
66
+ const { width, height, depth, type } = header;
67
+ if (type === 3 && (!palette || palette.length % 3 !== 0))
68
+ corrupt("調色盤影像缺少 PLTE");
69
+ const bits = CHANNELS[type] * depth;
70
+ const bpp = Math.max(1, bits >> 3);
71
+ const stride = Math.ceil((width * bits) / 8);
72
+ let raw;
73
+ try {
74
+ raw = inflateSync(Buffer.concat(data), { maxOutputLength: (stride + 1) * height + 1 });
75
+ }
76
+ catch {
77
+ corrupt("影像資料無法解壓縮");
78
+ }
79
+ if (raw.length !== (stride + 1) * height)
80
+ corrupt("影像資料長度不符");
81
+ const prev = Buffer.alloc(stride), line = Buffer.alloc(stride);
82
+ const rgba = Buffer.alloc(width * height * 4);
83
+ const sample = (x, channel) => {
84
+ if (depth === 16)
85
+ return line[(x * CHANNELS[type] + channel) * 2];
86
+ if (depth === 8)
87
+ return line[x * CHANNELS[type] + channel];
88
+ const bit = x * depth;
89
+ return (line[bit >> 3] >> (8 - depth - (bit & 7))) & ((1 << depth) - 1);
90
+ };
91
+ const scale = depth < 8 ? 255 / ((1 << depth) - 1) : 1;
92
+ for (let y = 0; y < height; y++) {
93
+ const row = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1));
94
+ const filter = raw[y * (stride + 1)];
95
+ for (let i = 0; i < stride; i++) {
96
+ const a = i >= bpp ? line[i - bpp] : 0, b = prev[i], c = i >= bpp ? prev[i - bpp] : 0;
97
+ const predictor = filter === 0 ? 0 : filter === 1 ? a : filter === 2 ? b : filter === 3 ? (a + b) >> 1 : filter === 4 ? paeth(a, b, c) : corrupt("未知的列濾波");
98
+ line[i] = (row[i] + predictor) & 0xff;
99
+ }
100
+ for (let x = 0; x < width; x++) {
101
+ const out = (y * width + x) * 4;
102
+ if (type === 3) {
103
+ const index = sample(x, 0);
104
+ if (index * 3 + 2 >= palette.length)
105
+ corrupt("調色盤索引超出範圍");
106
+ palette.copy(rgba, out, index * 3, index * 3 + 3);
107
+ rgba[out + 3] = alpha && index < alpha.length ? alpha[index] : 255;
108
+ }
109
+ else if (type === 0 || type === 4) {
110
+ const grey = Math.round(sample(x, 0) * scale);
111
+ rgba.fill(grey, out, out + 3);
112
+ rgba[out + 3] = type === 4 ? sample(x, 1) : 255;
113
+ }
114
+ else {
115
+ for (let channel = 0; channel < 3; channel++)
116
+ rgba[out + channel] = sample(x, channel);
117
+ rgba[out + 3] = type === 6 ? sample(x, 3) : 255;
118
+ }
119
+ }
120
+ line.copy(prev);
121
+ }
122
+ return { width, height, rgba };
123
+ }
124
+ /** 8-bit RGBA, adaptive per-row filters, no ancillary chunks: same pixels, same bytes. */
125
+ export function encodePng(width, height, rgba) {
126
+ const stride = width * 4;
127
+ const out = Buffer.alloc((stride + 1) * height);
128
+ const zero = Buffer.alloc(stride);
129
+ const candidate = Buffer.alloc(stride);
130
+ for (let y = 0; y < height; y++) {
131
+ const line = rgba.subarray(y * stride, (y + 1) * stride);
132
+ const prev = y ? rgba.subarray((y - 1) * stride, y * stride) : zero;
133
+ let bestScore = Infinity;
134
+ for (let filter = 0; filter <= 4; filter++) {
135
+ let score = 0;
136
+ for (let i = 0; i < stride; i++) {
137
+ const a = i >= 4 ? line[i - 4] : 0, b = prev[i], c = i >= 4 ? prev[i - 4] : 0;
138
+ const predictor = filter === 0 ? 0 : filter === 1 ? a : filter === 2 ? b : filter === 3 ? (a + b) >> 1 : paeth(a, b, c);
139
+ const value = (line[i] - predictor) & 0xff;
140
+ candidate[i] = value;
141
+ score += value < 128 ? value : 256 - value;
142
+ }
143
+ if (score < bestScore) {
144
+ bestScore = score;
145
+ out[y * (stride + 1)] = filter;
146
+ candidate.copy(out, y * (stride + 1) + 1);
147
+ }
148
+ }
149
+ }
150
+ const ihdr = Buffer.alloc(13);
151
+ ihdr.writeUInt32BE(width, 0);
152
+ ihdr.writeUInt32BE(height, 4);
153
+ ihdr[8] = 8;
154
+ ihdr[9] = 6;
155
+ return Buffer.concat([
156
+ SIGNATURE,
157
+ chunk("IHDR", ihdr),
158
+ chunk("IDAT", deflateSync(out, { level: 9 })),
159
+ chunk("IEND", Buffer.alloc(0)),
160
+ ]);
161
+ }