@synopackageland/cli 0.7.0 → 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 (165) hide show
  1. package/CHANGELOG.md +301 -1
  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/add.d.ts +4 -1
  13. package/dist/add.js +14 -9
  14. package/dist/build-spk/dsm-icons.d.ts +31 -2
  15. package/dist/build-spk/dsm-icons.js +135 -56
  16. package/dist/build-spk/dsm-platforms.json +4 -1
  17. package/dist/build-spk/gateway.d.ts +1 -1
  18. package/dist/build-spk/gateway.js +8 -4
  19. package/dist/build-spk/generator.js +17 -3
  20. package/dist/build-spk/go-toolchain.d.ts +20 -0
  21. package/dist/build-spk/go-toolchain.js +44 -0
  22. package/dist/build-spk/png.d.ts +14 -0
  23. package/dist/build-spk/png.js +161 -0
  24. package/dist/build-spk/ui-texts.d.ts +20 -0
  25. package/dist/build-spk/ui-texts.js +42 -0
  26. package/dist/cli.js +39 -9
  27. package/dist/contract/i18n.d.ts +5 -0
  28. package/dist/contract/i18n.js +45 -0
  29. package/dist/contract/icon.d.ts +5 -0
  30. package/dist/contract/icon.js +27 -0
  31. package/dist/contract/validate.js +4 -0
  32. package/dist/contract/web-launch.js +6 -2
  33. package/dist/deploy/native-install-values.d.ts +17 -0
  34. package/dist/deploy/native-install-values.js +32 -0
  35. package/dist/deploy/plan.d.ts +6 -2
  36. package/dist/deploy/plan.js +57 -6
  37. package/dist/error-registry.js +15 -6
  38. package/dist/host/dsm-client.js +53 -17
  39. package/dist/host/dsm-http.js +24 -2
  40. package/dist/host/types.d.ts +1 -0
  41. package/dist/info.js +28 -9
  42. package/dist/init.js +1 -1
  43. package/dist/runtime/dsm-version.d.ts +6 -0
  44. package/dist/runtime/dsm-version.js +8 -0
  45. package/dist/runtime/executor-build.d.ts +1 -1
  46. package/dist/runtime/executor-build.js +8 -5
  47. package/dist/standalone/daemon.d.ts +2 -1
  48. package/dist/standalone/daemon.js +10 -6
  49. package/dist/standalone/dsm-window.d.ts +2 -0
  50. package/dist/standalone/dsm-window.js +26 -40
  51. package/dist/standalone/i18n.d.ts +12 -0
  52. package/dist/standalone/i18n.js +30 -0
  53. package/dist/standalone/inspect-spk.d.ts +5 -0
  54. package/dist/standalone/inspect-spk.js +41 -2
  55. package/dist/standalone/manifest.d.ts +5 -6
  56. package/dist/standalone/manifest.js +21 -34
  57. package/dist/standalone/native-authorization-window.d.ts +1 -1
  58. package/dist/standalone/native-authorization-window.js +7 -1
  59. package/dist/standalone/native-layout.d.ts +2 -0
  60. package/dist/standalone/native-layout.js +22 -1
  61. package/dist/standalone/native-root-window.d.ts +4 -2
  62. package/dist/standalone/native-root-window.js +33 -18
  63. package/dist/standalone/root-window.d.ts +4 -1
  64. package/dist/standalone/root-window.js +143 -33
  65. package/dist/standalone/spk.js +7 -4
  66. package/dist/templates/_daemon/go.mod +1 -1
  67. package/dist/templates/_daemon/main.go +6 -3
  68. package/dist/templates/_daemon/main_test.go +21 -1
  69. package/dist/templates/_daemon/testdata/child.go +3 -0
  70. package/dist/templates/_executor/README.md +4 -1
  71. package/dist/templates/_executor/anchor.go +10 -0
  72. package/dist/templates/_executor/anchor_test.go +40 -5
  73. package/dist/templates/_executor/digest_review_test.go +2 -2
  74. package/dist/templates/_executor/dsm.go +40 -3
  75. package/dist/templates/_executor/go.mod +2 -0
  76. package/dist/templates/_executor/main.go +11 -5
  77. package/dist/templates/_executor/managed.go +3 -3
  78. package/dist/templates/_executor/managed_test.go +3 -3
  79. package/dist/templates/_executor/path_test.go +18 -0
  80. package/dist/templates/_executor/pid.go +37 -0
  81. package/dist/templates/_executor/pid_test.go +113 -0
  82. package/dist/templates/_executor/platform_other.go +8 -0
  83. package/dist/templates/_executor/purge_log.go +18 -0
  84. package/dist/templates/_executor/tasks.go +45 -7
  85. package/dist/templates/_executor/tasks_dsm71_test.go +114 -0
  86. package/dist/templates/_executor/tasks_test.go +58 -1
  87. package/dist/templates/_executor/upgrade_watch_test.go +13 -5
  88. package/dist/templates/_gateway/go.mod +2 -0
  89. package/dist/templates/binary/dsm-package.yaml +3 -0
  90. package/dist/templates/compose/synology-app.yaml +3 -0
  91. package/dist/templates/native-go/backend/go.mod +1 -1
  92. package/dist/templates/native-go/backend/internal/filestation/client.go +39 -3
  93. package/dist/templates/native-go/backend/internal/filestation/download_dsm71_test.go +82 -0
  94. package/dist/templates/native-go/backend/internal/identity/README.md +5 -1
  95. package/dist/templates/native-go/backend/internal/identity/dsm.go +24 -5
  96. package/dist/templates/native-go/backend/internal/identity/http.go +1 -1
  97. package/dist/templates/native-go/backend/internal/identity/identity_test.go +48 -1
  98. package/dist/templates/native-go/backend/internal/identity/service.go +1 -1
  99. package/dist/templates/native-go/dsm-package.yaml +3 -0
  100. package/dist/templates/native-go-delegate/frontend/test/delegate.test.ts +15 -0
  101. package/dist/templates/native-go-root-ops/backend/internal/app/executor.go +30 -2
  102. package/dist/templates/native-go-root-ops/backend/internal/app/executor_test.go +64 -0
  103. package/dist/templates/native-go-vue/frontend/src/delegate/ExecutorSettings.vue +3 -2
  104. package/dist/templates/native-go-vue/frontend/src/delegate/executor-settings.test.ts +23 -0
  105. package/dist/templates/native-go-vue/frontend/src/delegate/executor-setup.ts +5 -1
  106. package/dist/templates/native-go-vue/frontend/src/delegate/i18n/de.json +1 -0
  107. package/dist/templates/native-go-vue/frontend/src/delegate/i18n/en.json +1 -0
  108. package/dist/templates/native-go-vue/frontend/src/delegate/i18n/es.json +1 -0
  109. package/dist/templates/native-go-vue/frontend/src/delegate/i18n/fr.json +1 -0
  110. package/dist/templates/native-go-vue/frontend/src/delegate/i18n/it.json +1 -0
  111. package/dist/templates/native-go-vue/frontend/src/delegate/i18n/ja.json +1 -0
  112. package/dist/templates/native-go-vue/frontend/src/delegate/i18n/ko.json +1 -0
  113. package/dist/templates/native-go-vue/frontend/src/delegate/i18n/zh-CN.json +1 -0
  114. package/dist/templates/native-go-vue/frontend/src/delegate/i18n/zh-TW.json +1 -0
  115. package/package.json +2 -1
  116. package/schema/synology-app.schema.json +22 -0
  117. package/templates/_daemon/go.mod +1 -1
  118. package/templates/_daemon/main.go +6 -3
  119. package/templates/_daemon/main_test.go +21 -1
  120. package/templates/_daemon/testdata/child.go +3 -0
  121. package/templates/_executor/README.md +4 -1
  122. package/templates/_executor/anchor.go +10 -0
  123. package/templates/_executor/anchor_test.go +40 -5
  124. package/templates/_executor/digest_review_test.go +2 -2
  125. package/templates/_executor/dsm.go +40 -3
  126. package/templates/_executor/go.mod +2 -0
  127. package/templates/_executor/main.go +11 -5
  128. package/templates/_executor/managed.go +3 -3
  129. package/templates/_executor/managed_test.go +3 -3
  130. package/templates/_executor/path_test.go +18 -0
  131. package/templates/_executor/pid.go +37 -0
  132. package/templates/_executor/pid_test.go +113 -0
  133. package/templates/_executor/platform_other.go +8 -0
  134. package/templates/_executor/purge_log.go +18 -0
  135. package/templates/_executor/tasks.go +45 -7
  136. package/templates/_executor/tasks_dsm71_test.go +114 -0
  137. package/templates/_executor/tasks_test.go +58 -1
  138. package/templates/_executor/upgrade_watch_test.go +13 -5
  139. package/templates/_gateway/go.mod +2 -0
  140. package/templates/binary/dsm-package.yaml +3 -0
  141. package/templates/compose/synology-app.yaml +3 -0
  142. package/templates/native-go/backend/go.mod +1 -1
  143. package/templates/native-go/backend/internal/filestation/client.go +39 -3
  144. package/templates/native-go/backend/internal/filestation/download_dsm71_test.go +82 -0
  145. package/templates/native-go/backend/internal/identity/README.md +5 -1
  146. package/templates/native-go/backend/internal/identity/dsm.go +24 -5
  147. package/templates/native-go/backend/internal/identity/http.go +1 -1
  148. package/templates/native-go/backend/internal/identity/identity_test.go +48 -1
  149. package/templates/native-go/backend/internal/identity/service.go +1 -1
  150. package/templates/native-go/dsm-package.yaml +3 -0
  151. package/templates/native-go-delegate/frontend/test/delegate.test.ts +15 -0
  152. package/templates/native-go-root-ops/backend/internal/app/executor.go +30 -2
  153. package/templates/native-go-root-ops/backend/internal/app/executor_test.go +64 -0
  154. package/templates/native-go-vue/frontend/src/delegate/ExecutorSettings.vue +3 -2
  155. package/templates/native-go-vue/frontend/src/delegate/executor-settings.test.ts +23 -0
  156. package/templates/native-go-vue/frontend/src/delegate/executor-setup.ts +5 -1
  157. package/templates/native-go-vue/frontend/src/delegate/i18n/de.json +1 -0
  158. package/templates/native-go-vue/frontend/src/delegate/i18n/en.json +1 -0
  159. package/templates/native-go-vue/frontend/src/delegate/i18n/es.json +1 -0
  160. package/templates/native-go-vue/frontend/src/delegate/i18n/fr.json +1 -0
  161. package/templates/native-go-vue/frontend/src/delegate/i18n/it.json +1 -0
  162. package/templates/native-go-vue/frontend/src/delegate/i18n/ja.json +1 -0
  163. package/templates/native-go-vue/frontend/src/delegate/i18n/ko.json +1 -0
  164. package/templates/native-go-vue/frontend/src/delegate/i18n/zh-CN.json +1 -0
  165. package/templates/native-go-vue/frontend/src/delegate/i18n/zh-TW.json +1 -0
@@ -2,11 +2,29 @@ import { spawnSync } from "node:child_process";
2
2
  import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- function isHostUnreachable(error) {
6
- const code = error.cause?.code
5
+ function errorCode(error) {
6
+ return error.cause?.code
7
7
  ?? error.code;
8
+ }
9
+ function isHostUnreachable(error) {
10
+ const code = errorCode(error);
8
11
  return code === "EHOSTUNREACH" || code === "ENETUNREACH";
9
12
  }
13
+ const UNTRUSTED_CERTIFICATE = new Set([
14
+ "DEPTH_ZERO_SELF_SIGNED_CERT",
15
+ "SELF_SIGNED_CERT_IN_CHAIN",
16
+ "UNABLE_TO_VERIFY_LEAF_SIGNATURE",
17
+ "UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
18
+ "CERT_HAS_EXPIRED",
19
+ "ERR_TLS_CERT_ALTNAME_INVALID",
20
+ ]);
21
+ /** Node reports an untrusted certificate only as "fetch failed"; say what to do. Verification stays on. */
22
+ function untrustedCertificate(url, code) {
23
+ const origin = new URL(url).origin;
24
+ return Object.assign(new Error(`DSM at ${origin} presented a TLS certificate this computer does not trust (${code}). ` +
25
+ `Export the NAS certificate or its CA as PEM and run with NODE_EXTRA_CA_CERTS=<that file>, ` +
26
+ `or give DSM a certificate whose name matches the host profile. Certificate checks stay on.`), { code: "DSM_TLS_UNTRUSTED" });
27
+ }
10
28
  function headerRecord(headers) {
11
29
  if (!headers) {
12
30
  return {};
@@ -24,6 +42,10 @@ export async function dsmFetch(url, init = {}) {
24
42
  return await fetch(url, init);
25
43
  }
26
44
  catch (error) {
45
+ const code = errorCode(error);
46
+ if (code && UNTRUSTED_CERTIFICATE.has(code)) {
47
+ throw untrustedCertificate(url, code);
48
+ }
27
49
  if (!isHostUnreachable(error)) {
28
50
  throw error;
29
51
  }
@@ -33,6 +33,7 @@ export interface NasHost {
33
33
  ready: boolean;
34
34
  executor_digest: string;
35
35
  policy_digest: string;
36
+ error?: string;
36
37
  } | undefined>;
37
38
  getArchFamily?(): Promise<NativeArchFamily | undefined>;
38
39
  /** List the current logical Shared Folder names using an administrator session. */
package/dist/info.js CHANGED
@@ -5,6 +5,7 @@ import { appOriginFromContract } from "./host/open-url.js";
5
5
  import { resolveDeployIdentity } from "./deploy/identity.js";
6
6
  import { loadStandaloneManifest, } from "./standalone/manifest.js";
7
7
  import { inspectSpk } from "./standalone/inspect-spk.js";
8
+ import { isNativeLayout, loadNativeLayout } from "./standalone/native-layout.js";
8
9
  import { executorPolicy, executorPolicyText, sha256 } from "./runtime/executor-policy.js";
9
10
  import { executorTasks } from "./runtime/executor-tasks.js";
10
11
  import { uninstallCleanup } from "./runtime/uninstall-cleanup.js";
@@ -72,7 +73,7 @@ async function liveRootCapability(m, host) {
72
73
  return classified;
73
74
  const observed = await host.getExecutorStatus?.(m.package);
74
75
  if (observed?.ready !== true || observed.policy_digest !== policyDigest)
75
- return { ...classified, state: "blocked", reason: "Executor health or policy digest readback could not be verified." };
76
+ return { ...classified, state: "blocked", reason: observed?.error ?? "Executor health or policy digest readback could not be verified." };
76
77
  return { ...classified, reason: "Root tasks and the running executor match the source policy (no local SPK, so executor bytes were not compared)." };
77
78
  }
78
79
  catch {
@@ -116,7 +117,7 @@ async function rootCapability(m, host, version) {
116
117
  if (classified.state === "ready") {
117
118
  const observed = await host.getExecutorStatus?.(m.package);
118
119
  if (!observed || observed.ready !== true || observed.executor_digest !== executor.executorDigest || observed.policy_digest !== executor.policyDigest)
119
- return { ...classified, executor, state: "blocked", reason: "Executor health or digest readback could not be verified." };
120
+ return { ...classified, executor, state: "blocked", reason: observed?.error ?? "Executor health or digest readback could not be verified." };
120
121
  }
121
122
  return { ...classified, executor };
122
123
  }
@@ -127,12 +128,14 @@ async function rootCapability(m, host, version) {
127
128
  export async function standaloneInfo(options) {
128
129
  if (options.dev)
129
130
  throw Object.assign(new Error("--dev is not supported for native-go/binary; choose a distinct package name at init"), { code: "DEVELOPMENT_INSTANCE_UNSUPPORTED" });
131
+ if (isNativeLayout(options.root))
132
+ return nativeLayoutInfo(options.root, options.host);
130
133
  const loaded = loadStandaloneManifest(options.root);
131
134
  if (!loaded.ok)
132
135
  throw Object.assign(new Error(loaded.errors.map((e) => e.message).join("\n")), { code: loaded.errors[0].code });
133
136
  const m = loaded.manifest;
134
- const remote = await options.host.getInstalledPackageState?.(m.package);
135
- const installed = await options.host.isPackageInstalled(m.package);
137
+ const { remote, ...state } = await packageState(m.package, options.host);
138
+ const installed = state.installed;
136
139
  const capabilities = [];
137
140
  let executor;
138
141
  for (const name of ["files", "jobs", "identity", "rootOps", "delegate"]) {
@@ -158,16 +161,32 @@ export async function standaloneInfo(options) {
158
161
  });
159
162
  }
160
163
  return {
161
- packageIdentity: m.package,
162
- developmentInstance: false,
163
- installed,
164
- readiness: remote?.status ?? (installed ? "UNKNOWN" : "NOT_INSTALLED"),
165
- openUrl: `${options.host.dsmOrigin.replace(/\/$/, "")}/?launchApp=SYNO.SDS.${m.package}.Instance`,
164
+ ...state,
166
165
  capabilities,
167
166
  ...(executor ? { executor } : {}),
168
167
  uninstallCleanup: uninstallCleanup(m.package, Boolean(m.capabilities.rootOps || m.capabilities.delegate)),
169
168
  };
170
169
  }
170
+ /** A native layout has no SDK capabilities; report package state only. */
171
+ async function nativeLayoutInfo(root, host) {
172
+ const loaded = loadNativeLayout(root);
173
+ if (!loaded.ok)
174
+ throw Object.assign(new Error(loaded.errors.map((e) => e.message).join("\n")), { code: loaded.errors[0].code });
175
+ const { remote, ...state } = await packageState(loaded.manifest.package, host);
176
+ return { ...state, capabilities: [] };
177
+ }
178
+ async function packageState(pkg, host) {
179
+ const remote = await host.getInstalledPackageState?.(pkg);
180
+ const installed = await host.isPackageInstalled(pkg);
181
+ return {
182
+ remote,
183
+ packageIdentity: pkg,
184
+ developmentInstance: false,
185
+ installed,
186
+ readiness: remote?.status ?? (installed ? "UNKNOWN" : "NOT_INSTALLED"),
187
+ openUrl: `${host.dsmOrigin.replace(/\/$/, "")}/?launchApp=SYNO.SDS.${pkg}.Instance`,
188
+ };
189
+ }
171
190
  export function formatProjectInfo(info) {
172
191
  return [
173
192
  `Package Identity:${info.packageIdentity}`,
package/dist/init.js CHANGED
@@ -195,7 +195,7 @@ export function initFromTemplate(template, targetDir, options = {}) {
195
195
  }
196
196
  if (executor) {
197
197
  const path = join(targetDir, "backend/internal/app/routes_test.go");
198
- writeFileSync(path, readFileSync(path, "utf8").replace('if strings.HasPrefix(r.Method(), "jobs.") {', 'if r.Method() == "executor.status" { want = "internal" }\n\t\tif r.Method() == "root.run" { want = "session_required" }\n\t\tif strings.HasPrefix(r.Method(), "jobs.") {'));
198
+ writeFileSync(path, readFileSync(path, "utf8").replace('if strings.HasPrefix(r.Method(), "jobs.") {', 'if r.Method() == "root.run" { want = "session_required" }\n\t\tif strings.HasPrefix(r.Method(), "jobs.") {'));
199
199
  }
200
200
  if (background && ui === "vue") {
201
201
  const app = join(targetDir, "frontend/src/App.vue");
@@ -0,0 +1,6 @@
1
+ /** Self-contained: the same function is embedded in the DSM window. True only
2
+ * when DSM refused a request because this method version is unavailable, the
3
+ * one refusal that may be retried with an older version. DSM 7.1.1 (t-0234)
4
+ * returns 104 for a version above maxVersion and 103 for a method absent at an
5
+ * existing version. Accepts the numeric code and the window's dsm_ form. */
6
+ export declare function versionUnsupported(code: unknown): boolean;
@@ -0,0 +1,8 @@
1
+ /** Self-contained: the same function is embedded in the DSM window. True only
2
+ * when DSM refused a request because this method version is unavailable, the
3
+ * one refusal that may be retried with an older version. DSM 7.1.1 (t-0234)
4
+ * returns 104 for a version above maxVersion and 103 for a method absent at an
5
+ * existing version. Accepts the numeric code and the window's dsm_ form. */
6
+ export function versionUnsupported(code) {
7
+ return ["103", "104", "dsm_103", "dsm_104"].indexOf(String(code)) >= 0;
8
+ }
@@ -1,4 +1,4 @@
1
- import { type NativeArchFamily } from '../build-spk/dsm-arch.js';
1
+ import type { NativeArchFamily } from '../build-spk/dsm-arch.js';
2
2
  import type { CommandRunner } from '../standalone/build.js';
3
3
  import type { StandaloneManifest } from '../standalone/manifest.js';
4
4
  import { type ExecutorPolicy } from './executor-policy.js';
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { templatesRoot } from '../template-paths.js';
4
- import { goBuildEnv } from '../build-spk/dsm-arch.js';
4
+ import { PINNED_GO_HINT, pinnedGoBuildEnv, pinnedGoProblem } from '../build-spk/go-toolchain.js';
5
5
  import { StandaloneBuildError } from '../standalone/spk.js';
6
6
  import { checkCgiBinary } from '../standalone/binary.js';
7
7
  import { executorPolicy, executorPolicyText, sha256 } from './executor-policy.js';
@@ -15,19 +15,22 @@ export function buildExecutor(m, arch, work, run) {
15
15
  if (!existsSync(join(source, 'go.mod')))
16
16
  throw new StandaloneBuildError('EXECUTOR_BUILD_FAILED', 'Executor sources are missing.');
17
17
  const binary = join(dir, 'executor');
18
- const result = run('go', ['build', '-trimpath', '-buildvcs=false', '-ldflags', '-s -w -buildid=', '-o', binary, '.'], { cwd: source, env: { ...process.env, ...goBuildEnv(arch) } });
18
+ const result = run('go', ['build', '-trimpath', '-buildvcs=false', '-ldflags', '-s -w -buildid=', '-o', binary, '.'], { cwd: source, env: { ...process.env, ...pinnedGoBuildEnv(arch) } });
19
19
  if (result.missing || result.status !== 0)
20
- throw new StandaloneBuildError('EXECUTOR_BUILD_FAILED', `Executor build failed (${arch}). ${result.stderr}`);
20
+ throw new StandaloneBuildError('EXECUTOR_BUILD_FAILED', `Executor build failed (${arch}). ${PINNED_GO_HINT}\n${result.stderr}`);
21
21
  const problem = checkCgiBinary(work, binary, arch);
22
22
  if (problem)
23
23
  throw new StandaloneBuildError(problem.code, problem.message);
24
24
  const lifecycleBinary = join(dir, 'lifecycle');
25
- const lifecycleResult = run('go', ['build', '-trimpath', '-buildvcs=false', '-ldflags', '-s -w -buildid=', '-o', lifecycleBinary, './cmd/lifecycle'], { cwd: source, env: { ...process.env, ...goBuildEnv(arch) } });
25
+ const lifecycleResult = run('go', ['build', '-trimpath', '-buildvcs=false', '-ldflags', '-s -w -buildid=', '-o', lifecycleBinary, './cmd/lifecycle'], { cwd: source, env: { ...process.env, ...pinnedGoBuildEnv(arch) } });
26
26
  if (lifecycleResult.missing || lifecycleResult.status !== 0)
27
- throw new StandaloneBuildError('EXECUTOR_BUILD_FAILED', `Lifecycle helper build failed (${arch}). ${lifecycleResult.stderr}`);
27
+ throw new StandaloneBuildError('EXECUTOR_BUILD_FAILED', `Lifecycle helper build failed (${arch}). ${PINNED_GO_HINT}\n${lifecycleResult.stderr}`);
28
28
  const lifecycleProblem = checkCgiBinary(work, lifecycleBinary, arch);
29
29
  if (lifecycleProblem)
30
30
  throw new StandaloneBuildError(lifecycleProblem.code, lifecycleProblem.message);
31
+ const toolchainProblem = pinnedGoProblem(binary) ?? pinnedGoProblem(lifecycleBinary);
32
+ if (toolchainProblem)
33
+ throw new StandaloneBuildError('EXECUTOR_BUILD_FAILED', toolchainProblem);
31
34
  const policy = executorPolicy(m), policyText = executorPolicyText(policy);
32
35
  const pins = { package: m.package, executorDigest: sha256(readFileSync(binary)), policyDigest: sha256(policyText) };
33
36
  return { ...pins, binary, lifecycleBinary, policy, policyText, tasks: executorTasks(pins), implementation: 'executor' };
@@ -1,4 +1,4 @@
1
- import { type NativeArchFamily } from "../build-spk/dsm-arch.js";
1
+ import type { NativeArchFamily } from "../build-spk/dsm-arch.js";
2
2
  import type { CommandRunner } from "./build.js";
3
3
  import type { StandaloneError, StandaloneManifest } from "./manifest.js";
4
4
  export interface Daemon {
@@ -15,6 +15,7 @@ export interface Daemon {
15
15
  start: number;
16
16
  stop: number;
17
17
  status: number;
18
+ uninstall: number;
18
19
  };
19
20
  }
20
21
  export declare function readDaemon(value: unknown, root: string, arches: NativeArchFamily[], fail: (error: StandaloneError) => void): Daemon | undefined;
@@ -1,6 +1,6 @@
1
1
  import { chmodSync, copyFileSync, lstatSync, mkdirSync, readdirSync, realpathSync, writeFileSync } from "node:fs";
2
2
  import { isAbsolute, join, relative, resolve } from "node:path";
3
- import { goBuildEnv } from "../build-spk/dsm-arch.js";
3
+ import { PINNED_GO_HINT, pinnedGoBuildEnv, pinnedGoProblem } from "../build-spk/go-toolchain.js";
4
4
  import { templatesRoot } from "../template-paths.js";
5
5
  import { checkCgiBinary } from "./binary.js";
6
6
  import { StandaloneBuildError } from "./spk.js";
@@ -41,11 +41,12 @@ export function readDaemon(value, root, arches, fail) {
41
41
  if (!record(env) || Object.keys(env).length > 64 || Object.entries(env).some(([k, v]) => !envKey(k) || !token(v)))
42
42
  return invalid();
43
43
  const limits = value.timeouts ?? {};
44
- if (!record(limits) || Object.keys(limits).some(k => !["start", "stop", "status"].includes(k)))
44
+ if (!record(limits) || Object.keys(limits).some(k => !["start", "stop", "status", "uninstall"].includes(k)))
45
45
  return invalid();
46
- const timeouts = { start: limits.start ?? 5, stop: limits.stop ?? 10, status: limits.status ?? 2 };
46
+ // Defaults hold under IO load on a busy NAS; DSM's package unit allows 3600 s.
47
+ const timeouts = { start: limits.start ?? 30, stop: limits.stop ?? 30, status: limits.status ?? 5, uninstall: limits.uninstall ?? 60 };
47
48
  for (const [key, v] of Object.entries(timeouts)) {
48
- if (!Number.isInteger(v) || v < (key === "start" ? 2 : key === "stop" ? 2 : 1) || v > (key === "status" ? 10 : 120))
49
+ if (!Number.isInteger(v) || v < (key === "status" ? 1 : 2) || v > (key === "status" ? 10 : 120))
49
50
  return invalid();
50
51
  }
51
52
  return { exec: value.exec, targets, args, ...(uninstall ? { uninstall_args: uninstall } : {}), env: Object.fromEntries(Object.keys(env).sort().map(key => [key, env[key]])), timeouts: timeouts };
@@ -59,13 +60,16 @@ export function buildDaemonControl(manifest, arch, work, run) {
59
60
  return undefined;
60
61
  const out = join(work, `daemon-control-${arch}`);
61
62
  const result = run("go", ["build", "-trimpath", "-buildvcs=false", "-ldflags", "-s -w -buildid=", "-o", out, "."], {
62
- cwd: join(templatesRoot(), "_daemon"), env: { ...process.env, ...goBuildEnv(arch) },
63
+ cwd: join(templatesRoot(), "_daemon"), env: { ...process.env, ...pinnedGoBuildEnv(arch) },
63
64
  });
64
65
  if (result.missing || result.status !== 0)
65
- throw new StandaloneBuildError("STANDALONE_DAEMON_BUILD_FAILED", `daemon controller build 需要 Go 1.24 以上。\n${result.stderr}`);
66
+ throw new StandaloneBuildError("STANDALONE_DAEMON_BUILD_FAILED", `daemon controller build failed (${arch}). ${PINNED_GO_HINT}\n${result.stderr}`);
66
67
  const problem = checkCgiBinary(work, out, arch);
67
68
  if (problem)
68
69
  throw new StandaloneBuildError(problem.code, problem.message);
70
+ const toolchainProblem = pinnedGoProblem(out);
71
+ if (toolchainProblem)
72
+ throw new StandaloneBuildError("STANDALONE_DAEMON_BUILD_FAILED", toolchainProblem);
69
73
  return out;
70
74
  }
71
75
  export function copyDaemonPayload(manifest, arch, target, controller) {
@@ -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
  /**
@@ -1,9 +1,10 @@
1
1
  import { REOPEN_EVENTS_RUNTIME } from "./reopen-events.js";
2
2
  import { DSM_LANGS } from "./i18n.js";
3
- import { ROOT_PANEL, ROOT_RUNTIME, rootTasks } from "./root-window.js";
3
+ import { PASSWORD_CONFIRM, ROOT_PANEL, ROOT_RUNTIME, rootTasks } from "./root-window.js";
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
@@ -316,6 +330,10 @@ ${ns}.mountRootPanel = (function () {
316
330
  ${ROOT_PANEL}
317
331
  return mountRootPanel;
318
332
  })();
333
+ ${ns}.createPasswordConfirm = (function () {
334
+ ${PASSWORD_CONFIRM}
335
+ return createPasswordConfirm;
336
+ })();
319
337
 
320
338
  `,
321
339
  html: `autoScroll: true,
@@ -329,46 +347,14 @@ ${ROOT_PANEL}
329
347
  pins: ${JSON.stringify({ package: manifest.package, executorDigest: manifest.executor.executorDigest, policyDigest: manifest.executor.policyDigest })},
330
348
  user: session,
331
349
  send: ${ns}.webApi(me),
332
- confirmPassword: function (suppliedPassword) {
333
- // Send exactly one PasswordConfirm v2 auth with a masked password field.
334
- return new Promise(function (resolve, reject) {
335
- if (typeof suppliedPassword === "string" && suppliedPassword) {
336
- me.sendWebAPI({api: "SYNO.Core.User.PasswordConfirm", method: "auth", version: 2, params: {password: suppliedPassword}, scope: me,
337
- callback: function(success, response) { suppliedPassword = ""; if (!success) reject({code: "dsm_" + ((response && response.code) || "error")}); else resolve({confirmed: true, token: response && response.SynoConfirmPWToken}); }
338
- });
339
- suppliedPassword = "";
340
- return;
341
- }
342
- var form = document.createElement("form"), label = document.createElement("label"), input = document.createElement("input");
343
- var submit = document.createElement("button"), cancel = document.createElement("button");
344
- label.textContent = "Confirm your DSM administrator password";
345
- input.type = "password"; input.autocomplete = "current-password"; input.required = true;
346
- label.appendChild(input); form.appendChild(label);
347
- submit.type = "submit"; submit.textContent = "Approve"; form.appendChild(submit);
348
- cancel.type = "button"; cancel.textContent = "Cancel"; form.appendChild(cancel);
349
- me.body.dom.appendChild(form); input.focus();
350
- function close() { input.value = ""; if (form.parentNode) form.parentNode.removeChild(form); }
351
- cancel.onclick = function() { close(); resolve({confirmed: false}); };
352
- form.onsubmit = function(event) {
353
- event.preventDefault(); submit.disabled = cancel.disabled = true;
354
- var password = input.value; input.value = "";
355
- me.sendWebAPI({api: "SYNO.Core.User.PasswordConfirm", method: "auth", version: 2, params: {password: password}, scope: me,
356
- callback: function(success, response) {
357
- password = ""; close();
358
- if (!success) { reject({code: "dsm_" + ((response && response.code) || "error")}); return; }
359
- resolve({confirmed: true, token: response && response.SynoConfirmPWToken});
360
- }
361
- });
362
- };
363
- });
364
- },
350
+ confirmPassword: ${ns}.createPasswordConfirm(me),
365
351
  call: ${ns}.call,
366
352
  wait: function (ms) { return new Promise(function (resolve) { setTimeout(resolve, ms); }); }
367
353
  });
368
- var panels = me.body && me.body.dom.querySelector(".dsmapp-rootpanels");
369
- 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) {
370
356
  ${ns}.mountRootPanel(panels, access, null, ${ns}.rootTasks, session().admin);
371
357
  }
372
- `,
358
+ `}`,
373
359
  };
374
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) {
@@ -211,7 +246,11 @@ async function checkDaemon(dir, info, paths, payload, findings, executor, backgr
211
246
  if (payload.find(p => p.path === `daemon/payload/${daemon.exec}`)?.mode !== "0755")
212
247
  throw new Error("missing executable");
213
248
  const manifest = { package: info.package, daemon, capabilities: { ...(executor ? { rootOps: true } : {}), ...(background ? { jobs: { worker: 'sha256-v1' } } : {}) } };
214
- if (readFileSync(join(unpacked, "daemon/config.json"), "utf8") !== daemonConfig(manifest))
249
+ // A config from before timeouts.uninstall pairs with a control that rejects the key.
250
+ const legacy = typeof config.timeouts === "object" && config.timeouts !== null && !("uninstall" in config.timeouts);
251
+ const { uninstall: _, ...legacyTimeouts } = daemon.timeouts;
252
+ const canonical = daemonConfig(legacy ? { ...manifest, daemon: { ...daemon, timeouts: legacyTimeouts } } : manifest);
253
+ if (readFileSync(join(unpacked, "daemon/config.json"), "utf8") !== canonical)
215
254
  throw new Error("non-canonical daemon config");
216
255
  for (const hook of ["start-stop-status", "preupgrade", "preuninst"]) {
217
256
  if (readFileSync(join(dir, "scripts", hook), "utf8") !== renderStandaloneScript(hook, manifest))
@@ -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