@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
@@ -0,0 +1,42 @@
1
+ import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { DSM_LANGS, LOCALES } from "../standalone/i18n.js";
4
+ const SECTION = "synopkgland";
5
+ const TITLE = `${SECTION}:app_title`;
6
+ const DESC = `${SECTION}:app_desc`;
7
+ function translated(app) {
8
+ return Object.values(app.info ?? {}).some((text) => text?.displayname || text?.description);
9
+ }
10
+ /**
11
+ * `ui/config` title fields. With translations they are `section:key`
12
+ * references DSM resolves from `ui/texts/<lang>/strings` for the main menu
13
+ * and the window title; without, the literal English text as before.
14
+ */
15
+ export function uiTitleFields(app) {
16
+ return translated(app)
17
+ ? { title: TITLE, desc: DESC, preloadTexts: [TITLE, DESC] }
18
+ : { title: app.displayname, desc: app.description };
19
+ }
20
+ /** `ui/texts/<dsm lang>/strings` for English and every translated language; none without translations. */
21
+ export function renderUiTexts(app) {
22
+ const files = new Map();
23
+ if (!translated(app))
24
+ return files;
25
+ for (const locale of LOCALES) {
26
+ const text = locale === "en" ? undefined : app.info?.[locale];
27
+ if (locale !== "en" && !text)
28
+ continue;
29
+ const title = text?.displayname ?? app.displayname;
30
+ const desc = text?.description ?? app.description;
31
+ files.set(DSM_LANGS[locale], `[${SECTION}]\napp_title="${title}"\napp_desc="${desc}"\n`);
32
+ }
33
+ return files;
34
+ }
35
+ export function writeUiTexts(uiRoot, app) {
36
+ for (const [lang, strings] of renderUiTexts(app)) {
37
+ mkdirSync(join(uiRoot, "texts", lang), { recursive: true });
38
+ const path = join(uiRoot, "texts", lang, "strings");
39
+ writeFileSync(path, strings, "utf8");
40
+ chmodSync(path, 0o644);
41
+ }
42
+ }
package/dist/cli.js CHANGED
@@ -3,6 +3,7 @@ import { existsSync, readFileSync, renameSync } from "node:fs";
3
3
  import { resolve, dirname, join } from "node:path";
4
4
  import { findProjectRoot } from "./discovery.js";
5
5
  import { loadContract } from "./contract/load.js";
6
+ import { contractIconPath } from "./contract/icon.js";
6
7
  import { formatErrors } from "./errors.js";
7
8
  import { formatInspectReport, inspectProject } from "./inspect.js";
8
9
  import { formatPinReport, pinProjectImages } from "./images/pin.js";
@@ -16,6 +17,7 @@ import { testProject } from "./test-command.js";
16
17
  import { buildSpk } from "./build-spk/generator.js";
17
18
  import { CONTAINER_ARCH_FAMILIES, isContainerArchFamily, isNativeArchFamily, NATIVE_ARCH_FAMILIES, parseArchList, } from "./build-spk/dsm-arch.js";
18
19
  import { DEFAULT_MIN_DSM, CONTAINER_MIN_DSM } from "./build-spk/dsm-version.js";
20
+ import { iconReport } from "./build-spk/dsm-icons.js";
19
21
  import { deployProject } from "./deploy/deploy.js";
20
22
  import { planDeployment, deployStandalone } from "./deploy/plan.js";
21
23
  import { createNasHost } from "./host/dsm-client.js";
@@ -48,6 +50,17 @@ function collectKv(value, previous, flag) {
48
50
  [value.slice(0, separator)]: value.slice(separator + 1),
49
51
  };
50
52
  }
53
+ /** check's icon lines (stdout) and its single-colour warning (stderr). */
54
+ function formatIconReport(report, manifestFile) {
55
+ return {
56
+ lines: report.source === "custom"
57
+ ? [`圖示:自訂 ${report.path}`]
58
+ : report.source === "layout"
59
+ ? ["圖示:由 layout metadata 的 PACKAGE_ICON*.PNG 提供(若有)", `info:在 ${manifestFile} 設定 icon: 可改由 CLI 產生 Package Center 圖示。`]
60
+ : ["圖示:SDK 預設圖示", `info:在 ${manifestFile} 設定 icon: <正方形 PNG(至少 256×256)或 icon_<size>.png 目錄> 換成自己的圖示。`],
61
+ ...(report.solid ? { warning: "warning:圖示只有單一顏色,Package Center 與 DSM 主選單會像一個色塊;建議換成有圖形的圖示。" } : {}),
62
+ };
63
+ }
51
64
  function formatSpkInspection(spk, report) {
52
65
  return [
53
66
  `SPK:${spk}`,
@@ -55,6 +68,7 @@ function formatSpkInspection(spk, report) {
55
68
  `arch:${report.arch ?? "?"} runtime:${report.kind ?? "?"}`,
56
69
  `簽署:${report.signed ? "有 Synology 簽章" : "未簽署(Package Center 需允許任何發行者)"}`,
57
70
  `CGI:${report.cgi ?? "無"}`,
71
+ ...(report.icon ? [`圖示:${report.icon.source === "default" ? "SDK 預設圖示" : report.icon.source === "custom" ? `自訂(PACKAGE_ICON_256 sha256 ${report.icon.sha256.slice(0, 12)}…)` : "無 PACKAGE_ICON"}`] : []),
58
72
  ...(report.entry === "admin-only" ? ["入口:entry: admin-only,只有 DSM 管理員能開啟與呼叫任何 route(含 SDK routes)"] : []),
59
73
  ...(report.entry === "app-privilege" ? ["入口:entry: app-privilege,由 DSM 控制台「應用程式權限」決定誰能使用"] : []),
60
74
  ...(report.portal ? [`Application Portal:預設別名 /${report.portal.alias}/(${report.portal.appId}),由管理員套用後啟用`] : []),
@@ -153,10 +167,16 @@ export async function runCli(argv) {
153
167
  const loaded = isNativeLayout(standalone) ? loadNativeLayout(standalone) : loadStandaloneManifest(standalone);
154
168
  if (!loaded.ok)
155
169
  throw failure(loaded.errors[0].code, loaded.errors.map((e) => `[${e.code}] ${e.message}`).join("\n"), "將 dsm-package.yaml 更新為 schema: 2,修正宣告後重試。");
170
+ const declared = "icon" in loaded.manifest && loaded.manifest.icon ? resolve(standalone, loaded.manifest.icon) : undefined;
171
+ const icon = isNativeLayout(standalone) && !declared ? { source: "layout", solid: false } : iconReport(standalone, declared);
172
+ const iconText = formatIconReport(icon, "dsm-package.yaml");
173
+ if (iconText.warning)
174
+ console.error(iconText.warning);
156
175
  emit("check", {
157
176
  package: loaded.manifest.package,
158
177
  capabilities: "capabilities" in loaded.manifest ? loaded.manifest.capabilities : {},
159
- }, "驗證通過。");
178
+ icon,
179
+ }, ["驗證通過。", ...iconText.lines].join("\n"));
160
180
  return;
161
181
  }
162
182
  const project = requireProject(rootOf(command));
@@ -166,7 +186,11 @@ export async function runCli(argv) {
166
186
  if (!result.ok)
167
187
  throw failure(result.errors[0].code, formatErrors(result.errors), result.errors[0].suggestion);
168
188
  const report = inspectProject(project);
169
- emit("check", { report, warnings: result.warnings ?? [] }, `驗證通過。\n${formatInspectReport(report)}`);
189
+ const icon = iconReport(project.root, contractIconPath(loadContract(project.contractPath).contract, project.root));
190
+ const iconText = formatIconReport(icon, "synology-app.yaml");
191
+ if (iconText.warning)
192
+ console.error(iconText.warning);
193
+ emit("check", { report, warnings: result.warnings ?? [], icon }, [`驗證通過。`, formatInspectReport(report), ...iconText.lines].join("\n"));
170
194
  });
171
195
  program
172
196
  .command("inspect <spk>")
@@ -0,0 +1,5 @@
1
+ import type { AppContract, ValidationError } from "../types.js";
2
+ import { type ParsedContract } from "./load.js";
3
+ import type { AppTitle } from "../build-spk/ui-texts.js";
4
+ export declare function contractAppTitle(contract: AppContract): AppTitle;
5
+ export declare function validateContractI18n(parsed: ParsedContract, contractFile: string): ValidationError[];
@@ -0,0 +1,45 @@
1
+ import { contractSpan, makeError } from "../errors.js";
2
+ import { getValuePosition } from "./load.js";
3
+ import { readInfoTranslations } from "../standalone/i18n.js";
4
+ /** Same rule as dsm-package.yaml text: INFO and DSM strings files quote with " and cannot escape. */
5
+ function plain(value) {
6
+ return typeof value === "string" && value.trim() !== "" && !/[\r\n"\\]/.test(value);
7
+ }
8
+ function isRecord(value) {
9
+ return typeof value === "object" && value !== null && !Array.isArray(value);
10
+ }
11
+ /** `i18n.info` as validation accepted it; undefined when absent or invalid. */
12
+ function contractInfo(contract) {
13
+ if (!isRecord(contract.i18n))
14
+ return undefined;
15
+ return readInfoTranslations(contract.i18n.info, { invalid: () => undefined, text: (value) => (plain(value) ? value : undefined) });
16
+ }
17
+ export function contractAppTitle(contract) {
18
+ return {
19
+ displayname: String(contract.displayname ?? contract.package),
20
+ description: String(contract.description ?? ""),
21
+ info: contractInfo(contract),
22
+ };
23
+ }
24
+ export function validateContractI18n(parsed, contractFile) {
25
+ const { contract, doc } = parsed;
26
+ if (contract.i18n === undefined)
27
+ return [];
28
+ const errors = [];
29
+ const invalid = (why) => {
30
+ const pos = getValuePosition(doc, ["i18n"]) ?? { line: 1, column: 1 };
31
+ errors.push(makeError("CONTRACT_I18N_INVALID", contractSpan(contractFile, pos.line, pos.column, "i18n"), `i18n ${why}`, "i18n 只接受 info;info 以 zh-TW、ja 等支援語言為 key,值為單行、不含 \" 或 \\ 的 displayname / description(英文寫在頂層)。"));
32
+ return undefined;
33
+ };
34
+ const text = (value, field) => plain(value) ? value : invalid(`${field} 必須是單行、非空且不含 " 或 \\ 的文字。`);
35
+ if (!isRecord(contract.i18n) || Object.keys(contract.i18n).some((key) => key !== "info")) {
36
+ return invalid("只接受 info(各語言的 displayname 與 description)。") ?? errors;
37
+ }
38
+ const info = readInfoTranslations(contract.i18n.info, { invalid: (why) => invalid(`.info ${why}`), text });
39
+ if (info && Object.keys(info).length > 0) {
40
+ // DSM resolves the window title from ui/texts strings, which carry the English text too.
41
+ text(contract.displayname, "displayname");
42
+ text(contract.description, "description");
43
+ }
44
+ return errors;
45
+ }
@@ -0,0 +1,5 @@
1
+ import type { AppContract, ValidationError } from "../types.js";
2
+ import { type ParsedContract } from "./load.js";
3
+ /** `icon` resolved against the project root; validation keeps it inside the project. */
4
+ export declare function contractIconPath(contract: AppContract, root: string): string | undefined;
5
+ export declare function validateContractIcon(parsed: ParsedContract, contractFile: string): ValidationError[];
@@ -0,0 +1,27 @@
1
+ import { dirname, isAbsolute, relative, resolve } from "node:path";
2
+ import { contractSpan, makeError } from "../errors.js";
3
+ import { getValuePosition } from "./load.js";
4
+ import { renderPackageIcons } from "../build-spk/dsm-icons.js";
5
+ /** `icon` resolved against the project root; validation keeps it inside the project. */
6
+ export function contractIconPath(contract, root) {
7
+ return typeof contract.icon === "string" ? resolve(root, contract.icon) : undefined;
8
+ }
9
+ export function validateContractIcon(parsed, contractFile) {
10
+ const { contract, doc, contractPath } = parsed;
11
+ if (contract.icon === undefined || !contractPath)
12
+ return [];
13
+ const pos = getValuePosition(doc, ["icon"]) ?? { line: 1, column: 1 };
14
+ const invalid = (message) => [makeError("CONTRACT_ICON_INVALID", contractSpan(contractFile, pos.line, pos.column, "icon"), `icon:${message}`, "icon 填專案內的正方形 PNG(至少 256×256),或含 icon_256.png 的 icon_<size>.png 目錄;省略則使用 SDK 預設圖示。")];
15
+ const root = dirname(contractPath);
16
+ const rel = typeof contract.icon === "string" && contract.icon.trim() !== "" ? relative(root, resolve(root, contract.icon)) : undefined;
17
+ if (rel === undefined || isAbsolute(String(contract.icon)) || rel.startsWith("..") || isAbsolute(rel)) {
18
+ return invalid(`「${String(contract.icon)}」必須是位於專案目錄內的相對路徑。`);
19
+ }
20
+ try {
21
+ renderPackageIcons(resolve(root, rel));
22
+ return [];
23
+ }
24
+ catch (error) {
25
+ return invalid(error.message);
26
+ }
27
+ }
@@ -5,6 +5,8 @@ import { collectInstallParameters, getValuePosition, stringViolations, } from ".
5
5
  import { validateContractSchema } from "./schema-validate.js";
6
6
  import { DSM_GROUP_NAME, isWebAccessMode, isWebLaunchMode, readWebIntegration } from "./web-launch.js";
7
7
  import { contractPortalSupported, readPortalDeclaration } from "../portal/alias.js";
8
+ import { validateContractIcon } from "./icon.js";
9
+ import { validateContractI18n } from "./i18n.js";
8
10
  import { loadCompose } from "../compose/analyze.js";
9
11
  import { collectPosixAllSharesParameters, isPosixAllSharesType, validatePosixAllSharesSources, } from "./posix-all-shares.js";
10
12
  import { getErrorCode } from "../error-registry.js";
@@ -56,6 +58,8 @@ export function validateContract(parsed, contractFile) {
56
58
  errors.push(...validateWebLaunch(parsed, contractFile));
57
59
  errors.push(...validateWebAccess(parsed, contractFile));
58
60
  errors.push(...validateWebPortal(parsed, contractFile));
61
+ errors.push(...validateContractIcon(parsed, contractFile));
62
+ errors.push(...validateContractI18n(parsed, contractFile));
59
63
  return errors;
60
64
  }
61
65
  function validatePosixAllShares(parsed, contractFile) {
@@ -1,4 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { uiTitleFields } from "../build-spk/ui-texts.js";
3
+ import { contractAppTitle } from "./i18n.js";
2
4
  /** DSM Web Station portal alias max length (error 1311 above this). */
3
5
  export const WEB_STATION_ALIAS_MAX = 20;
4
6
  export const WEB_LAUNCH_MODES = ["dsm-window", "new-page"];
@@ -139,13 +141,15 @@ export function renderWebStationResource(contract) {
139
141
  }
140
142
  export function renderDsmWindowConfig(contract) {
141
143
  const packageId = String(contract.package);
144
+ const { title, desc, preloadTexts } = uiTitleFields(contractAppTitle(contract));
142
145
  return `${JSON.stringify({
143
146
  ".url": {
144
147
  [packageId]: {
145
148
  type: "url",
146
149
  allUsers: true,
147
- title: String(contract.displayname ?? packageId),
148
- desc: String(contract.description ?? ""),
150
+ title,
151
+ desc,
152
+ ...(preloadTexts ? { preloadTexts } : {}),
149
153
  icon: "images/app_{0}.png",
150
154
  url: trustedAppEntryPath(contract),
151
155
  },
@@ -703,3 +703,6 @@ ERROR_REGISTRY["STANDALONE_ENTRY_INVALID"] = { "code": "STANDALONE_ENTRY_INVALID
703
703
  ERROR_REGISTRY["STANDALONE_ENTRY_WIDENED"] = { "code": "STANDALONE_ENTRY_WIDENED", "description": "entry: admin-only 與放寬權限的 route 設定衝突。", "suggestion": "把 permissions.json 中 authenticated 或 public 的 route 改為 administrators;要開放給一般使用者請改用 entry: app-privilege 或省略 entry。" };
704
704
  ERROR_REGISTRY["STANDALONE_ENTRY_UNSUPPORTED"] = { "code": "STANDALONE_ENTRY_UNSUPPORTED", "description": "backend 無法強制 entry 模式。", "suggestion": "使用 Go backend,複製 SDK 的 internal/dsm entry gate 並在 cmd/app/main.go 呼叫 dsm.NewEntryGate。" };
705
705
  ERROR_REGISTRY["DSM_TLS_UNTRUSTED"] = { "code": "DSM_TLS_UNTRUSTED", "description": "DSM 的 TLS 憑證不受這台電腦信任(自簽、過期或名稱不符)。", "suggestion": "把 NAS 憑證或其 CA 匯出成 PEM,用 NODE_EXTRA_CA_CERTS=<該檔> 執行;或讓 DSM 使用與 host profile 名稱相符的受信任憑證。不要關閉憑證驗證。" };
706
+ ERROR_REGISTRY["STANDALONE_ICON_INVALID"] = { "code": "STANDALONE_ICON_INVALID", "description": "dsm-package.yaml 的 icon 不是可用的套件圖示。", "suggestion": "icon 填專案內的正方形 PNG(至少 256×256),或含 icon_256.png 的 icon_<size>.png 目錄;省略則使用 SDK 預設圖示。" };
707
+ ERROR_REGISTRY["CONTRACT_ICON_INVALID"] = { "code": "CONTRACT_ICON_INVALID", "description": "synology-app.yaml 的 icon 不是可用的套件圖示。", "suggestion": "icon 填專案內的正方形 PNG(至少 256×256),或含 icon_256.png 的 icon_<size>.png 目錄;省略則使用 SDK 預設圖示。" };
708
+ ERROR_REGISTRY["CONTRACT_I18N_INVALID"] = { "code": "CONTRACT_I18N_INVALID", "description": "synology-app.yaml 的 i18n 設定不合法。", "suggestion": "i18n 只接受 info;info 以 zh-TW、ja 等支援語言為 key,值為單行、不含 \" 或 \\ 的 displayname / description(英文寫在頂層)。" };
@@ -1,4 +1,5 @@
1
1
  import type { EntryMode, StandaloneManifest } from "./manifest.js";
2
+ import { type AppTitle } from "../build-spk/ui-texts.js";
2
3
  /** Same path the generated wrapper POSTs to, and the payload location of the CGI. */
3
4
  export declare const CGI_PAYLOAD_PATH = "ui/cgi/api.cgi";
4
5
  export declare function cgiUrl(packageId: string): string;
@@ -16,6 +17,7 @@ export declare const I18N_RUNTIME: string;
16
17
  export declare function windowScriptName(manifest: Pick<StandaloneManifest, "build">): string;
17
18
  /** The launcher fields an entry mode fixes; inspect checks a built SPK against them. */
18
19
  export declare function entryLauncherAccess(entry: EntryMode): Record<string, unknown>;
20
+ export declare function appTitle(manifest: StandaloneManifest): AppTitle;
19
21
  /** `ui/config`: registers the DSM main-menu app and its window class. */
20
22
  export declare function renderUiConfig(manifest: StandaloneManifest): string;
21
23
  /**
@@ -4,6 +4,7 @@ import { PASSWORD_CONFIRM, ROOT_PANEL, ROOT_RUNTIME, rootTasks } from "./root-wi
4
4
  import { HOST_ADAPTER_RUNTIME } from "./host-adapter.js";
5
5
  import { permitsOrdinaryUsers } from "./api-permissions.js";
6
6
  import { PORTAL_RUNTIME } from "./portal-window.js";
7
+ import { uiTitleFields } from "../build-spk/ui-texts.js";
7
8
  /** Same path the generated wrapper POSTs to, and the payload location of the CGI. */
8
9
  export const CGI_PAYLOAD_PATH = "ui/cgi/api.cgi";
9
10
  export function cgiUrl(packageId) {
@@ -62,15 +63,20 @@ export function entryLauncherAccess(entry) {
62
63
  ? { allUsers: false }
63
64
  : { allUsers: true, grantPrivilege: "all", advanceGrantPrivilege: true };
64
65
  }
66
+ export function appTitle(manifest) {
67
+ return { displayname: manifest.displayname, description: manifest.description, info: manifest.i18n?.info };
68
+ }
65
69
  /** `ui/config`: registers the DSM main-menu app and its window class. */
66
70
  export function renderUiConfig(manifest) {
67
71
  const ns = `SYNO.SDS.${manifest.package}`;
72
+ const { title, desc, preloadTexts } = uiTitleFields(appTitle(manifest));
68
73
  return `${JSON.stringify({
69
74
  [windowScriptName(manifest)]: {
70
75
  [`${ns}.Instance`]: {
71
76
  type: "app",
72
- title: manifest.displayname,
73
- desc: manifest.description,
77
+ title,
78
+ desc,
79
+ ...(preloadTexts ? { preloadTexts } : {}),
74
80
  icon: "images/app_{0}.png",
75
81
  appWindow: `${ns}.MainWindow`,
76
82
  allowMultiInstance: false,
@@ -79,7 +85,7 @@ export function renderUiConfig(manifest) {
79
85
  },
80
86
  [`${ns}.MainWindow`]: {
81
87
  type: "lib",
82
- title: manifest.displayname,
88
+ title,
83
89
  icon: "images/app_{0}.png",
84
90
  depend: [],
85
91
  },
@@ -300,6 +306,14 @@ const ROOT_PANEL_CSS = [
300
306
  ".dsmapp-rootpanel-primary{background:#057feb;color:#fff;border:1px solid #057feb;border-radius:3px}",
301
307
  ".dsmapp-rootpanel-message{color:#c0392b}.dsmapp-rootpanel-hint{color:#526070;font-size:12px}",
302
308
  ].join("");
309
+ /**
310
+ * An install-selected delegate scope is approved only in the app's settings: the
311
+ * approval carries the scope the administrator picks there, and the window panel
312
+ * has no scope picker.
313
+ */
314
+ function settingsOnlyApproval(manifest) {
315
+ return manifest.ui.build !== undefined && manifest.capabilities.delegate?.scope === "install-selected";
316
+ }
303
317
  /**
304
318
  * The capabilities.rootOps: true additions to the window: the exact tasks and panel
305
319
  * text, the access runtime wired to DSM's own password confirmation and the
@@ -337,10 +351,10 @@ ${PASSWORD_CONFIRM}
337
351
  call: ${ns}.call,
338
352
  wait: function (ms) { return new Promise(function (resolve) { setTimeout(resolve, ms); }); }
339
353
  });
340
- var panels = me.body && me.body.dom.querySelector(".dsmapp-rootpanels");
341
- if (panels && ${manifest.ui.build !== undefined && manifest.capabilities.delegate?.scope === "install-selected" ? "false" : "true"}) {
354
+ ${settingsOnlyApproval(manifest) ? "" : ` var panels = me.body && me.body.dom.querySelector(".dsmapp-rootpanels");
355
+ if (panels) {
342
356
  ${ns}.mountRootPanel(panels, access, null, ${ns}.rootTasks, session().admin);
343
357
  }
344
- `,
358
+ `}`,
345
359
  };
346
360
  }
@@ -18,6 +18,18 @@ export type CatalogsResult = {
18
18
  ok: false;
19
19
  errors: StandaloneError[];
20
20
  };
21
+ export interface InfoText {
22
+ displayname?: string;
23
+ description?: string;
24
+ }
25
+ /** INFO `displayname_<dsm lang>` / `description_<dsm lang>`; English stays top level. */
26
+ export type InfoTranslations = Partial<Record<Locale, InfoText>>;
27
+ export interface InfoTextChecks {
28
+ invalid(why: string): undefined;
29
+ /** The plain single-line text INFO and DSM strings files can carry, or undefined after reporting. */
30
+ text(value: unknown, field: string): string | undefined;
31
+ }
32
+ export declare function readInfoTranslations(value: unknown, check: InfoTextChecks): InfoTranslations | undefined;
21
33
  export declare function isLocale(value: string): value is Locale;
22
34
  /** `<dir>/<locale>.json` for each shipped locale; en.json is required. */
23
35
  export declare function loadCatalogs(dir: string): CatalogsResult;
@@ -17,6 +17,36 @@ export const DSM_LANGS = {
17
17
  es: "spn",
18
18
  it: "ita",
19
19
  };
20
+ const INFO_TEXT_FIELDS = new Set(["displayname", "description"]);
21
+ export function readInfoTranslations(value, check) {
22
+ if (value === undefined) {
23
+ return {};
24
+ }
25
+ if (!isRecord(value)) {
26
+ return check.invalid("必須以語言為 key,例如 zh-TW: { displayname, description }。");
27
+ }
28
+ const info = {};
29
+ for (const [locale, text] of Object.entries(value)) {
30
+ if (!isLocale(locale) || locale === "en") {
31
+ return check.invalid(`的「${locale}」不是支援的翻譯語言;英文寫在頂層 displayname / description。`);
32
+ }
33
+ if (!isRecord(text) || Object.keys(text).some((key) => !INFO_TEXT_FIELDS.has(key))) {
34
+ return check.invalid(`.${locale} 只接受 displayname 與 description。`);
35
+ }
36
+ const entry = {};
37
+ for (const field of INFO_TEXT_FIELDS) {
38
+ if (text[field] !== undefined) {
39
+ const checked = check.text(text[field], `i18n.info.${locale}.${field}`);
40
+ if (!checked) {
41
+ return undefined;
42
+ }
43
+ entry[field] = checked;
44
+ }
45
+ }
46
+ info[locale] = entry;
47
+ }
48
+ return info;
49
+ }
20
50
  const MESSAGE_KEY = /^[A-Za-z][A-Za-z0-9_-]*(\.[A-Za-z0-9_-]+)*$/;
21
51
  export function isLocale(value) {
22
52
  return LOCALES.includes(value);
@@ -30,6 +30,11 @@ export interface SpkInspection {
30
30
  };
31
31
  /** Declared `entry` mode; `admin-only` is the administrator floor of every route. */
32
32
  entry?: EntryMode;
33
+ /** PACKAGE_ICON_256.PNG: the SDK default, a custom icon (by digest) or none. */
34
+ icon?: {
35
+ source: "default" | "custom" | "none";
36
+ sha256?: string;
37
+ };
33
38
  }
34
39
  /** Reads an SPK without installing it and reports how it would run on DSM. */
35
40
  export declare function inspectStandaloneSpk(spkPath: string): Promise<SpkInspection>;
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { inspectNativeLayout } from "./native-layout.js";
2
3
  import { inspectExecutor, inspectExecutorAnchor } from "../runtime/executor-inspect.js";
3
4
  import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
@@ -20,6 +21,8 @@ import { listPortExposures, loadCompose } from "../compose/analyze.js";
20
21
  import { uninstallCleanup } from "../runtime/uninstall-cleanup.js";
21
22
  import { PORTAL_JSON_RELPATH, PORTAL_MUSTACHE_RELPATH, portalResource, renderPortalJson, renderPortalLifecycle, renderPortalMustache, renderPortalWizardStep } from "../portal/package.js";
22
23
  import { isPortalAlias } from "../portal/alias.js";
24
+ import { defaultPackageIcons, MAX_ICON_BYTES } from "../build-spk/dsm-icons.js";
25
+ import { decodePng } from "../build-spk/png.js";
23
26
  const ROOT_SCRIPT_HINTS = [/\bsudo\b/, /\bchown\s+(-\w+\s+)*root\b/, /chmod\s+[ugo]*\+s\b/, /chmod\s+[2467][0-7]{3}\b/];
24
27
  /** Reads an SPK without installing it and reports how it would run on DSM. */
25
28
  export async function inspectStandaloneSpk(spkPath) {
@@ -41,8 +44,9 @@ export async function inspectSpk(spkPath, expectedKind) {
41
44
  for (const key of ["package", "version", "arch", "os_min_ver", "checksum"])
42
45
  if (!info[key])
43
46
  findings.push(`Missing INFO ${key}`);
47
+ const icon = inspectPackageIcons(dir, findings);
44
48
  return { kind: "native-layout", package: info.package, version: info.version, arch: info.arch,
45
- signed: existsSync(join(dir, "syno_signature.asc")), payload, findings, capabilities: [] };
49
+ signed: existsSync(join(dir, "syno_signature.asc")), payload, findings, capabilities: [], icon };
46
50
  }
47
51
  const payload = await listPayload(join(dir, "package.tgz"), findings);
48
52
  const paths = new Set(payload.map(entry => entry.path));
@@ -112,6 +116,7 @@ export async function inspectSpk(spkPath, expectedKind) {
112
116
  await checkEntryLauncher(dir, info.package, metadata.entry, findings);
113
117
  if (standalone)
114
118
  checkSharedFolder(dir, findings, info.package, sharedFolder, executor?.policy.scope === 'install-selected', portal);
119
+ const icon = inspectPackageIcons(dir, findings);
115
120
  const cgiEntry = payload.find((entry) => entry.path === CGI_PAYLOAD_PATH);
116
121
  if (standalone && !cgiEntry) {
117
122
  findings.push(`payload 沒有 ${CGI_PAYLOAD_PATH}`);
@@ -121,6 +126,7 @@ export async function inspectSpk(spkPath, expectedKind) {
121
126
  }
122
127
  return {
123
128
  kind,
129
+ icon,
124
130
  ...(executor ? { executor } : {}),
125
131
  ...(portal ? { portal: { appId: portal.appId, alias: portal.alias } } : {}),
126
132
  ...(metadata.entry ? { entry: metadata.entry } : {}),
@@ -156,6 +162,35 @@ async function checkEntryLauncher(dir, pkg, entry, findings) {
156
162
  findings.push(`ENTRY_LAUNCHER_MISMATCH sdk-package.json 宣告 entry: ${entry},但 ui/config 的 launcher 不是 ${JSON.stringify(expected)};請用 synopkgland build 重新產生`);
157
163
  }
158
164
  }
165
+ /** Package Center shows PACKAGE_ICON at 64 and 256 px; the package source refuses a release whose icon is over 256 KiB. */
166
+ function inspectPackageIcons(dir, findings) {
167
+ for (const [name, size] of [["PACKAGE_ICON.PNG", 64], ["PACKAGE_ICON_256.PNG", 256]]) {
168
+ const bytes = readOptionalBytes(join(dir, name));
169
+ if (!bytes)
170
+ continue;
171
+ if (bytes.length > MAX_ICON_BYTES) {
172
+ findings.push(`${name} 有 ${bytes.length} bytes,超過 package source 的 256 KiB 上限;上架後整個版本會被拒收`);
173
+ continue;
174
+ }
175
+ try {
176
+ const image = decodePng(bytes);
177
+ if (image.width !== size || image.height !== size)
178
+ findings.push(`${name} 是 ${image.width}×${image.height},Package Center 需要 ${size}×${size}`);
179
+ }
180
+ catch (error) {
181
+ findings.push(`${name}:${error.message}`);
182
+ }
183
+ }
184
+ const big = readOptionalBytes(join(dir, "PACKAGE_ICON_256.PNG"));
185
+ if (!big)
186
+ return { source: "none" };
187
+ return big.equals(defaultPackageIcons().packageIcon256)
188
+ ? { source: "default" }
189
+ : { source: "custom", sha256: createHash("sha256").update(big).digest("hex") };
190
+ }
191
+ function readOptionalBytes(path) {
192
+ return existsSync(path) ? readFileSync(path) : undefined;
193
+ }
159
194
  const OS_MIN_VER = /^7\.\d+(\.\d+)?-\d+$/;
160
195
  /** INFO os_min_ver and arch must be values DSM accepts, and arch must match the shipped CGI. */
161
196
  async function checkArch(dir, info, kind, hasCgi, findings) {
@@ -2,7 +2,8 @@ import { type PackageMetadata } from "../package-metadata.js";
2
2
  import { type DelegateCapability } from "../runtime/executor-policy.js";
3
3
  import type { ExecutorPins } from "../runtime/executor-tasks.js";
4
4
  import { type NativeArchFamily } from "../build-spk/dsm-arch.js";
5
- import { type Catalogs, type Locale } from "./i18n.js";
5
+ import { type Catalogs, type InfoTranslations } from "./i18n.js";
6
+ export type { InfoText } from "./i18n.js";
6
7
  import { type RootOp } from "./root-ops.js";
7
8
  import { type Daemon } from "./daemon.js";
8
9
  import { type Background } from "./background.js";
@@ -22,15 +23,11 @@ export type StandaloneBackend = {
22
23
  kind: "binary";
23
24
  files: Partial<Record<NativeArchFamily, string>>;
24
25
  };
25
- export interface InfoText {
26
- displayname?: string;
27
- description?: string;
28
- }
29
26
  export interface StandaloneI18n {
30
27
  /** UI messages per locale, embedded into the DSM window. */
31
28
  catalogs?: Catalogs;
32
29
  /** INFO `displayname_<dsm lang>` / `description_<dsm lang>`. */
33
- info: Partial<Record<Locale, InfoText>>;
30
+ info: InfoTranslations;
34
31
  }
35
32
  export interface StandaloneManifest {
36
33
  metadata?: PackageMetadata;
@@ -69,6 +66,8 @@ export interface StandaloneManifest {
69
66
  entry?: EntryMode;
70
67
  /** `portal`: registers the DSM Application Portal entry; an administrator activates it. */
71
68
  portal?: PortalDeclaration;
69
+ /** `icon`: a square PNG of at least 256 px, or a directory of icon_<size>.png; absent uses the SDK default. */
70
+ icon?: string;
72
71
  }
73
72
  /**
74
73
  * `admin-only`: DSM administrators only. `app-privilege`: administrators and
@@ -4,7 +4,7 @@ import { existsSync, readFileSync } from "node:fs";
4
4
  import { isAbsolute, join, relative, resolve } from "node:path";
5
5
  import { parse } from "yaml";
6
6
  import { isNativeArchFamily } from "../build-spk/dsm-arch.js";
7
- import { checkGlossary, isLocale, loadCatalogs } from "./i18n.js";
7
+ import { checkGlossary, loadCatalogs, readInfoTranslations } from "./i18n.js";
8
8
  import { loadRootOps, ROOT_OPS_DIR } from "./root-ops.js";
9
9
  import { readDaemon } from "./daemon.js";
10
10
  import { readBackground } from "./background.js";
@@ -12,6 +12,7 @@ import { DEFAULT_MIN_DSM, isDsmAtLeast, PORTAL_MIN_DSM, portalMinDsmMessage } fr
12
12
  import { readSharedFolder } from "./shared-folder.js";
13
13
  import { floorAtAdministrators, permitsOrdinaryUsers, readApiPermissions } from "./api-permissions.js";
14
14
  import { readPortalDeclaration } from "../portal/alias.js";
15
+ import { renderPackageIcons } from "../build-spk/dsm-icons.js";
15
16
  /**
16
17
  * `dsm-package.yaml`: a Standalone Package, i.e. an unsigned native SPK that
17
18
  * runs as its own DSM package account with a CGI backend and a DSM window UI.
@@ -19,10 +20,9 @@ import { readPortalDeclaration } from "../portal/alias.js";
19
20
  export const STANDALONE_MANIFEST = "dsm-package.yaml";
20
21
  export const KNOWN_FIELDS = new Set([
21
22
  "schema", "package", "version", "build", "displayname", "description",
22
- "metadata", "maintainer", "os_min_ver", "arches", "backend", "ui", "capabilities", "i18n", "daemon", "install", "entry", "portal",
23
+ "metadata", "maintainer", "os_min_ver", "arches", "backend", "ui", "capabilities", "i18n", "daemon", "install", "entry", "portal", "icon",
23
24
  ]);
24
25
  const I18N_FIELDS = new Set(["messages", "glossary", "info"]);
25
- const INFO_TEXT_FIELDS = new Set(["displayname", "description"]);
26
26
  const PACKAGE_ID = /^[A-Za-z][A-Za-z0-9]{1,63}$/;
27
27
  const VERSION = /^\d+(\.\d+){0,3}$/;
28
28
  const OS_MIN_VER = /^\d+\.\d+(\.\d+)?-\d+$/;
@@ -154,6 +154,7 @@ function readManifest(raw, c) {
154
154
  if (capabilities.delegate?.scope === "install-selected" && ui && !ui.build)
155
155
  c.fail("CAPABILITY_COMBINATION_INVALID", "delegate scope install-selected 只能在 Vue 面板確認;plain UI 不支援,請改用 vue UI(init --ui vue)。");
156
156
  const i18n = raw.i18n === undefined ? undefined : readI18n(raw.i18n, c);
157
+ const icon = raw.icon === undefined ? undefined : readIcon(raw.icon, c);
157
158
  const portal = raw.portal === undefined || !packageId ? undefined : readPortalDeclaration(raw.portal, packageId)
158
159
  ?? c.fail("STANDALONE_PORTAL_INVALID", "portal 必須是 {} 或 { alias: <小寫英數與 -,1–64 字,不可為 DSM 保留路徑> }。");
159
160
  if (portal && osMinVer && !isDsmAtLeast(osMinVer, PORTAL_MIN_DSM)) {
@@ -185,8 +186,21 @@ function readManifest(raw, c) {
185
186
  ...(sharedFolder ? { sharedFolder } : {}),
186
187
  ...(entry ? { entry } : {}),
187
188
  ...(portal ? { portal } : {}),
189
+ ...(icon ? { icon } : {}),
188
190
  };
189
191
  }
192
+ function readIcon(value, c) {
193
+ const path = c.path(value, "icon");
194
+ if (!path)
195
+ return undefined;
196
+ try {
197
+ renderPackageIcons(path);
198
+ return path;
199
+ }
200
+ catch (error) {
201
+ return c.fail("STANDALONE_ICON_INVALID", `icon:${error.message}`);
202
+ }
203
+ }
190
204
  /**
191
205
  * The CGI enforces the entry mode only when the project's backend carries the
192
206
  * SDK gate and main wires it; `-X main.entry` on an older main would be
@@ -282,7 +296,10 @@ function readI18n(value, c) {
282
296
  if (!isRecord(value) || Object.keys(value).some((key) => !I18N_FIELDS.has(key))) {
283
297
  return invalid("只接受 messages(訊息目錄)、glossary(用語表)與 info(各語言 INFO 文字)。");
284
298
  }
285
- const info = readInfoText(value.info, c);
299
+ const info = readInfoTranslations(value.info, {
300
+ invalid: (why) => c.fail("STANDALONE_I18N_INVALID", `i18n.info ${why}`),
301
+ text: (text, field) => c.text(text, field),
302
+ });
286
303
  if (value.messages === undefined) {
287
304
  return value.glossary === undefined ? info && { info } : invalid("glossary 需要搭配 messages。");
288
305
  }
@@ -300,36 +317,6 @@ function readI18n(value, c) {
300
317
  drift.forEach((e) => c.fail(e.code, e.message));
301
318
  return info && drift.length === 0 ? { catalogs: loaded.catalogs, info } : undefined;
302
319
  }
303
- function readInfoText(value, c) {
304
- if (value === undefined) {
305
- return {};
306
- }
307
- const invalid = (why) => c.fail("STANDALONE_I18N_INVALID", `i18n.info ${why}`);
308
- if (!isRecord(value)) {
309
- return invalid("必須以語言為 key,例如 zh-TW: { displayname, description }。");
310
- }
311
- const info = {};
312
- for (const [locale, text] of Object.entries(value)) {
313
- if (!isLocale(locale) || locale === "en") {
314
- return invalid(`的「${locale}」不是支援的翻譯語言;英文寫在頂層 displayname / description。`);
315
- }
316
- if (!isRecord(text) || Object.keys(text).some((key) => !INFO_TEXT_FIELDS.has(key))) {
317
- return invalid(`.${locale} 只接受 displayname 與 description。`);
318
- }
319
- const entry = {};
320
- for (const field of INFO_TEXT_FIELDS) {
321
- if (text[field] !== undefined) {
322
- const checked = c.text(text[field], `i18n.info.${locale}.${field}`);
323
- if (!checked) {
324
- return undefined;
325
- }
326
- entry[field] = checked;
327
- }
328
- }
329
- info[locale] = entry;
330
- }
331
- return info;
332
- }
333
320
  /**
334
321
  * The nearest directory at or above `start` that holds dsm-package.yaml,
335
322
  * unless a Managed Profile project (synology-app.yaml) is found first.
@@ -37,6 +37,8 @@ export interface NativeLayout {
37
37
  backend: {
38
38
  kind: 'native';
39
39
  };
40
+ /** Project-relative package icon; the CLI then owns PACKAGE_ICON*.PNG, the payload keeps its own window icons. */
41
+ icon?: string;
40
42
  layout: {
41
43
  payload: string;
42
44
  metadata: string;