@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.
- package/CHANGELOG.md +67 -0
- package/assets/default-icon/README.md +19 -0
- package/assets/default-icon/default-icon-small.svg +9 -0
- package/assets/default-icon/default-icon.svg +10 -0
- package/assets/default-icon/icon_16.png +0 -0
- package/assets/default-icon/icon_24.png +0 -0
- package/assets/default-icon/icon_256.png +0 -0
- package/assets/default-icon/icon_32.png +0 -0
- package/assets/default-icon/icon_48.png +0 -0
- package/assets/default-icon/icon_64.png +0 -0
- package/assets/default-icon/icon_72.png +0 -0
- package/dist/build-spk/dsm-icons.d.ts +31 -2
- package/dist/build-spk/dsm-icons.js +135 -56
- package/dist/build-spk/generator.js +17 -3
- package/dist/build-spk/png.d.ts +14 -0
- package/dist/build-spk/png.js +161 -0
- package/dist/build-spk/ui-texts.d.ts +20 -0
- package/dist/build-spk/ui-texts.js +42 -0
- package/dist/cli.js +26 -2
- package/dist/contract/i18n.d.ts +5 -0
- package/dist/contract/i18n.js +45 -0
- package/dist/contract/icon.d.ts +5 -0
- package/dist/contract/icon.js +27 -0
- package/dist/contract/validate.js +4 -0
- package/dist/contract/web-launch.js +6 -2
- package/dist/error-registry.js +3 -0
- package/dist/standalone/dsm-window.d.ts +2 -0
- package/dist/standalone/dsm-window.js +20 -6
- package/dist/standalone/i18n.d.ts +12 -0
- package/dist/standalone/i18n.js +30 -0
- package/dist/standalone/inspect-spk.d.ts +5 -0
- package/dist/standalone/inspect-spk.js +36 -1
- package/dist/standalone/manifest.d.ts +5 -6
- package/dist/standalone/manifest.js +21 -34
- package/dist/standalone/native-layout.d.ts +2 -0
- package/dist/standalone/native-layout.js +22 -1
- package/dist/standalone/spk.js +7 -4
- package/dist/templates/binary/dsm-package.yaml +3 -0
- package/dist/templates/compose/synology-app.yaml +3 -0
- package/dist/templates/native-go/dsm-package.yaml +3 -0
- package/package.json +2 -1
- package/schema/synology-app.schema.json +22 -0
- package/templates/binary/dsm-package.yaml +3 -0
- package/templates/compose/synology-app.yaml +3 -0
- 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
|
-
|
|
2
|
-
export declare
|
|
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 {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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;
|