@synopackageland/cli 0.9.6 → 0.10.0

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 (111) hide show
  1. package/CHANGELOG.md +144 -5
  2. package/README.md +5 -0
  3. package/dist/add.js +7 -8
  4. package/dist/build-spk/dsm-arch.js +1 -1
  5. package/dist/build-spk/dsm-icons.d.ts +8 -1
  6. package/dist/build-spk/dsm-icons.js +26 -12
  7. package/dist/build-spk/dsm-version.d.ts +1 -1
  8. package/dist/build-spk/dsm-version.js +4 -4
  9. package/dist/build-spk/gateway.js +2 -2
  10. package/dist/build-spk/generator.js +1 -1
  11. package/dist/build-spk/png.js +16 -16
  12. package/dist/capabilities.js +1 -1
  13. package/dist/capability-overlays.d.ts +16 -5
  14. package/dist/capability-overlays.js +30 -16
  15. package/dist/cli.js +107 -107
  16. package/dist/compose/analyze.js +23 -23
  17. package/dist/compose/privilege.js +12 -12
  18. package/dist/contract/i18n.js +3 -3
  19. package/dist/contract/icon.js +2 -2
  20. package/dist/contract/posix-all-shares.js +2 -2
  21. package/dist/contract/schema-validate.js +3 -3
  22. package/dist/contract/validate.js +16 -16
  23. package/dist/contract/web-launch.d.ts +2 -1
  24. package/dist/contract/web-launch.js +3 -2
  25. package/dist/deploy/deploy.js +8 -8
  26. package/dist/deploy/homes-values.js +1 -1
  27. package/dist/deploy/identity-shared.js +1 -1
  28. package/dist/deploy/native-install-values.js +1 -1
  29. package/dist/deploy/param-values.js +4 -4
  30. package/dist/deploy/plan.d.ts +1 -1
  31. package/dist/deploy/port-preflight.js +3 -3
  32. package/dist/deploy/run-as-values.js +2 -2
  33. package/dist/deploy/share-permission-preflight.js +6 -6
  34. package/dist/deploy/share-values.js +3 -3
  35. package/dist/error-registry.js +369 -346
  36. package/dist/errors.js +1 -1
  37. package/dist/host/dsm-port-profile.d.ts +1 -1
  38. package/dist/host/dsm-port-profile.js +1 -1
  39. package/dist/info.js +11 -11
  40. package/dist/init.d.ts +2 -0
  41. package/dist/init.js +85 -38
  42. package/dist/inspect.js +5 -5
  43. package/dist/portal/alias.d.ts +1 -1
  44. package/dist/portal/alias.js +4 -4
  45. package/dist/runtime/executor-build.js +12 -1
  46. package/dist/runtime/executor-digests.json +8 -85
  47. package/dist/runtime/executor-inspect.js +11 -4
  48. package/dist/runtime/executor-policy.d.ts +9 -2
  49. package/dist/runtime/executor-policy.js +27 -8
  50. package/dist/runtime/root-programs.d.ts +47 -0
  51. package/dist/runtime/root-programs.js +153 -0
  52. package/dist/runtime/sdk-source.d.ts +5 -4
  53. package/dist/runtime/sdk-source.js +8 -4
  54. package/dist/skill.js +14 -14
  55. package/dist/spk/check-info.js +17 -17
  56. package/dist/spk/check-metadata.js +18 -18
  57. package/dist/spk/check-payload.js +10 -10
  58. package/dist/spk/rules.d.ts +57 -57
  59. package/dist/spk/rules.js +50 -50
  60. package/dist/spk/validate-spk.js +5 -5
  61. package/dist/standalone/api-permissions.js +1 -1
  62. package/dist/standalone/background.d.ts +3 -1
  63. package/dist/standalone/background.js +8 -4
  64. package/dist/standalone/binary.js +7 -7
  65. package/dist/standalone/bridge-app.d.ts +23 -0
  66. package/dist/standalone/bridge-app.js +141 -0
  67. package/dist/standalone/bridge-backend.d.ts +40 -0
  68. package/dist/standalone/bridge-backend.js +95 -0
  69. package/dist/standalone/bridge-build.d.ts +21 -0
  70. package/dist/standalone/bridge-build.js +62 -0
  71. package/dist/standalone/bridge-json.d.ts +16 -0
  72. package/dist/standalone/bridge-json.js +59 -0
  73. package/dist/standalone/bridge-runtime.d.ts +16 -0
  74. package/dist/standalone/bridge-runtime.js +46 -0
  75. package/dist/standalone/bridge-rust.d.ts +16 -0
  76. package/dist/standalone/bridge-rust.js +89 -0
  77. package/dist/standalone/build.js +18 -14
  78. package/dist/standalone/daemon.js +6 -6
  79. package/dist/standalone/dsm-window.d.ts +10 -5
  80. package/dist/standalone/dsm-window.js +64 -16
  81. package/dist/standalone/i18n.js +18 -18
  82. package/dist/standalone/inspect-bridge.d.ts +31 -0
  83. package/dist/standalone/inspect-bridge.js +111 -0
  84. package/dist/standalone/inspect-spk.d.ts +1 -1
  85. package/dist/standalone/inspect-spk.js +77 -69
  86. package/dist/standalone/lifecycle.js +22 -3
  87. package/dist/standalone/manifest.d.ts +17 -9
  88. package/dist/standalone/manifest.js +143 -65
  89. package/dist/standalone/portal-window.js +14 -7
  90. package/dist/standalone/request-timeout.d.ts +9 -0
  91. package/dist/standalone/request-timeout.js +13 -0
  92. package/dist/standalone/root-ops.d.ts +23 -2
  93. package/dist/standalone/root-ops.js +118 -13
  94. package/dist/standalone/sdk-check.js +4 -4
  95. package/dist/standalone/shared-folder.js +7 -7
  96. package/dist/standalone/spk.d.ts +2 -0
  97. package/dist/standalone/spk.js +30 -11
  98. package/dist/standalone/uninstall-hook.js +22 -4
  99. package/dist/standalone/uninstall.js +7 -7
  100. package/dist/standalone/window-size.d.ts +17 -0
  101. package/dist/standalone/window-size.js +31 -0
  102. package/dist/types.d.ts +1 -1
  103. package/dist/validate.js +13 -13
  104. package/package.json +2 -2
  105. package/schema/bridge-manifest.v1.schema.json +86 -0
  106. package/skills/reference/create.md +76 -2
  107. package/skills/reference/ui-json.md +2 -1
  108. package/skills/reference/ui-vue.md +9 -3
  109. package/skills/reference/wrap.md +1 -1
  110. package/dist/standalone/rust-build.d.ts +0 -35
  111. package/dist/standalone/rust-build.js +0 -127
package/CHANGELOG.md CHANGED
@@ -1,19 +1,158 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.10.0 (2026-10-13)
4
+
5
+ Builds with SDK 0.10.0 and templates 0.10.0: `init` writes SDK v0.10.0 (Go
6
+ module, Rust crate tag and `backend.sdk`), `@synopackageland/sdk` 0.10.0 and
7
+ `@synopackageland/ui` 0.9.4, and the Vue templates use the SDK's `AppShell`.
8
+ CLI 0.10 builds only SDK 0.10.x: the executor digest table records SDK
9
+ 0.10.0 and no longer lists 0.9.x, so an App on SDK 0.9.x is refused
10
+ (`SDK_UNSUPPORTED`) and stays on CLI 0.9.7. SDK 0.10.0's executor changed, so
11
+ a rebuilt App carries new executor and lifecycle digests and its pins are
12
+ approved again; the policy digest changes only for a policy that uses root
13
+ program settings, jobs, `stop` or `state`.
14
+
15
+ 繁中摘要:使用 SDK 0.10.0 與 templates 0.10.0(UI 0.9.4),Vue 範本改用 SDK 的
16
+ `AppShell`;新增 Python、Node、Rust App(bridge runtime)、root programs 設定、
17
+ `ui.window`、`backend.timeout_seconds`(預設 60 秒),CLI 輸出全改英文。CLI 0.10
18
+ 只接受 SDK 0.10.x,digest 表移除 0.9.x;executor 變更,重建的 App 要重新核准
19
+ executor 與 lifecycle digest。
20
+
21
+ - Breaking: every CLI message is English. Error descriptions and suggestions,
22
+ `--help` text, and check, inspect, build and deploy output no longer carry
23
+ Chinese. Only the multilingual data shipped to DSM end users keeps its
24
+ translations: the install/upgrade/uninstall wizard copy, the App UI catalogs
25
+ and the INFO `displayname`/`description` translations. A test now fails on
26
+ CJK in any other `src/` file.
27
+ - Behavior change: every App's backend request deadline is 60 seconds instead
28
+ of the SDK's 20. `bridge.json` always carries `timeout_seconds`, so the
29
+ runtime's deadline and the `deadline_ms` it sends the App follow it; a Go
30
+ CGI gets it as `-X main.requestTimeout` and keeps the SDK's 20 seconds
31
+ until its main reads that variable into `api.Timeout`. The DSM window sets
32
+ `ctx.requestTimeout` to the deadline and gives up two seconds past it (62
33
+ seconds instead of 22), also for a prebuilt binary CGI.
34
+ - `backend.timeout_seconds` (1–3600) sets another deadline for go, python,
35
+ node and rust backends. A Go main that does not read `requestTimeout` into
36
+ `api.Timeout` is refused (`STANDALONE_TIMEOUT_UNSUPPORTED`); a binary
37
+ backend refuses it.
38
+ - `ui.window: {width, height, minWidth, minHeight}` sets the DSM window's
39
+ size; omitted fields keep 960×640, at least 700×420, and a standalone
40
+ launch keeps its 320 px minimum width. Sizes outside 320–3840 × 240–2160,
41
+ or a minimum larger than the size, are refused
42
+ (`STANDALONE_UI_WINDOW_INVALID`).
43
+ - The generated DSM window knows a standalone (Portal) launch,
44
+ `_S("standalone") === true`: its minimum width drops from 700 to 320 px so
45
+ a phone can show it, and `mount` gets `ctx.fullPage` and `ctx.appIcon` (the
46
+ package's 64 px window icon) for a full-page layout and brand.
47
+ - `init -t native-python | native-node | native-rust` creates the native-go
48
+ App with a Python, Node.js (TypeScript) or Rust backend: the Go template's
49
+ UI, frontend and root program, the language's capability overlays for
50
+ `--with jobs,root-ops,delegate` (jobs also declares `files: session`, which
51
+ its example reads; delegate opens `app.status` and `report.save` to
52
+ signed-in users; Rust lists the added methods in `backend.methods`), and the
53
+ App SDK its tests import in `backend/vendor`. `--shared-folder` stays
54
+ native-go only. Capability overlays carry their permission rules.
55
+ - Breaking: `backend.kind: rust` (the Rust full SDK) is removed, and
56
+ `init -t native-rust` now creates the thin Rust App behind the bridge
57
+ runtime instead.
58
+ - `backend.runtime: python | node | rust` builds an App in another language
59
+ behind the SDK's Go runtime (spec/bridge/v1). `build` compiles
60
+ `bridge/cmd/runtime` of the App's SDK release as `ui/cgi/api.cgi` with the
61
+ sha256 of `bridge.json` and `permissions.json` bound, and ships the App in
62
+ `target/app`: Python with the SDK in `vendor/`, Node with the SDK in
63
+ `node_modules/@synopackageland/app` and an `install_dep_packages` on
64
+ Package Center's Node.js, Rust cross-compiled with `cargo zigbuild`.
65
+ Methods come from `backend.methods` or the App's `--describe`. `inspect`
66
+ checks the manifest schema, the digests, the entry, that the App is not
67
+ under `ui/`, and the Node dependencies. The `bridge` gate builds all three
68
+ for every architecture.
69
+ - Bridge Apps may declare `jobs`, `rootOps`, `portal` and an
70
+ `until-revoked` `delegate` for `files.moveFromStaging`, plus
71
+ `backend.transfers` and `backend.max_upload_bytes`; `bridge.json` carries
72
+ them. With jobs or delegate the runtime is also installed as
73
+ `target/background/worker` and DSM runs it with `--worker` as the package
74
+ account. `inspect` refuses delegate digests that are not the SPK
75
+ executor's. The Node SDK is copied as its `package.json` and `dist/`, so
76
+ the SDK checkout must be built.
77
+ - `root` in dsm-package.yaml configures root programs for the executor's
78
+ root programs, root jobs, package stop step and root state directory:
79
+ `programs.<name>` gives a params `schema` and `run` or `job` limits,
80
+ `kind: binary` takes `root/<arch>/<name>` (an ELF for each architecture,
81
+ so the policy digest differs per architecture), `stop` names the program
82
+ the executor runs at package stop and upgrade, and `state: true` asks for
83
+ the root state directory. Programs without settings keep the bare digest,
84
+ so existing policies and their digests do not change. Jobs add
85
+ `root.start` and the `root.jobs` operations. A build whose SDK executor
86
+ predates these refuses them (`EXECUTOR_ROOT_PROGRAMS_UNSUPPORTED`).
87
+ - `preupgrade` stops the executor with `executor/lifecycle stop --upgrade`;
88
+ an installed helper without the flag gets a plain stop.
89
+
90
+ - The DSM window icons are named after their content,
91
+ `images/app_<hash>_<size>.png`, and `ui/config` points at that name. DSM and
92
+ the browser cache an App icon by its URL, so after an icon change DSM kept
93
+ showing the old one (the blue square in t-0391) until the cache expired; a
94
+ new icon now has a new URL, and an unchanged icon keeps its cached one. DSM
95
+ 7.4 passes the config's icon path, with `{0}` for the size, to
96
+ `SYNO.Core.Synohdpack getHDIcon` for every `webman/3rdparty/` App, so any
97
+ file name works.
98
+ - `ctx.call(method, params, {signal})` aborts its fetch when the signal does,
99
+ so a page the user leaves really stops its request, and rejects
100
+ `{code: "request_canceled"}`. It also gives up after the SDK's
101
+ `clientTimeout(ctx.requestTimeout)`, two seconds past the backend deadline
102
+ (the backend deadline unless the App sets `ctx.requestTimeout` in ms, read
103
+ on every call), aborts the fetch and rejects `{code: "request_timeout"}`.
104
+ Before, a call waited as long as the browser did. The portal and root
105
+ panels use the same call.
106
+ - preuninst waits for the App's uninstall hook by looking it up in `/proc`
107
+ instead of `kill -0`. DSM's bash logs a failed builtin to syslog even with
108
+ `2>/dev/null`, so every uninstall with a hook logged `BASH_ERR ... kill: No
109
+ such process` twice (t-0383 B8). The wait, the timeout and the result are
110
+ unchanged; a host without `/proc` still uses `kill -0`.
111
+ - preuninst stops an uninstall hook past its timeout, and prestart waits for
112
+ a stale package user manager to exit, without a `kill` that can fail: the
113
+ hook's process group and the manager are looked up in `/proc` first, so
114
+ neither logs `BASH_ERR ... kill: No such process` once the process has
115
+ exited.
116
+ - The Application Portal panel's Italian instructions use the infinitive like
117
+ DSM ("Riprovare o usare il Pannello di controllo."), the same text as the
118
+ SDK's `i18n/portal` catalogs.
119
+ - `ctx.portal.status()` waits for a register or unregister in progress to
120
+ end, whatever its result, like the SDK's `createPortalAccess`. Before, a
121
+ status read during a slow change could show "The Application Portal entry
122
+ was not fully removed".
123
+
124
+ ## 0.9.7 (2026-10-13)
125
+
126
+ Builds with SDK 0.9.8 and templates 0.9.7: `init` writes SDK v0.9.8 (Go
127
+ module and Rust crate tag), `@synopackageland/sdk` 0.9.8 and
128
+ `@synopackageland/ui` 0.9.2. SDK 0.9.8 fixes `BackgroundPanel`, whose controls
129
+ to choose a file and submit it were hidden on DSM. The executor digest table
130
+ records SDK 0.9.8 with the same executor and lifecycle digests as 0.9.7, and
131
+ the policy digest does not change, so a rebuilt App keeps the pins it was
132
+ approved with.
133
+
134
+ 繁中摘要:使用 SDK 0.9.8 與 templates 0.9.7(UI 0.9.2)。SDK 0.9.8 修正
135
+ `BackgroundPanel` 在 DSM 上看不到選檔與送出按鈕。digest 表加入 SDK 0.9.8,
136
+ executor 與 lifecycle digest 與 0.9.7 相同,policy digest 也不變,重建的 App
137
+ 沿用原本核准的 pins。
138
+
3
139
  ## 0.9.6 (2026-10-12)
4
140
 
5
141
  Builds with SDK 0.9.7 and templates 0.9.6: `init` writes SDK v0.9.7 (Go
6
142
  module and Rust crate tag), `@synopackageland/sdk` 0.9.7 and
7
143
  `@synopackageland/ui` 0.9.2. The executor digest table records SDK 0.9.7,
8
144
  whose executor and lifecycle binary changed, so a rebuilt App carries new
9
- executor digests. SDK 0.9.7 adds `bg.jobs.pause` and `bg.jobs.resume` to the
10
- policy, so the policy digest of every App changes: after the upgrade, every
11
- App asks for approval again.
145
+ executor digests. The policy digest changes only for an App that declares
146
+ `uninstall.purge.root`, and such an App asks for approval again after the
147
+ upgrade; `bg.jobs.pause` and `bg.jobs.resume` reach the CGI route table, not
148
+ the policy. Whether new executor digests alone ask for approval again has not
149
+ been checked on a DSM yet.
12
150
 
13
151
  繁中摘要:使用 SDK 0.9.7 與 templates 0.9.6(UI 0.9.2)。executor digest 表加入
14
152
  SDK 0.9.7;executor 與 lifecycle 都有變更,重建的 App 會帶新的 executor digest。
15
- SDK 0.9.7 的 policy 新增 `bg.jobs.pause`、`bg.jobs.resume`,所有 App 的 policy
16
- digest 都會變,升級後都需要重新核准。新增解除安裝 hook(`uninstall.purge`)。
153
+ 只有宣告 `uninstall.purge.root` 的 App 的 policy digest 會變,升級後需要重新核准;
154
+ `bg.jobs.pause`、`bg.jobs.resume` 只進 CGI route 表,不影響 policy。只換 executor
155
+ digest 時是否需要重新核准,尚未在 DSM 上驗證。新增解除安裝 hook(`uninstall.purge`)。
17
156
 
18
157
  - Uninstall hook: `uninstall.purge` in dsm-package.yaml (`exec`, `args`,
19
158
  `env`, `timeout` 2..120 s, optional `root`) names the App's own command,
package/README.md CHANGED
@@ -34,12 +34,17 @@ the SPK with Package Center's Manual Install.
34
34
  | Template | Use it for | Manifest |
35
35
  | --- | --- | --- |
36
36
  | `native-go` | A Go backend with a Vue UI (or `--ui plain`) | `dsm-package.yaml` |
37
+ | `native-python`, `native-node`, `native-rust` | The `native-go` App with a Python, Node.js (TypeScript) or Rust backend | `dsm-package.yaml` |
37
38
  | `binary` | A static Linux program you build yourself | `dsm-package.yaml` |
38
39
  | `compose` | An existing Docker Compose app (`--from compose.yaml`) | `synology-app.yaml` |
39
40
 
40
41
  Existing native products with their own payload layout use `backend: {kind: native}` in
41
42
  `dsm-package.yaml`.
42
43
 
44
+ A backend in Python, Node or Rust uses `backend: {runtime: python|node|rust, ...}`: the CGI is
45
+ the SDK's Go runtime, which checks the DSM session and `permissions.json` and starts the App
46
+ from `target/app` as the package account for each request (see `skills/reference/create.md`).
47
+
43
48
  ## SDK and templates
44
49
 
45
50
  The CLI packages, deploys and checks. The app-side code is the SDK
package/dist/add.js CHANGED
@@ -11,7 +11,6 @@ import { API_PERMISSIONS_FILE } from './standalone/api-permissions.js';
11
11
  import { DEFAULT_MIN_DSM, isDsmAtLeast, PORTAL_EXECUTOR_MIN_DSM } from './build-spk/dsm-version.js';
12
12
  import { loadStandaloneManifest } from './standalone/manifest.js';
13
13
  import { PORTAL_REQUIRES_EXECUTOR } from './portal/alias.js';
14
- const NATIVE_TEMPLATE = { go: 'native-go', rust: 'native-rust' };
15
14
  function files(root, dir = root) {
16
15
  if (!existsSync(dir))
17
16
  return [];
@@ -61,7 +60,7 @@ function addPortal(root, result) {
61
60
  if (loaded.manifest.portal)
62
61
  return { ...result, unchanged: [...result.unchanged, 'portal'] };
63
62
  if (!safePath(root, 'dsm-package.yaml'))
64
- throw new Error('[ADD_FILE_CONFLICT] 既有檔案與 SDK overlay 不同:dsm-package.yaml;請先移動或合併衝突,不會覆寫。');
63
+ throw new Error('[ADD_FILE_CONFLICT] The existing file differs from the SDK overlay: dsm-package.yaml; move or merge the conflict first, nothing is overwritten.');
65
64
  const manifestPath = join(root, 'dsm-package.yaml');
66
65
  const { values, notes } = raiseOsMinVer(loaded.manifest.os_min_ver, PORTAL_EXECUTOR_MIN_DSM, 'portal');
67
66
  writeFileSync(manifestPath, setTopLevel(readFileSync(manifestPath, 'utf8'), { ...values, portal: {} }));
@@ -89,18 +88,18 @@ export function addCapabilities(root, requested) {
89
88
  if (!loaded.ok)
90
89
  throw new Error(loaded.errors.map(e => `[${e.code}] ${e.message}`).join('\n'));
91
90
  const m = loaded.manifest;
92
- if (m.backend.kind === 'binary')
93
- throw new Error('[CAPABILITY_UNSUPPORTED] add 需要 backend.kind: go 或 rust。');
94
- const template = NATIVE_TEMPLATE[m.backend.kind];
91
+ if (m.backend.kind !== 'go')
92
+ throw new Error('[CAPABILITY_UNSUPPORTED] add needs backend.kind: go.');
93
+ const template = 'native-go';
95
94
  const existing = Object.entries(capabilitySettings(names)).filter(([key]) => key in m.capabilities).map(([key]) => key);
96
95
  const unchanged = names.filter(name => existing.includes(Object.keys(capabilitySettings([name]))[0]));
97
96
  const added = names.filter(name => !unchanged.includes(name));
98
97
  if (!added.length)
99
98
  return { added, unchanged };
100
99
  if (m.daemon && added.includes('jobs') && !added.includes('delegate') && m.capabilities.delegate?.lifetime !== 'until-revoked')
101
- throw new Error('[CAPABILITY_COMBINATION_INVALID] daemon 僅可與 until-revoked delegate 固定 worker 共存。');
100
+ throw new Error('[CAPABILITY_COMBINATION_INVALID] daemon can only coexist with an until-revoked delegate fixed worker.');
102
101
  if (m.backend.dir !== join(root, 'backend'))
103
- throw new Error('[ADD_LAYOUT_UNSUPPORTED] SDK overlay 需要 backend.dir: backend。');
102
+ throw new Error('[ADD_LAYOUT_UNSUPPORTED] The SDK overlay needs backend.dir: backend.');
104
103
  const work = mkdtempSync(join(tmpdir(), 'synopkgland-add-'));
105
104
  try {
106
105
  // Generate SDK files using init's identical substitutions, without touching app-owned files.
@@ -130,7 +129,7 @@ export function addCapabilities(root, requested) {
130
129
  if (!safePath(root, 'dsm-package.yaml'))
131
130
  conflicts.push('dsm-package.yaml');
132
131
  if (conflicts.length)
133
- throw new Error(`[ADD_FILE_CONFLICT] 既有檔案與 SDK overlay 不同:${conflicts.join(', ')};請先移動或合併衝突,不會覆寫。`);
132
+ throw new Error(`[ADD_FILE_CONFLICT] Existing files differ from the SDK overlay: ${conflicts.join(', ')}; move or merge the conflict first, nothing is overwritten.`);
134
133
  const text = readFileSync(manifestPath, 'utf8');
135
134
  const capabilities = { ...parse(text).capabilities, ...nativeCapabilitySettings(added) };
136
135
  // rootOps needs DSM 7.1-42661 or later.
@@ -32,7 +32,7 @@ export function parseArchList(value) {
32
32
  const families = [];
33
33
  for (const item of value.split(",").map((part) => part.trim())) {
34
34
  if (!isNativeArchFamily(item)) {
35
- throw new Error(`--arch 只接受 ${NATIVE_ARCH_FAMILIES.join("、")}(可用逗號列出多個),收到「${value}」。`);
35
+ throw new Error(`--arch accepts only ${NATIVE_ARCH_FAMILIES.join(", ")} (list several with commas), got "${value}".`);
36
36
  }
37
37
  if (!families.includes(item)) {
38
38
  families.push(item);
@@ -1,4 +1,4 @@
1
- /** Sizes DSM loads for `images/app_{0}.png` (main menu, desktop, window, taskbar). */
1
+ /** Sizes DSM loads for the window icon template (main menu, desktop, window, taskbar). */
2
2
  export declare const WINDOW_ICON_SIZES: readonly [16, 24, 32, 48, 64, 72, 256];
3
3
  /** The package source drops a release whose PACKAGE_ICON is larger than this. */
4
4
  export declare const MAX_ICON_BYTES: number;
@@ -27,5 +27,12 @@ export interface IconReport {
27
27
  solid: boolean;
28
28
  }
29
29
  export declare function iconReport(root: string, icon: string | undefined): IconReport;
30
+ /**
31
+ * `ui/config`'s icon, relative to ui/: DSM puts the size in for {0}. DSM and
32
+ * the browser cache an icon by its URL, so the name carries the icons' content
33
+ * and a changed icon is loaded again.
34
+ */
35
+ export declare function windowIconTemplate(icons: PackageIcons): string;
36
+ export declare function windowIconFile(icons: PackageIcons, size: number): string;
30
37
  export declare function writeDsmWindowIcons(uiDir: string, icons: PackageIcons): void;
31
38
  export declare function writePackageCenterIcons(spkRoot: string, icons: PackageIcons): void;
@@ -1,8 +1,9 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
3
  import { basename, dirname, join, relative } from "node:path";
3
4
  import { fileURLToPath } from "node:url";
4
5
  import { decodePng, encodePng } from "./png.js";
5
- /** Sizes DSM loads for `images/app_{0}.png` (main menu, desktop, window, taskbar). */
6
+ /** Sizes DSM loads for the window icon template (main menu, desktop, window, taskbar). */
6
7
  export const WINDOW_ICON_SIZES = [16, 24, 32, 48, 64, 72, 256];
7
8
  /** The package source drops a release whose PACKAGE_ICON is larger than this. */
8
9
  export const MAX_ICON_BYTES = 256 * 1024;
@@ -14,7 +15,7 @@ function readPng(path, name) {
14
15
  return decodePng(readFileSync(path));
15
16
  }
16
17
  catch (error) {
17
- throw new Error(`${name}:${error.message}`);
18
+ throw new Error(`${name}: ${error.message}`);
18
19
  }
19
20
  }
20
21
  /** Area-average downscale in premultiplied alpha, so transparent edges stay clean. */
@@ -78,24 +79,24 @@ function readIconDirectory(dir) {
78
79
  continue;
79
80
  const size = Number(match[1]);
80
81
  if (!WINDOW_ICON_SIZES.includes(size)) {
81
- throw new Error(`${name}:不支援的尺寸;只接受 ${WINDOW_ICON_SIZES.map((s) => `icon_${s}.png`).join("、")}。`);
82
+ throw new Error(`${name}: unsupported size; only ${WINDOW_ICON_SIZES.map((s) => `icon_${s}.png`).join(", ")} are accepted.`);
82
83
  }
83
84
  const image = readPng(join(dir, name), name);
84
85
  if (image.width !== size || image.height !== size) {
85
- throw new Error(`${name}:尺寸是 ${image.width}×${image.height},應為 ${size}×${size}。`);
86
+ throw new Error(`${name}: the size is ${image.width}x${image.height}; it should be ${size}x${size}.`);
86
87
  }
87
88
  drawn.set(size, image);
88
89
  }
89
90
  if (!drawn.has(256))
90
- throw new Error(`圖示目錄 ${basename(dir)}/ 缺少 icon_256.png(256×256 PNG,其他尺寸可省略,由它縮小產生)。`);
91
+ throw new Error(`Icon directory ${basename(dir)}/ is missing icon_256.png (a 256x256 PNG; other sizes may be omitted and are generated by scaling it down).`);
91
92
  return drawn;
92
93
  }
93
94
  function readIconFile(path) {
94
95
  const image = readPng(path, basename(path));
95
96
  if (image.width !== image.height)
96
- throw new Error(`${basename(path)}:尺寸 ${image.width}×${image.height} 不是正方形。`);
97
+ throw new Error(`${basename(path)}: the size ${image.width}x${image.height} is not square.`);
97
98
  if (image.width < 256)
98
- throw new Error(`${basename(path)}:尺寸 ${image.width}×${image.height},至少 256×256。`);
99
+ throw new Error(`${basename(path)}: the size ${image.width}x${image.height} must be at least 256x256.`);
99
100
  return new Map([[256, scale(image, 256)]]);
100
101
  }
101
102
  function render(drawn, source) {
@@ -104,7 +105,7 @@ function render(drawn, source) {
104
105
  for (const size of WINDOW_ICON_SIZES) {
105
106
  const png = encodePng(size, size, scale(drawn.get(size) ?? big, size).rgba);
106
107
  if (png.length > MAX_ICON_BYTES) {
107
- throw new Error(`${size}×${size} 圖示編碼後 ${png.length} bytes,超過 package source 的 256 KiB 上限;請減少雜訊或細節。`);
108
+ throw new Error(`The encoded ${size}x${size} icon is ${png.length} bytes, over the package source limit of 256 KiB; reduce noise or detail.`);
108
109
  }
109
110
  window.set(size, png);
110
111
  }
@@ -113,7 +114,7 @@ function render(drawn, source) {
113
114
  /** A project icon: one square PNG of at least 256 px, or a directory of icon_<size>.png. */
114
115
  export function renderPackageIcons(path) {
115
116
  if (!existsSync(path))
116
- throw new Error(`找不到圖示 ${basename(path)}。`);
117
+ throw new Error(`Icon ${basename(path)} was not found.`);
117
118
  return render(statSync(path).isDirectory() ? readIconDirectory(path) : readIconFile(path), "custom");
118
119
  }
119
120
  let defaults;
@@ -134,11 +135,24 @@ function writeIcon(path, png) {
134
135
  writeFileSync(path, png);
135
136
  chmodSync(path, 0o644);
136
137
  }
138
+ /**
139
+ * `ui/config`'s icon, relative to ui/: DSM puts the size in for {0}. DSM and
140
+ * the browser cache an icon by its URL, so the name carries the icons' content
141
+ * and a changed icon is loaded again.
142
+ */
143
+ export function windowIconTemplate(icons) {
144
+ const hash = createHash("sha256");
145
+ for (const [size, png] of icons.window)
146
+ hash.update(`${size}:${png.length}:`).update(png);
147
+ return `images/app_${hash.digest("hex").slice(0, 12)}_{0}.png`;
148
+ }
149
+ export function windowIconFile(icons, size) {
150
+ return windowIconTemplate(icons).replace("{0}", String(size));
151
+ }
137
152
  export function writeDsmWindowIcons(uiDir, icons) {
138
- const imagesDir = join(uiDir, "images");
139
- mkdirSync(imagesDir, { recursive: true });
153
+ mkdirSync(join(uiDir, "images"), { recursive: true });
140
154
  for (const [size, png] of icons.window)
141
- writeIcon(join(imagesDir, `app_${size}.png`), png);
155
+ writeIcon(join(uiDir, windowIconFile(icons, size)), png);
142
156
  }
143
157
  export function writePackageCenterIcons(spkRoot, icons) {
144
158
  writeIcon(join(spkRoot, "PACKAGE_ICON.PNG"), icons.packageIcon);
@@ -15,7 +15,7 @@ export declare const CONTAINER_MIN_DSM: string;
15
15
  */
16
16
  export declare const PORTAL_MIN_DSM: string;
17
17
  /** Why DSM below PORTAL_MIN_DSM refuses a package that declares the web-config portal resource. */
18
- export declare const PORTAL_MIN_DSM_REASON = "DSM 7.1 \u628A web-config \u5217\u70BA Synology \u5C08\u7528 worker\uFF0C\u4EE5\u932F\u8AA4 320 \u62D2\u88DD\u5E36 portal\uFF08web-config app-portal\uFF09\u7684\u672A\u7C3D\u7AE0\u5957\u4EF6\uFF1B7.2 \u8D77\u958B\u653E\u662F\u4F9D synopkg \u539F\u59CB\u78BC\u5224\u65B7\uFF0C\u5C1A\u672A\u5BE6\u6A5F\u9A57\u8B49";
18
+ export declare const PORTAL_MIN_DSM_REASON = "DSM 7.1 lists web-config as a Synology-only worker and rejects unsigned packages with a portal (web-config app-portal) with error 320; the 7.2 onward allowance is inferred from the synopkg source and is not yet verified on hardware";
19
19
  /**
20
20
  * Below {@link PORTAL_MIN_DSM} the root executor registers the portal entry in
21
21
  * DSM's app.d instead; that file format is verified from DSM 7.1 on.
@@ -22,7 +22,7 @@ export const CONTAINER_MIN_DSM = FIRST_BUILD["7.2.1"];
22
22
  */
23
23
  export const PORTAL_MIN_DSM = FIRST_BUILD["7.2"];
24
24
  /** Why DSM below PORTAL_MIN_DSM refuses a package that declares the web-config portal resource. */
25
- export const PORTAL_MIN_DSM_REASON = "DSM 7.1 把 web-config 列為 Synology 專用 worker,以錯誤 320 拒裝帶 portal(web-config app-portal)的未簽章套件;7.2 起開放是依 synopkg 原始碼判斷,尚未實機驗證";
25
+ export const PORTAL_MIN_DSM_REASON = "DSM 7.1 lists web-config as a Synology-only worker and rejects unsigned packages with a portal (web-config app-portal) with error 320; the 7.2 onward allowance is inferred from the synopkg source and is not yet verified on hardware";
26
26
  /**
27
27
  * Below {@link PORTAL_MIN_DSM} the root executor registers the portal entry in
28
28
  * DSM's app.d instead; that file format is verified from DSM 7.1 on.
@@ -34,7 +34,7 @@ export function portalByExecutor(osMinVer) {
34
34
  }
35
35
  /** Why a portal package cannot declare `value`, and how to fix it; `field` names the portal key. */
36
36
  export function portalMinDsmMessage(value, field) {
37
- return `${field} 需要 DSM ${PORTAL_EXECUTOR_MIN_DSM} 以上,os_min_ver 是「${value}」。DSM ${PORTAL_MIN_DSM} 以前由 executor 寫入 DSM 的 app.d 註冊入口,這個格式只在 DSM 7.1 起驗證過。請把 os_min_ver 提高到 ${PORTAL_EXECUTOR_MIN_DSM} 以上,或移除 ${field}。`;
37
+ return `${field} needs DSM ${PORTAL_EXECUTOR_MIN_DSM} or later; os_min_ver is "${value}". Before DSM ${PORTAL_MIN_DSM} the executor writes the entry into DSM's app.d, and that format is only verified from DSM 7.1. Raise os_min_ver to ${PORTAL_EXECUTOR_MIN_DSM} or later, or remove ${field}.`;
38
38
  }
39
39
  /** `true` when `value` is not older than `floor`, comparing version parts then build. */
40
40
  export function isDsmAtLeast(value, floor) {
@@ -53,7 +53,7 @@ export function isDsmAtLeast(value, floor) {
53
53
  /** Container Apps cannot declare an os_min_ver below {@link CONTAINER_MIN_DSM}. */
54
54
  export function assertContainerMinDsm(value) {
55
55
  if (!isDsmAtLeast(value, CONTAINER_MIN_DSM)) {
56
- throw new Error(`[MIN_DSM_CONTAINER_FLOOR] 容器 App 需要 DSM ${CONTAINER_MIN_DSM} 以上(docker-project/Container Manager 從 DSM 7.2.1 才有),收到「${value}」。`);
56
+ throw new Error(`[MIN_DSM_CONTAINER_FLOOR] A container App needs DSM ${CONTAINER_MIN_DSM} or later (docker-project/Container Manager starts at DSM 7.2.1); got "${value}".`);
57
57
  }
58
58
  }
59
59
  const FULL_VERSION = /^7\.\d+(\.\d+)?-\d+$/;
@@ -64,7 +64,7 @@ export function resolveMinDsm(value) {
64
64
  }
65
65
  const build = FIRST_BUILD[value];
66
66
  if (!build) {
67
- throw new Error(`[MIN_DSM_INVALID] --min-dsm 只接受 ${Object.keys(FIRST_BUILD).join("、")},或完整的 <版本>-<build>(例如 7.2-64570),收到「${value}」。`);
67
+ throw new Error(`[MIN_DSM_INVALID] --min-dsm accepts only ${Object.keys(FIRST_BUILD).join(", ")}, or a full <version>-<build> (for example 7.2-64570); got "${value}".`);
68
68
  }
69
69
  return build;
70
70
  }
@@ -32,10 +32,10 @@ export async function buildGatewayBinary(input) {
32
32
  env: { ...process.env, ...pinnedGoBuildEnv(input.arch) },
33
33
  });
34
34
  if (result.missing) {
35
- return { error: "GATEWAY_GO_MISSING", detail: `找不到 go 指令。DSM 驗證閘道需要 Go 1.21 以上版本(https://go.dev/dl/)才能建置。${PINNED_GO_HINT}` };
35
+ return { error: "GATEWAY_GO_MISSING", detail: `The go command was not found. Building the DSM verification gateway needs Go 1.21 or later (https://go.dev/dl/). ${PINNED_GO_HINT}` };
36
36
  }
37
37
  if (result.status !== 0) {
38
- return { error: "GATEWAY_BUILD_FAILED", detail: `go build(${input.arch})失敗。${PINNED_GO_HINT}\n${result.stderr}` };
38
+ return { error: "GATEWAY_BUILD_FAILED", detail: `go build (${input.arch}) failed. ${PINNED_GO_HINT}\n${result.stderr}` };
39
39
  }
40
40
  const problem = checkCgiBinary(input.work, out, input.arch);
41
41
  if (problem) {
@@ -178,7 +178,7 @@ export async function buildSpk(options) {
178
178
  if (contract.adminport !== undefined && resolvedWebLaunch(contract) === "dsm-window") {
179
179
  const uiDir = join(staging, "package", "ui");
180
180
  mkdirSync(uiDir, { recursive: true });
181
- writeFileSync(join(uiDir, "config"), renderDsmWindowConfig(contract), "utf8");
181
+ writeFileSync(join(uiDir, "config"), renderDsmWindowConfig(contract, icons), "utf8");
182
182
  writeDsmWindowIcons(uiDir, icons);
183
183
  writeUiTexts(uiDir, contractAppTitle(contract));
184
184
  }
@@ -4,7 +4,7 @@ const MAX_SIDE = 4096;
4
4
  const CHANNELS = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 };
5
5
  const DEPTHS = { 0: [1, 2, 4, 8, 16], 2: [8, 16], 3: [1, 2, 4, 8], 4: [8, 16], 6: [8, 16] };
6
6
  function corrupt(why) {
7
- throw new Error(`PNG 損壞:${why}`);
7
+ throw new Error(`Corrupt PNG: ${why}`);
8
8
  }
9
9
  function chunk(type, data) {
10
10
  const head = Buffer.alloc(8);
@@ -21,34 +21,34 @@ function paeth(a, b, c) {
21
21
  }
22
22
  export function decodePng(bytes) {
23
23
  if (bytes.length < 8 || !bytes.subarray(0, 8).equals(SIGNATURE))
24
- throw new Error("不是 PNG 檔(檔頭簽章不符)。");
24
+ throw new Error("Not a PNG file (header signature mismatch).");
25
25
  let offset = 8;
26
26
  let header;
27
27
  let palette, alpha, ended = false;
28
28
  const data = [];
29
29
  while (!ended) {
30
30
  if (offset + 12 > bytes.length)
31
- corrupt("檔案被截斷");
31
+ corrupt("file is truncated");
32
32
  const length = bytes.readUInt32BE(offset);
33
33
  const type = bytes.toString("latin1", offset + 4, offset + 8);
34
34
  const end = offset + 12 + length;
35
35
  if (end > bytes.length)
36
- corrupt("檔案被截斷");
36
+ corrupt("file is truncated");
37
37
  const body = bytes.subarray(offset + 8, offset + 8 + length);
38
38
  if (crc32(body, crc32(bytes.subarray(offset + 4, offset + 8))) !== bytes.readUInt32BE(end - 4))
39
- corrupt(`${type} CRC 不符`);
39
+ corrupt(`${type} CRC mismatch`);
40
40
  if (!header && type !== "IHDR")
41
- corrupt("第一個 chunk 必須是 IHDR");
41
+ corrupt("the first chunk must be IHDR");
42
42
  if (type === "IHDR") {
43
43
  if (header || length !== 13)
44
- corrupt("IHDR 無效");
44
+ corrupt("invalid IHDR");
45
45
  header = { width: body.readUInt32BE(0), height: body.readUInt32BE(4), depth: body[8], type: body[9] };
46
46
  if (!DEPTHS[header.type]?.includes(header.depth) || body[10] !== 0 || body[11] !== 0)
47
- corrupt("不支援的色彩型態或位元深度");
47
+ corrupt("unsupported colour type or bit depth");
48
48
  if (body[12] !== 0)
49
- throw new Error("不支援交錯(interlaced)PNG;請另存為非交錯 PNG。");
49
+ throw new Error("Interlaced PNG is not supported; save it as a non-interlaced PNG.");
50
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)。`);
51
+ throw new Error(`PNG size ${header.width}x${header.height} is out of range (1-${MAX_SIDE} px per side).`);
52
52
  }
53
53
  }
54
54
  else if (type === "PLTE")
@@ -62,10 +62,10 @@ export function decodePng(bytes) {
62
62
  offset = end;
63
63
  }
64
64
  if (!header || !data.length)
65
- corrupt("缺少影像資料");
65
+ corrupt("missing image data");
66
66
  const { width, height, depth, type } = header;
67
67
  if (type === 3 && (!palette || palette.length % 3 !== 0))
68
- corrupt("調色盤影像缺少 PLTE");
68
+ corrupt("palette image is missing PLTE");
69
69
  const bits = CHANNELS[type] * depth;
70
70
  const bpp = Math.max(1, bits >> 3);
71
71
  const stride = Math.ceil((width * bits) / 8);
@@ -74,10 +74,10 @@ export function decodePng(bytes) {
74
74
  raw = inflateSync(Buffer.concat(data), { maxOutputLength: (stride + 1) * height + 1 });
75
75
  }
76
76
  catch {
77
- corrupt("影像資料無法解壓縮");
77
+ corrupt("image data cannot be decompressed");
78
78
  }
79
79
  if (raw.length !== (stride + 1) * height)
80
- corrupt("影像資料長度不符");
80
+ corrupt("image data length mismatch");
81
81
  const prev = Buffer.alloc(stride), line = Buffer.alloc(stride);
82
82
  const rgba = Buffer.alloc(width * height * 4);
83
83
  const sample = (x, channel) => {
@@ -94,7 +94,7 @@ export function decodePng(bytes) {
94
94
  const filter = raw[y * (stride + 1)];
95
95
  for (let i = 0; i < stride; i++) {
96
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("未知的列濾波");
97
+ const predictor = filter === 0 ? 0 : filter === 1 ? a : filter === 2 ? b : filter === 3 ? (a + b) >> 1 : filter === 4 ? paeth(a, b, c) : corrupt("unknown row filter");
98
98
  line[i] = (row[i] + predictor) & 0xff;
99
99
  }
100
100
  for (let x = 0; x < width; x++) {
@@ -102,7 +102,7 @@ export function decodePng(bytes) {
102
102
  if (type === 3) {
103
103
  const index = sample(x, 0);
104
104
  if (index * 3 + 2 >= palette.length)
105
- corrupt("調色盤索引超出範圍");
105
+ corrupt("palette index out of range");
106
106
  palette.copy(rgba, out, index * 3, index * 3 + 3);
107
107
  rgba[out + 3] = alpha && index < alpha.length ? alpha[index] : 255;
108
108
  }
@@ -3,7 +3,7 @@ export function capabilityNames(values) {
3
3
  const names = [...new Set(values.flatMap(value => value.split(',')).map(value => value.trim()))];
4
4
  for (const name of names) {
5
5
  if (!CAPABILITY_NAMES.includes(name))
6
- throw new Error(`[CAPABILITY_INVALID] 不支援能力「${name}」;可選 ${CAPABILITY_NAMES.join(', ')}。`);
6
+ throw new Error(`[CAPABILITY_INVALID] Unsupported capability "${name}"; choose from ${CAPABILITY_NAMES.join(', ')}.`);
7
7
  }
8
8
  return names;
9
9
  }
@@ -1,17 +1,28 @@
1
+ import type { CapabilityName } from './capabilities.js';
2
+ import type { ApiPermission } from './standalone/api-permissions.js';
1
3
  /**
2
4
  * How each capability of a native template is laid over its base template: the
3
- * template directories copied on top, files borrowed from another overlay, and
4
- * the base files the overlay replaces. init and add both read this table.
5
+ * template directories copied on top, files borrowed from another overlay, the
6
+ * base files the overlay replaces, and the rules it adds to the App's
7
+ * permissions. A bridge App's example may also need another capability, and a
8
+ * Rust App lists the methods it adds in its manifest. init and add both read
9
+ * this table.
5
10
  */
6
11
  export interface CapabilityOverlay {
7
12
  overlays: string[];
8
13
  borrowed: string[];
9
14
  removed: string[];
15
+ permissions: Record<string, ApiPermission>;
16
+ implied?: CapabilityName[];
17
+ methods?: string[];
10
18
  }
11
- export type NativeTemplate = 'native-go' | 'native-rust';
19
+ export type BridgeTemplate = 'native-python' | 'native-node' | 'native-rust';
20
+ export type NativeTemplate = 'native-go' | BridgeTemplate;
21
+ export declare const BRIDGE_TEMPLATES: BridgeTemplate[];
12
22
  export declare const CAPABILITY_OVERLAYS: Record<NativeTemplate, Record<string, CapabilityOverlay>>;
13
- /** The overlays of a native template's Vue frontend, copied before the capabilities'. */
14
- export declare const VUE_OVERLAYS: Record<NativeTemplate, string[]>;
23
+ /** What a native template's base and Vue frontend add before the capabilities: a bridge App borrows the Go template's UI. */
24
+ export declare const BASE_BORROWED: Record<NativeTemplate, string[]>;
25
+ export declare const VUE_OVERLAYS: Record<NativeTemplate, Pick<CapabilityOverlay, 'overlays' | 'borrowed'>>;
15
26
  /** The overlays of these capabilities, in table order, each once; a capability without one adds nothing. */
16
27
  export declare function capabilityOverlays(template: NativeTemplate, names: readonly string[]): CapabilityOverlay;
17
28
  /** Each file of the borrowed paths, a borrowed folder listed file by file, under `templates`. */