@synopackageland/cli 0.7.1 → 0.7.2

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 +67 -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 +20 -0
  18. package/dist/build-spk/ui-texts.js +42 -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 +6 -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 +20 -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,72 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.2 (2026-10-11)
4
+
5
+ Rebuild packages to pick up the new default icon. Packages without `icon` now
6
+ show the SDK default icon instead of a solid blue square.
7
+
8
+ ### Package icon
9
+
10
+ - `dsm-package.yaml` and `synology-app.yaml` accept `icon`: a square PNG of at
11
+ least 256×256 inside the project, or a folder of `icon_<size>.png` (16, 24,
12
+ 32, 48, 64, 72, 256; `icon_256.png` required, missing sizes scaled from it).
13
+ `build` writes `PACKAGE_ICON.PNG` (64×64), `PACKAGE_ICON_256.PNG` and every
14
+ DSM window size `ui/images/app_<size>.png` from it. SVG is not accepted, so
15
+ the CLI gains no image dependency. `check` refuses an icon outside the
16
+ project, a non-PNG, a non-square or smaller image, and one whose Package
17
+ Center icons exceed the package source's 256 KiB limit
18
+ (`STANDALONE_ICON_INVALID`, `CONTRACT_ICON_INVALID`).
19
+
20
+ - A native layout may set `icon` too; `build` then writes both Package Center
21
+ icons and refuses a metadata folder that also ships them. Without `icon` the
22
+ layout keeps owning its icons.
23
+
24
+ - `check` reports which icon a build uses: `圖示:自訂 <path>`, or `圖示:SDK
25
+ 預設圖示` with an info hint about `icon:` (not an error). It warns when an
26
+ icon is a single colour. `check --json` reports it in `data.icon`.
27
+
28
+ - `inspect` reports `icon` as `default`, `custom` (with the SHA-256 of
29
+ `PACKAGE_ICON_256.PNG`) or `none`, and lists a Package Center icon over
30
+ 256 KiB, of the wrong size or not a PNG as a finding.
31
+
32
+ - The SDK default icon replaces the solid blue square in Package Center, the
33
+ DSM main menu and the window. Sources are in `assets/default-icon/`.
34
+
35
+ ### Translated DSM title
36
+
37
+ - With `i18n.info` translations, the DSM main-menu entry and window title
38
+ follow the DSM language. `ui/config` refers to `synopkgland:app_title` and
39
+ `app_desc` in `ui/texts/<DSM language>/strings`, written for English and each
40
+ translated language; a language without its own text shows the English one.
41
+ Without translations `ui/config` keeps the literal English title.
42
+
43
+ - `synology-app.yaml` accepts the same `i18n.info` block. It writes INFO
44
+ `displayname_<lang>` / `description_<lang>` and, with `launch: dsm-window`,
45
+ localizes the DSM window entry. Once a translation exists, the English
46
+ `displayname` and `description` must also be one line without `"` or `\`
47
+ (`CONTRACT_I18N_INVALID`).
48
+
49
+ ### Delegate window
50
+
51
+ - A DSM window built for a `scope: install-selected` delegate no longer
52
+ contains the dead `if (panels && false)` check. The window leaves out the
53
+ approval panel code instead, and other scopes generate `if (panels)`. The
54
+ behavior is unchanged: this scope is approved only in the app's
55
+ **Administrator setup** panel, because that approval carries the scope the
56
+ administrator picks and the window panel has no scope picker. The delegate
57
+ guide now says so.
58
+
59
+ ### 繁體中文摘要
60
+
61
+ - 升級後重新建置套件即可換上新的 SDK 預設圖示(不再是純藍方塊)。executor digest 不變,不需重新核准。
62
+ - `dsm-package.yaml`、`synology-app.yaml` 與 native layout 新增 `icon`:專案內至少 256×256 的正方形
63
+ PNG,或含 `icon_256.png` 的 `icon_<size>.png` 目錄;不接受 SVG。`check` 顯示使用的圖示並檢查格式與
64
+ 256 KiB 上限,`inspect` 回報 `default`/`custom`/`none`。
65
+ - 有 `i18n.info` 翻譯時,DSM 主選單與視窗標題會跟著 DSM 語言切換;缺翻譯的語言顯示英文。
66
+ `synology-app.yaml` 也支援 `i18n.info`。
67
+ - `scope: install-selected` 的 DSM 視窗不再產生 `if (panels && false)` 死碼;此 scope 仍只在 App 的
68
+ 「管理員設定」核准。
69
+
3
70
  ## 0.7.1 (2026-10-10)
4
71
 
5
72
  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
+ }
@@ -0,0 +1,20 @@
1
+ import { type InfoTranslations } from "../standalone/i18n.js";
2
+ /** The English INFO text and its translations, which also name the app in DSM. */
3
+ export interface AppTitle {
4
+ displayname: string;
5
+ description: string;
6
+ info?: InfoTranslations;
7
+ }
8
+ /**
9
+ * `ui/config` title fields. With translations they are `section:key`
10
+ * references DSM resolves from `ui/texts/<lang>/strings` for the main menu
11
+ * and the window title; without, the literal English text as before.
12
+ */
13
+ export declare function uiTitleFields(app: AppTitle): {
14
+ title: string;
15
+ desc: string;
16
+ preloadTexts?: string[];
17
+ };
18
+ /** `ui/texts/<dsm lang>/strings` for English and every translated language; none without translations. */
19
+ export declare function renderUiTexts(app: AppTitle): Map<string, string>;
20
+ export declare function writeUiTexts(uiRoot: string, app: AppTitle): void;