@synopackageland/cli 0.9.4 → 0.9.6

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 (73) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/README.md +1 -1
  3. package/assets/licenses/MUSL-COPYRIGHT +193 -0
  4. package/assets/licenses/RUST-COPYRIGHT-library.html +5556 -0
  5. package/dist/add.js +29 -29
  6. package/dist/build-spk/dsm-version.d.ts +14 -4
  7. package/dist/build-spk/dsm-version.js +17 -5
  8. package/dist/build-spk/notices.d.ts +8 -1
  9. package/dist/build-spk/notices.js +13 -4
  10. package/dist/build-spk/uninstall-wizard.d.ts +11 -0
  11. package/dist/build-spk/uninstall-wizard.js +16 -3
  12. package/dist/build-spk/wizard-i18n.d.ts +1 -1
  13. package/dist/build-spk/wizard-i18n.js +1 -1
  14. package/dist/build-spk/wizard-messages.d.ts +44 -0
  15. package/dist/build-spk/wizard-messages.js +44 -0
  16. package/dist/capability-overlays.d.ts +10 -5
  17. package/dist/capability-overlays.js +32 -10
  18. package/dist/cli.js +5 -5
  19. package/dist/deploy/plan.d.ts +1 -1
  20. package/dist/deploy/plan.js +1 -1
  21. package/dist/error-registry.js +46 -4
  22. package/dist/info.js +4 -3
  23. package/dist/init.js +53 -28
  24. package/dist/portal/alias.d.ts +8 -0
  25. package/dist/portal/alias.js +16 -0
  26. package/dist/runtime/executor-build.d.ts +1 -1
  27. package/dist/runtime/executor-build.js +2 -1
  28. package/dist/runtime/executor-digests.json +22 -0
  29. package/dist/runtime/executor-policy.d.ts +15 -0
  30. package/dist/runtime/executor-policy.js +32 -6
  31. package/dist/runtime/sdk-source.d.ts +14 -2
  32. package/dist/runtime/sdk-source.js +39 -4
  33. package/dist/runtime/uninstall-cleanup.d.ts +5 -1
  34. package/dist/runtime/uninstall-cleanup.js +11 -1
  35. package/dist/standalone/api-permissions.d.ts +4 -2
  36. package/dist/standalone/api-permissions.js +6 -5
  37. package/dist/standalone/binary.d.ts +3 -2
  38. package/dist/standalone/binary.js +17 -5
  39. package/dist/standalone/build.js +16 -12
  40. package/dist/standalone/daemon.d.ts +6 -0
  41. package/dist/standalone/daemon.js +8 -4
  42. package/dist/standalone/dsm-window.js +16 -3
  43. package/dist/standalone/inspect-spk.d.ts +1 -1
  44. package/dist/standalone/inspect-spk.js +46 -13
  45. package/dist/standalone/lifecycle.js +38 -4
  46. package/dist/standalone/manifest.d.ts +12 -0
  47. package/dist/standalone/manifest.js +62 -20
  48. package/dist/standalone/native-layout.d.ts +4 -7
  49. package/dist/standalone/native-layout.js +41 -102
  50. package/dist/standalone/native-root-window.d.ts +2 -1
  51. package/dist/standalone/native-root-window.js +15 -5
  52. package/dist/standalone/portal-window.d.ts +4 -2
  53. package/dist/standalone/portal-window.js +54 -12
  54. package/dist/standalone/portal.d.ts +4 -1
  55. package/dist/standalone/portal.js +8 -2
  56. package/dist/standalone/root-ops.d.ts +2 -0
  57. package/dist/standalone/root-ops.js +2 -0
  58. package/dist/standalone/rust-build.d.ts +35 -0
  59. package/dist/standalone/rust-build.js +127 -0
  60. package/dist/standalone/sdk-check.d.ts +5 -3
  61. package/dist/standalone/sdk-check.js +21 -8
  62. package/dist/standalone/spk.js +15 -3
  63. package/dist/standalone/toolkit.d.ts +10 -0
  64. package/dist/standalone/toolkit.js +101 -0
  65. package/dist/standalone/uninstall-hook.d.ts +10 -0
  66. package/dist/standalone/uninstall-hook.js +71 -0
  67. package/dist/standalone/uninstall.d.ts +53 -0
  68. package/dist/standalone/uninstall.js +136 -0
  69. package/dist/types.d.ts +1 -1
  70. package/dist/yaml-edit.d.ts +6 -0
  71. package/dist/yaml-edit.js +31 -0
  72. package/package.json +2 -2
  73. package/skills/reference/portal.md +8 -4
@@ -1,5 +1,14 @@
1
+ /** The root commands that remove an Application Portal entry the executor could not unregister. */
2
+ export function portalEntryCleanupCommands(pkg) {
3
+ return [
4
+ `rm -f /usr/syno/etc/www/app.d/${pkg}.json /usr/syno/etc/www/app.d/${pkg}.mustache /usr/syno/etc/packages/${pkg}/AppPortal.json`,
5
+ `for c in dsm www alias .alias; do n=$c.syno-app-portal.${pkg}.conf; synow3tool --disable-nginx-confd=$n; synow3tool --unreg-nginx-confd=$n; done`,
6
+ `for c in server .server; do n=$c.syno-app-portal.${pkg}.conf; synow3tool --disable-nginx-sites=$n; synow3tool --unreg-nginx-sites=$n; done`,
7
+ 'nginx -t && synow3tool --nginx=reload',
8
+ ];
9
+ }
1
10
  const AUTOMATIC_FAILED = 'Only if automatic cleanup failed after uninstall; see the system log entry synopkgland-executor.';
2
- export function uninstallCleanup(pkg, { executor = false, portal = false } = {}) {
11
+ export function uninstallCleanup(pkg, { executor = false, portal = false, portalEntry = false } = {}) {
3
12
  const items = [
4
13
  { target: `@appdata/${pkg}, @appconf/${pkg}, @apphome/${pkg}`, condition: 'After uninstall, if retained data or empty DSM directories are no longer needed.', action: 'Back up retained data; an administrator may remove only these package-specific directories. The delete-data checkbox empties their contents and preserves DSM directory roots.' },
5
14
  { target: `@appshare/${pkg}, /usr/syno/etc/packages/${pkg}`, condition: 'After uninstall, if DSM leaves package infrastructure or a package-config symlink.', action: 'Administrator: inspect these package-specific paths and remove unused infrastructure; do not follow the symlink to delete shared data.' },
@@ -8,6 +17,7 @@ export function uninstallCleanup(pkg, { executor = false, portal = false } = {})
8
17
  ? { target: `SYNO.SDS.${pkg}.Instance rules in Application Privileges`, condition: AUTOMATIC_FAILED, action: 'The root executor removes these rules automatically through the Application Privileges API on uninstall. Administrator: remove surviving rules in Control Panel > Application Privileges; DSM would apply them again to a later install under the same name.' }
9
18
  : { target: `SYNO.SDS.${pkg}.Instance rules in Application Privileges`, condition: 'After uninstall, if this package will not be reinstalled.', action: 'DSM keeps these rules in /etc/synoappprivilege.db and applies them again to a later install under the same name. Administrator: remove them in Control Panel > Application Privileges.' },
10
19
  ...(executor && portal ? [{ target: `${pkg} Login Portal alias`, condition: AUTOMATIC_FAILED, action: 'DSM removes the alias configuration without reloading its web server; the root executor then reloads the web server automatically. Administrator: if the alias still opens a DSM page, save an application alias in Control Panel > Login Portal > Applications to reload it, or restart the NAS.' }] : []),
20
+ ...(executor && portalEntry ? [{ target: `${pkg} Application Portal entry`, condition: 'After uninstall, if the executor was not running or was never approved, so it could not remove the entry.', action: `Administrator, as root: ${portalEntryCleanupCommands(pkg).join('; ')}` }] : []),
11
21
  { target: `${pkg} package account/group`, condition: 'After uninstall, only if this package will not be reinstalled and the identity is unused.', action: 'Administrator: review DSM internal account/group and associated ACLs before cleanup. Package scripts do not delete system identities.' },
12
22
  ];
13
23
  if (executor)
@@ -1,5 +1,7 @@
1
1
  import type { Capabilities } from "./manifest.js";
2
- export declare const API_PERMISSIONS_FILE = "internal/api/permissions.json";
2
+ import { type SdkBackend } from "../runtime/sdk-source.js";
3
+ /** The App route policy inside each backend kind's directory. */
4
+ export declare const API_PERMISSIONS_FILE: Record<SdkBackend["kind"], string>;
3
5
  type SdkPermissions = Record<string, Record<string, ApiPermission>>;
4
6
  export declare const MAX_PERMISSIONS_BYTES = 65536;
5
7
  export declare const MAX_PERMISSION_METHODS = 1024;
@@ -10,7 +12,7 @@ export interface ApiPermissions {
10
12
  methods: Record<string, ApiPermission>;
11
13
  }
12
14
  export declare function parseApiPermissions(raw: Uint8Array): ApiPermissions;
13
- export declare function readApiPermissions(backendDir: string, capabilities?: Capabilities): {
15
+ export declare function readApiPermissions(backend: SdkBackend, capabilities?: Capabilities): {
14
16
  policy: ApiPermissions;
15
17
  digest: string;
16
18
  sdkDigest: string;
@@ -1,9 +1,10 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { lstatSync, readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
- import { sdkRequirement, sdkSource } from "../runtime/sdk-source.js";
4
+ import { appSdkVersion, sdkSource } from "../runtime/sdk-source.js";
5
5
  import { parseDocument } from "yaml";
6
- export const API_PERMISSIONS_FILE = "internal/api/permissions.json";
6
+ /** The App route policy inside each backend kind's directory. */
7
+ export const API_PERMISSIONS_FILE = { go: "internal/api/permissions.json", rust: "permissions.json" };
7
8
  /** SDK-owned routes per capability, in the Go module of the SDK version the App requires. */
8
9
  const SDK_PERMISSIONS_FILE = "api/sdk-permissions.json";
9
10
  export const MAX_PERMISSIONS_BYTES = 65536;
@@ -32,14 +33,14 @@ export function parseApiPermissions(raw) {
32
33
  }
33
34
  return value;
34
35
  }
35
- export function readApiPermissions(backendDir, capabilities = {}) {
36
- const file = join(backendDir, API_PERMISSIONS_FILE);
36
+ export function readApiPermissions(backend, capabilities = {}) {
37
+ const file = join(backend.dir, API_PERMISSIONS_FILE[backend.kind]);
37
38
  const stat = lstatSync(file);
38
39
  if (!stat.isFile() || stat.size > MAX_PERMISSIONS_BYTES)
39
40
  throw new Error("permissions must be a regular bounded JSON file");
40
41
  const raw = readFileSync(file);
41
42
  const policy = parseApiPermissions(raw);
42
- const version = sdkRequirement(backendDir);
43
+ const version = appSdkVersion(backend);
43
44
  const sdkRaw = version ? readFileSync(join(sdkSource(version).goDir, SDK_PERMISSIONS_FILE)) : Buffer.from("{}");
44
45
  const sdk = JSON.parse(sdkRaw.toString("utf8"));
45
46
  for (const methods of Object.values(sdk))
@@ -4,7 +4,8 @@ export declare const ELF_MACHINE: Record<NativeArchFamily, number>;
4
4
  /**
5
5
  * Checks a prebuilt CGI before it is packaged: a regular file inside the
6
6
  * project, a little-endian ELF of `arch`'s class and machine, and statically linked
7
- * (no PT_INTERP), because DSM models ship different libc versions.
7
+ * (no PT_INTERP), because DSM models ship different libc versions; only a
8
+ * build that links against a known DSM glibc may name its `interpreter`.
8
9
  * Returns undefined when the file is acceptable.
9
10
  */
10
- export declare function checkCgiBinary(projectRoot: string, file: string, arch: NativeArchFamily): StandaloneError | undefined;
11
+ export declare function checkCgiBinary(projectRoot: string, file: string, arch: NativeArchFamily, interpreter?: string): StandaloneError | undefined;
@@ -11,10 +11,11 @@ function fail(code, message) {
11
11
  /**
12
12
  * Checks a prebuilt CGI before it is packaged: a regular file inside the
13
13
  * project, a little-endian ELF of `arch`'s class and machine, and statically linked
14
- * (no PT_INTERP), because DSM models ship different libc versions.
14
+ * (no PT_INTERP), because DSM models ship different libc versions; only a
15
+ * build that links against a known DSM glibc may name its `interpreter`.
15
16
  * Returns undefined when the file is acceptable.
16
17
  */
17
- export function checkCgiBinary(projectRoot, file, arch) {
18
+ export function checkCgiBinary(projectRoot, file, arch, interpreter) {
18
19
  let stat;
19
20
  try {
20
21
  stat = lstatSync(file);
@@ -32,7 +33,7 @@ export function checkCgiBinary(projectRoot, file, arch) {
32
33
  if (stat.size > MAX_BINARY_BYTES) {
33
34
  return fail("STANDALONE_BINARY_TOO_LARGE", `${file} 超過 256 MiB。`);
34
35
  }
35
- return checkElf(readHeader(file), file, arch);
36
+ return checkElf(readHeader(file), file, arch, interpreter);
36
37
  }
37
38
  function readHeader(file) {
38
39
  const fd = openSync(file, "r");
@@ -58,7 +59,7 @@ function readElfHeader(buf) {
58
59
  }
59
60
  return undefined;
60
61
  }
61
- function checkElf(buf, file, arch) {
62
+ function checkElf(buf, file, arch, interpreter) {
62
63
  const bits = ELF_CLASS64[arch] ? 64 : 32;
63
64
  const notElf = fail("STANDALONE_BINARY_NOT_ELF", `${file} 不是 ${arch} 需要的 ${bits}-bit little-endian Linux ELF 執行檔。`);
64
65
  const header = readElfHeader(buf);
@@ -73,9 +74,20 @@ function checkElf(buf, file, arch) {
73
74
  return notElf;
74
75
  }
75
76
  for (let i = 0; i < phnum; i++) {
76
- if (buf.readUInt32LE(phoff + i * phentsize) === PT_INTERP) {
77
+ const entry = phoff + i * phentsize;
78
+ if (buf.readUInt32LE(entry) === PT_INTERP && (interpreter === undefined || interpreterOf(buf, entry, header.class64) !== interpreter)) {
77
79
  return fail("STANDALONE_BINARY_DYNAMIC", `${file} 是動態連結執行檔。請改用靜態連結(Go:CGO_ENABLED=0;Rust:*-unknown-linux-musl)。`);
78
80
  }
79
81
  }
80
82
  return undefined;
81
83
  }
84
+ /** The path a PT_INTERP program header names, when it lies within the read header bytes. */
85
+ function interpreterOf(buf, entry, class64) {
86
+ if (entry + (class64 ? 40 : 20) > buf.length)
87
+ return undefined;
88
+ const offset = class64 ? Number(buf.readBigUInt64LE(entry + 8)) : buf.readUInt32LE(entry + 4);
89
+ const size = class64 ? Number(buf.readBigUInt64LE(entry + 32)) : buf.readUInt32LE(entry + 16);
90
+ if (size < 1 || offset + size > buf.length || buf[offset + size - 1] !== 0)
91
+ return undefined;
92
+ return buf.toString("latin1", offset, offset + size - 1);
93
+ }
@@ -4,9 +4,10 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { goBuildEnv } from "../build-spk/dsm-arch.js";
7
- import { resolveMinDsm, DEFAULT_MIN_DSM, isDsmAtLeast, PORTAL_MIN_DSM, portalMinDsmMessage } from "../build-spk/dsm-version.js";
7
+ import { resolveMinDsm, DEFAULT_MIN_DSM, isDsmAtLeast, PORTAL_EXECUTOR_MIN_DSM, portalByExecutor, portalMinDsmMessage } from "../build-spk/dsm-version.js";
8
8
  import { checkCgiBinary } from "./binary.js";
9
9
  import { loadStandaloneManifest } from "./manifest.js";
10
+ import { buildRustBackend } from "./rust-build.js";
10
11
  import { assembleStandaloneSpk, StandaloneBuildError } from "./spk.js";
11
12
  import { buildDaemonControl } from "./daemon.js";
12
13
  import { buildExecutor } from "../runtime/executor-build.js";
@@ -80,7 +81,7 @@ export async function buildStandalone(options) {
80
81
  if (manifest.capabilities.rootOps === true && !isDsmAtLeast(manifest.os_min_ver, DEFAULT_MIN_DSM)) {
81
82
  throw new StandaloneBuildError("STANDALONE_OS_MIN_VER_INVALID", `root templates require DSM ${DEFAULT_MIN_DSM} or later.`);
82
83
  }
83
- if (manifest.portal && !isDsmAtLeast(manifest.os_min_ver, PORTAL_MIN_DSM)) {
84
+ if (manifest.portal && !isDsmAtLeast(manifest.os_min_ver, PORTAL_EXECUTOR_MIN_DSM)) {
84
85
  throw new StandaloneBuildError("STANDALONE_PORTAL_MIN_DSM", `[STANDALONE_PORTAL_MIN_DSM] ${portalMinDsmMessage(manifest.os_min_ver, "portal")}`);
85
86
  }
86
87
  const jobs = checkJobs(options.jobs);
@@ -102,14 +103,9 @@ export async function buildStandalone(options) {
102
103
  const executor = await buildExecutor(manifest, arch, archWork, run);
103
104
  executor?.warnings.forEach(warning => warnings.add(warning));
104
105
  const builtManifest = executor ? { ...manifest, executor } : manifest;
105
- const cgiPath = await backendFor(builtManifest, arch, archWork, run);
106
- const problem = checkCgiBinary(manifest.backend.kind === "go" ? archWork : manifest.root, cgiPath, arch);
107
- if (problem) {
108
- throw new StandaloneBuildError(problem.code, problem.message);
109
- }
106
+ const { cgi: cgiPath, worker: backgroundWorker } = await backendFor(builtManifest, arch, archWork, run);
110
107
  const outputPath = join(stage, `${manifest.package}-${manifest.spkVersion}-${arch}.spk`);
111
108
  const daemonControl = await buildDaemonControl(manifest, arch, archWork, run);
112
- const backgroundWorker = await buildBackgroundWorker(builtManifest, arch, archWork, run);
113
109
  await assembleStandaloneSpk({ manifest: builtManifest, arch, cgiPath, outputPath, daemonControl, backgroundWorker, executor });
114
110
  return outputPath;
115
111
  }));
@@ -133,14 +129,22 @@ async function buildUi(manifest, run) {
133
129
  throw new StandaloneBuildError("STANDALONE_UI_BUILD_FAILED", `UI build(${build.command.join(" ")})失敗。${installHint}\n${result.stderr}`);
134
130
  }
135
131
  }
132
+ /** The checked CGI of one architecture and, with capabilities.jobs, its fixed worker. */
136
133
  async function backendFor(manifest, arch, work, run) {
137
134
  const backend = manifest.backend;
138
- if (backend.kind === "binary") {
139
- return backend.files[arch];
135
+ if (backend.kind === "rust")
136
+ return buildRustBackend(manifest, arch, work, run);
137
+ const cgi = backend.kind === "binary" ? backend.files[arch] : await buildGoCgi(manifest, backend, arch, work, run);
138
+ const problem = checkCgiBinary(backend.kind === "go" ? work : manifest.root, cgi, arch);
139
+ if (problem) {
140
+ throw new StandaloneBuildError(problem.code, problem.message);
140
141
  }
142
+ return { cgi, worker: await buildBackgroundWorker(manifest, arch, work, run) };
143
+ }
144
+ async function buildGoCgi(manifest, backend, arch, work, run) {
141
145
  // UI build hooks run after validation. Refuse policy changes rather than
142
146
  // compiling CGI permissions different from the launcher metadata snapshot.
143
- if (readApiPermissions(backend.dir, manifest.capabilities).digest !== manifest.apiPermissionsDigest || readApiPermissions(backend.dir, manifest.capabilities).sdkDigest !== manifest.apiSDKPermissionsDigest) {
147
+ if (readApiPermissions(backend, manifest.capabilities).digest !== manifest.apiPermissionsDigest || readApiPermissions(backend, manifest.capabilities).sdkDigest !== manifest.apiSDKPermissionsDigest) {
144
148
  throw new StandaloneBuildError("STANDALONE_API_PERMISSIONS_CHANGED", "API permissions changed during build; check and rebuild.");
145
149
  }
146
150
  const module = goModulePath(backend.dir);
@@ -160,7 +164,7 @@ async function backendFor(manifest, arch, work, run) {
160
164
  ...(manifest.capabilities.identity ? [`-X main.identity=${manifest.capabilities.identity}`] : []),
161
165
  ...(manifest.capabilities.files || manifest.capabilities.delegate ? [`-X main.fileStation=session`] : []),
162
166
  ...(manifest.entry ? [`-X main.entry=${manifest.entry}`] : []),
163
- ...(manifest.portal ? ["-X main.portal=on"] : []),
167
+ ...(manifest.portal ? [`-X main.portal=${portalByExecutor(manifest.os_min_ver) ? "executor" : "on"}`] : []),
164
168
  ].join(" ");
165
169
  const result = await run("go", ["build", "-trimpath", "-buildvcs=false", "-ldflags", ldflags, "-o", out, backend.main], {
166
170
  cwd: backend.dir,
@@ -18,6 +18,12 @@ export interface Daemon {
18
18
  uninstall: number;
19
19
  };
20
20
  }
21
+ /** A fixed argv or env value: only the package's own directories expand. */
22
+ export declare const token: (v: unknown) => v is string;
23
+ /** An application variable that cannot steer the loader, the shell or DSM. */
24
+ export declare const envKey: (k: string) => boolean;
25
+ /** A relative path to a fixed executable, without `.` or `..` parts. */
26
+ export declare const relativeExec: (v: unknown) => v is string;
21
27
  export declare function readDaemon(value: unknown, root: string, arches: NativeArchFamily[], fail: (error: StandaloneError) => void): Daemon | undefined;
22
28
  export declare function daemonConfig(manifest: StandaloneManifest): string;
23
29
  export declare function buildDaemonControl(manifest: StandaloneManifest, arch: NativeArchFamily, work: string, run: CommandRunner): Promise<string | undefined>;
@@ -6,18 +6,22 @@ import { checkCgiBinary } from "./binary.js";
6
6
  import { StandaloneBuildError } from "./spk.js";
7
7
  const fields = new Set(["exec", "targets", "args", "env", "timeouts", "uninstall_args"]);
8
8
  const record = (v) => Boolean(v) && typeof v === "object" && !Array.isArray(v);
9
- const token = (v) => typeof v === "string" && Buffer.byteLength(v) <= 4096 && !v.includes("\0")
9
+ /** A fixed argv or env value: only the package's own directories expand. */
10
+ export const token = (v) => typeof v === "string" && Buffer.byteLength(v) <= 4096 && !v.includes("\0")
10
11
  && !v.replaceAll("${SYNOPKG_PKGDEST}", "").replaceAll("${SYNOPKG_PKGVAR}", "").includes("${");
11
- const envKey = (k) => /^[A-Z][A-Z0-9_]{0,63}$/.test(k)
12
+ /** An application variable that cannot steer the loader, the shell or DSM. */
13
+ export const envKey = (k) => /^[A-Z][A-Z0-9_]{0,63}$/.test(k)
12
14
  && !/^(PATH|HOME|TMPDIR|ENV|BASH_ENV|SHELLOPTS|BASHOPTS|GODEBUG|GOTRACEBACK|LD_.*|DYLD_.*|SYNOPKG_.*)$/.test(k);
15
+ /** A relative path to a fixed executable, without `.` or `..` parts. */
16
+ export const relativeExec = (v) => typeof v === "string" && v.length <= 200
17
+ && /^[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_.-]+)*$/.test(v) && !v.split("/").some(p => p === "." || p === "..");
13
18
  export function readDaemon(value, root, arches, fail) {
14
19
  if (value === undefined)
15
20
  return undefined;
16
21
  const invalid = () => { fail({ code: "STANDALONE_DAEMON_INVALID", message: "daemon 只接受固定 exec、targets、args、env 與 timeouts;路徑須在專案內,環境不接受 loader/shell/system 變數。" }); return undefined; };
17
22
  if (!record(value) || Object.keys(value).some(k => !fields.has(k)))
18
23
  return invalid();
19
- if (typeof value.exec !== "string" || !/^[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_.-]+)*$/.test(value.exec)
20
- || value.exec.split("/").some(p => p === "." || p === "..") || value.exec.length > 200)
24
+ if (!relativeExec(value.exec))
21
25
  return invalid();
22
26
  if (!record(value.targets) || Object.keys(value.targets).length !== arches.length)
23
27
  return invalid();
@@ -4,6 +4,7 @@ import { PASSWORD_CONFIRM, ROOT_PANEL, ROOT_RUNTIME, rootPanelCatalogs } from ".
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 { portalByExecutor } from "../build-spk/dsm-version.js";
7
8
  import { uiTitleFields } from "../build-spk/ui-texts.js";
8
9
  /** Same path the generated wrapper POSTs to, and the payload location of the CGI. */
9
10
  export const CGI_PAYLOAD_PATH = "ui/cgi/api.cgi";
@@ -172,6 +173,15 @@ ${root ? root.definitions : ""}${manifest.portal ? renderPortalDefinition(ns) :
172
173
  document.head.appendChild(js);
173
174
  };
174
175
 
176
+ // DSM reopens a window at the size it was last left at, even one saved on a
177
+ // larger screen, and only moves a window that does not fit: keep it inside.
178
+ ${ns}.fitDesktop = function (size) {
179
+ var taskbar = SYNO.SDS.TaskBar ? SYNO.SDS.TaskBar.height() : 0;
180
+ size.width = Math.min(size.width, Ext.lib.Dom.getViewWidth());
181
+ size.height = Math.min(size.height, Ext.lib.Dom.getViewHeight() - taskbar);
182
+ return size;
183
+ };
184
+
175
185
  Ext.define(${JSON.stringify(`${ns}.Instance`)}, {
176
186
  extend: "SYNO.SDS.AppInstance",
177
187
  appWindowName: ${JSON.stringify(`${ns}.MainWindow`)}
@@ -180,10 +190,13 @@ Ext.define(${JSON.stringify(`${ns}.Instance`)}, {
180
190
  Ext.define(${JSON.stringify(`${ns}.MainWindow`)}, {
181
191
  extend: "SYNO.SDS.AppWindow",
182
192
  constructor: function (config) {
183
- this.callParent([Ext.apply({
193
+ this.callParent([${ns}.fitDesktop(Ext.apply({
184
194
  width: 960, height: 640, minWidth: 700, minHeight: 420, layout: "fit",
185
195
  ${root ? root.html : `html: '<div class="dsmapp-root" style="height:100%"></div>'`}
186
- }, config)]);
196
+ }, config))]);
197
+ },
198
+ getRestoreSizePos: function (config) {
199
+ return ${ns}.fitDesktop(Ext.apply({ width: config.width, height: config.height }, this.callParent(arguments)));
187
200
  },
188
201
  onOpen: function () {
189
202
  this.callParent(arguments);
@@ -267,7 +280,7 @@ ${PORTAL_RUNTIME}
267
280
  function renderPortalAccess(ns, manifest) {
268
281
  return `${ns}.createPortalAccess({
269
282
  appId: ${JSON.stringify(`${ns}.Instance`)},
270
- defaultAlias: ${JSON.stringify(manifest.portal.alias)},
283
+ defaultAlias: ${JSON.stringify(manifest.portal.alias)},${portalByExecutor(manifest.os_min_ver) ? "\n entry: true," : ""}
271
284
  isAdmin: function () { return ${ns}.sessionUser().isAdmin; },
272
285
  send: ${ns}.webApi(me),
273
286
  call: ${ns}.call,
@@ -1,7 +1,7 @@
1
1
  import { type ExecutorInspection } from "../runtime/executor-inspect.js";
2
2
  import type { EntryMode } from "./manifest.js";
3
3
  import { type UninstallCleanupItem } from "../runtime/uninstall-cleanup.js";
4
- export type SpkKind = "native-go" | "binary" | "container" | "native-layout";
4
+ export type SpkKind = "native-go" | "native-rust" | "binary" | "container" | "native-layout";
5
5
  export interface SpkInspection {
6
6
  kind?: SpkKind;
7
7
  package?: string;
@@ -6,10 +6,13 @@ import { tmpdir } from "node:os";
6
6
  import { join } from "node:path";
7
7
  import * as tar from "tar";
8
8
  import { familyOfArchToken, NATIVE_ARCH_FAMILIES } from "../build-spk/dsm-arch.js";
9
- import { CONTAINER_MIN_DSM, isDsmAtLeast, PORTAL_MIN_DSM } from "../build-spk/dsm-version.js";
9
+ import { CONTAINER_MIN_DSM, isDsmAtLeast, PORTAL_EXECUTOR_MIN_DSM, PORTAL_MIN_DSM, PORTAL_MIN_DSM_REASON } from "../build-spk/dsm-version.js";
10
10
  import { ELF_MACHINE } from "./binary.js";
11
11
  import { CGI_PAYLOAD_PATH, entryLauncherAccess } from "./dsm-window.js";
12
12
  import { daemonConfig, readDaemon } from "./daemon.js";
13
+ import { ROOT_SCRIPT_HINTS } from "./root-ops.js";
14
+ import { DELETE_STATE_KEY } from "../build-spk/uninstall-wizard.js";
15
+ import { readUninstallRecord, stateItemTexts } from "./uninstall.js";
13
16
  import { BACKGROUND_UNIT_DIR, BACKGROUND_WORKER_PAYLOAD, backgroundUnitName, renderBackgroundUnit } from "./background.js";
14
17
  import { renderStandaloneResource } from "./resource.js";
15
18
  import { recoverSharedFolder, renderSharedFolderUpgradeWizardFiles, sharedFolderInstallSteps, SHARED_FOLDER_MARKER } from "./shared-folder.js";
@@ -23,7 +26,6 @@ import { PORTAL_JSON_RELPATH, PORTAL_MUSTACHE_RELPATH, renderPortalJson, renderP
23
26
  import { isPortalAlias } from "../portal/alias.js";
24
27
  import { defaultPackageIcons, MAX_ICON_BYTES } from "../build-spk/dsm-icons.js";
25
28
  import { decodePng } from "../build-spk/png.js";
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/];
27
29
  /** Reads an SPK without installing it and reports how it would run on DSM. */
28
30
  export async function inspectStandaloneSpk(spkPath) {
29
31
  return inspectSpk(spkPath, "native-go");
@@ -55,7 +57,7 @@ export async function inspectSpk(spkPath, expectedKind) {
55
57
  if (metadataText !== undefined) {
56
58
  try {
57
59
  const raw = JSON.parse(metadataText);
58
- if (raw.schema !== 2 || !["go", "binary"].includes(raw.backend) || !Array.isArray(raw.capabilities) || raw.capabilities.some((name) => !["files", "jobs", "identity", "rootOps", "delegate"].includes(String(name))) || new Set(raw.capabilities).size !== raw.capabilities.length || (raw.entry !== undefined && !["admin-only", "app-privilege"].includes(raw.entry)))
60
+ if (raw.schema !== 2 || !["go", "rust", "binary"].includes(raw.backend) || !Array.isArray(raw.capabilities) || raw.capabilities.some((name) => !["files", "jobs", "identity", "rootOps", "delegate"].includes(String(name))) || new Set(raw.capabilities).size !== raw.capabilities.length || (raw.entry !== undefined && !["admin-only", "app-privilege"].includes(raw.entry)))
59
61
  throw new Error();
60
62
  metadata = raw;
61
63
  }
@@ -63,17 +65,23 @@ export async function inspectSpk(spkPath, expectedKind) {
63
65
  findings.push("sdk-package.json 不符合 schema 2 capability inventory。");
64
66
  }
65
67
  }
68
+ const uninstallHook = metadata.uninstall === undefined ? undefined : readUninstallRecord(metadata.uninstall, metadata.capabilities?.includes("rootOps") === true);
69
+ if (metadata.uninstall !== undefined && !uninstallHook)
70
+ findings.push("UNINSTALL_RECORD_INVALID sdk-package.json 的 uninstall 不是 synopkgland build 寫出的 uninstall 設定。");
66
71
  const binaryBackend = metadata.backend === "binary";
67
- const kind = expectedKind ?? (paths.has("compose.yaml") ? "container" : binaryBackend ? "binary" : "native-go");
68
- const standalone = kind === "native-go" || kind === "binary";
72
+ // A strict standalone inspection names the language the CGI was built from.
73
+ const compiled = metadata.backend === "rust" ? "native-rust" : "native-go";
74
+ const kind = (expectedKind === "native-go" ? compiled : expectedKind) ?? (paths.has("compose.yaml") ? "container" : binaryBackend ? "binary" : compiled);
75
+ const compiledKind = kind === "native-go" || kind === "native-rust";
76
+ const standalone = compiledKind || kind === "binary";
69
77
  const info = readInfo(join(dir, "INFO"), findings, kind);
70
78
  await checkArch(dir, info, kind, paths.has(CGI_PAYLOAD_PATH), findings);
71
- const background = kind === "native-go" && paths.has(BACKGROUND_WORKER_PAYLOAD);
79
+ const background = compiledKind && paths.has(BACKGROUND_WORKER_PAYLOAD);
72
80
  const sharedFolder = standalone ? recoverSharedFolder(readOptional(join(dir, "scripts/postinst"))) : undefined;
73
81
  // A Compose App has no executor, so its SPK never carries a portal.
74
82
  const portal = standalone ? await recoverPortal(dir, info, `SYNO.SDS.${info.package}.Instance`, paths, payload, findings) : undefined;
75
83
  if (portal && info.os_min_ver && OS_MIN_VER.test(info.os_min_ver) && !isDsmAtLeast(info.os_min_ver, PORTAL_MIN_DSM)) {
76
- findings.push(`INFO os_min_ver「${info.os_min_ver}」低於 portal 需要的 DSM ${PORTAL_MIN_DSM};DSM 7.1.1 以錯誤 320 拒裝帶 web-config app-portal 的未簽章套件,7.2 未驗證`);
84
+ findings.push(`INFO os_min_ver「${info.os_min_ver}」低於 portal 需要的 DSM ${PORTAL_MIN_DSM};${PORTAL_MIN_DSM_REASON}`);
77
85
  }
78
86
  checkConf(dir, findings, kind, background, sharedFolder, info.package, portal);
79
87
  checkScripts(dir, findings);
@@ -96,18 +104,23 @@ export async function inspectSpk(spkPath, expectedKind) {
96
104
  executor = inspected.executor;
97
105
  if (executor)
98
106
  findings.push(...inspectExecutorAnchor(readFileSync(join(dir, "INFO"), "utf8"), executor.executorDigest));
107
+ if (executor?.policy.portal && info.os_min_ver && OS_MIN_VER.test(info.os_min_ver) && !isDsmAtLeast(info.os_min_ver, PORTAL_EXECUTOR_MIN_DSM)) {
108
+ findings.push(`INFO os_min_ver「${info.os_min_ver}」低於 executor 註冊 portal 需要的 DSM ${PORTAL_EXECUTOR_MIN_DSM}`);
109
+ }
110
+ if (executor && executor.policy.purge !== uninstallHook?.uninstall.purge.root)
111
+ findings.push(`UNINSTALL_ROOT_POLICY_MISMATCH executor policy 的 purge「${executor.policy.purge ?? ""}」與 uninstall.purge.root「${uninstallHook?.uninstall.purge.root ?? ""}」不同。`);
99
112
  if (payload.find(p => p.path === "executor/executor")?.mode !== "0755")
100
113
  findings.push("Executor must be executable.");
101
114
  if (payload.find(p => p.path === "executor/lifecycle")?.mode !== "0755")
102
115
  findings.push("Executor lifecycle helper must be executable.");
103
116
  if (![...paths].some(path => path.startsWith("daemon/"))) {
104
- const lifecycleManifest = { package: info.package, capabilities: { rootOps: true, ...(background ? { jobs: { worker: "sha256-v1" } } : {}) } };
117
+ const lifecycleManifest = { package: info.package, capabilities: { rootOps: true, ...(background ? { jobs: { worker: "sha256-v1" } } : {}) }, ...(uninstallHook ? { uninstall: uninstallHook.uninstall } : {}) };
105
118
  for (const hook of ["preuninst", "preupgrade", "start-stop-status"]) {
106
119
  if (readOptional(join(dir, "scripts", hook)) !== renderStandaloneScript(hook, lifecycleManifest))
107
120
  findings.push(`Executor scripts/${hook} differs from fixed lifecycle.`);
108
121
  }
109
122
  }
110
- for (const [name, text] of Object.entries(renderUninstallWizardFiles(info.package, "native", { executor: true, portal: Boolean(portal) }))) {
123
+ for (const [name, text] of Object.entries(renderUninstallWizardFiles(info.package, "native", { executor: true, portal: Boolean(portal), portalEntry: Boolean(executor?.policy.portal), ...uninstallWizardOptions(uninstallHook) }))) {
111
124
  if (readOptional(join(dir, "WIZARD_UIFILES", name)) !== text)
112
125
  findings.push(`Executor WIZARD_UIFILES/${name} is missing cleanup guidance.`);
113
126
  }
@@ -132,8 +145,9 @@ export async function inspectSpk(spkPath, expectedKind) {
132
145
  icon,
133
146
  ...(executor ? { executor } : {}),
134
147
  ...(portal ? { portal: { appId: portal.appId, alias: portal.alias } } : {}),
148
+ ...(executor?.policy.portal ? { portal: { appId: executor.policy.app, alias: executor.policy.portal.alias } } : {}),
135
149
  ...(metadata.entry ? { entry: metadata.entry } : {}),
136
- ...(standalone ? { uninstallCleanup: uninstallCleanup(info.package, { executor: hasExecutor, portal: Boolean(portal) }) } : {}),
150
+ ...(standalone ? { uninstallCleanup: uninstallCleanup(info.package, { executor: hasExecutor, portal: Boolean(portal), portalEntry: Boolean(executor?.policy.portal) }) } : {}),
137
151
  package: info.package,
138
152
  version: info.version,
139
153
  arch: info.arch,
@@ -467,10 +481,10 @@ function checkScripts(dir, findings) {
467
481
  const DATA_KEYS = ["pkgwizard_remove_additional", "delete_app_data"];
468
482
  const DELETE_COMMAND = /\brm\s+-[a-zA-Z]*[rR]|\bfind\b[^\n]*(-delete|-exec\s+rm)/;
469
483
  const UPGRADE_GUARD = /SYNOPKG_PKG_STATUS[^\n]*!=\s*"?UPGRADE"?/;
470
- function uninstallBoxes(file) {
484
+ function uninstallBoxes(file, keys) {
471
485
  try {
472
486
  const steps = JSON.parse(readFileSync(file, "utf8"));
473
- return steps.flatMap(step => step.items ?? []).flatMap(item => item.subitems ?? []).filter(box => DATA_KEYS.includes(box.key));
487
+ return steps.flatMap(step => step.items ?? []).flatMap(item => item.subitems ?? []).filter(box => keys.includes(box.key));
474
488
  }
475
489
  catch {
476
490
  return [];
@@ -482,6 +496,13 @@ function uninstallBoxes(file) {
482
496
  * package's own `/volumeN/@app*` directories; the wizard and the script must
483
497
  * agree on the box that asks for it.
484
498
  */
499
+ /** The wizard texts of the App's uninstall hook, as build rendered them. */
500
+ function uninstallWizardOptions(hook) {
501
+ if (!hook)
502
+ return {};
503
+ const { uninstall, catalogs } = hook;
504
+ return { rootPurge: Boolean(uninstall.purge.root), ...(uninstall.state ? { state: { items: (locale) => stateItemTexts(uninstall, catalogs, locale) } } : {}) };
505
+ }
485
506
  function checkUninstall(dir, findings) {
486
507
  const script = join(dir, "scripts", "postuninst");
487
508
  if (!existsSync(script))
@@ -489,7 +510,8 @@ function checkUninstall(dir, findings) {
489
510
  const body = readFileSync(script, "utf8");
490
511
  const wizardDir = join(dir, "WIZARD_UIFILES");
491
512
  const wizards = existsSync(wizardDir) ? readdirSync(wizardDir).filter(name => name === "uninstall_uifile" || name.startsWith("uninstall_uifile_")).sort() : [];
492
- const boxes = wizards.flatMap(name => uninstallBoxes(join(wizardDir, name)).map(box => ({ ...box, name })));
513
+ const boxes = wizards.flatMap(name => uninstallBoxes(join(wizardDir, name), DATA_KEYS).map(box => ({ ...box, name })));
514
+ checkStateBoxes(dir, wizards.flatMap(name => uninstallBoxes(join(wizardDir, name), [DELETE_STATE_KEY]).map(box => ({ ...box, name }))), findings);
493
515
  const deletes = DELETE_COMMAND.test(body);
494
516
  if (deletes) {
495
517
  if (!UPGRADE_GUARD.test(body))
@@ -510,6 +532,17 @@ function checkUninstall(dir, findings) {
510
532
  findings.push(`UNINSTALL_WIZARD_NOT_HONOURED WIZARD_UIFILES/${box.name} 提供 ${box.key} 勾選框,但 scripts/postuninst 沒有讀它`);
511
533
  }
512
534
  }
535
+ /** The App's delete-state box keeps the state by default, and preuninst hands its answer to the App hook. */
536
+ function checkStateBoxes(dir, boxes, findings) {
537
+ const script = join(dir, "scripts", "preuninst");
538
+ const body = existsSync(script) ? readFileSync(script, "utf8") : "";
539
+ for (const box of boxes) {
540
+ if (box.defaultValue !== false)
541
+ findings.push(`UNINSTALL_STATE_DEFAULT_CHECKED WIZARD_UIFILES/${box.name} 的 ${box.key} 預設必須不勾(保留 App 狀態)`);
542
+ if (!body.includes(box.key))
543
+ findings.push(`UNINSTALL_STATE_NOT_HONOURED WIZARD_UIFILES/${box.name} 提供 ${box.key} 勾選框,但 scripts/preuninst 沒有讀它`);
544
+ }
545
+ }
513
546
  export async function listPayload(packageTgz, findings) {
514
547
  if (!existsSync(packageTgz)) {
515
548
  findings.push("缺少 package.tgz");
@@ -4,6 +4,8 @@ import { renderSharedFolderScript } from "./shared-folder.js";
4
4
  import { renderDelegateInstallDraft } from '../build-spk/delegate-scope.js';
5
5
  import { renderPortalLifecycle } from "../portal/package.js";
6
6
  import { nativePortal } from "./portal.js";
7
+ import { renderUninstallHook } from "./uninstall-hook.js";
8
+ const UNINSTALL_ONLY = '[ "${SYNOPKG_PKG_STATUS:-}" = UPGRADE ] || synopkgland_uninstall_hook\n';
7
9
  /** Package-account lifecycle hooks for CGI, daemon and background packages. */
8
10
  export function renderStandaloneScript(name, manifest) {
9
11
  const portal = nativePortal(manifest);
@@ -43,12 +45,16 @@ function renderStandaloneScriptBody(name, manifest) {
43
45
  const stopDaemon = manifest.daemon ? `"/var/packages/${manifest.package}/target/daemon/control" stop "\${SYNOPKG_PKGDEST:-/var/packages/${manifest.package}/target}" "\${SYNOPKG_PKGVAR:-/var/packages/${manifest.package}/var}" || exit $?\n` : "";
44
46
  return normal.replace("#!/bin/sh\n", `#!/bin/sh\n${workerStop}if [ "$1" = start ]; then\n${request("start")}fi\nif [ "$1" = stop ]; then\n${stopWorker}${stopDaemon}${request("stop")}${workerResult}fi\n`);
45
47
  }
46
- const action = name === "preuninst" ? '"$action"' : "stop";
47
- const choose = name === "preuninst" ? 'action=purge\n[ "${SYNOPKG_PKG_STATUS:-}" != UPGRADE ] || action=stop\n' : "";
48
- return `#!/bin/sh\n${workerStop}${stopWorker}${choose}${request(action)}${manifest.daemon ? renderDaemonScript(name, manifest).replace("#!/bin/sh\n", "") : workerResult}`;
48
+ if (name === "preuninst")
49
+ return renderExecutorPreuninst(manifest, workerStop, stopWorker, workerResult, request);
50
+ return `#!/bin/sh\n${workerStop}${stopWorker}${request("stop")}${manifest.daemon ? renderDaemonScript(name, manifest).replace("#!/bin/sh\n", "") : workerResult}`;
49
51
  }
50
52
  if (name === "postuninst")
51
53
  return renderPackagePostuninst(manifest.package);
54
+ if (name === "preuninst" && manifest.uninstall) {
55
+ const body = manifest.daemon ? renderDaemonPreuninst(manifest) : `${UNINSTALL_ONLY}exit 0\n`;
56
+ return `#!/bin/sh\n${renderUninstallHook(manifest)}${body}`;
57
+ }
52
58
  if ((manifest.capabilities?.rootOps || manifest.capabilities?.delegate) && ["postinst", "postupgrade"].includes(name)) {
53
59
  const normal = renderCapabilityScript(name, { ...manifest, capabilities: { ...manifest.capabilities, rootOps: undefined, delegate: undefined } });
54
60
  return normal.replace("#!/bin/sh\n", `#!/bin/sh\n"\${SYNOPKG_PKGDEST:-/var/packages/${manifest.package}/target}/executor/lifecycle" start --package ${manifest.package} || exit $?\n`);
@@ -84,7 +90,11 @@ function renderDelegatedDaemonScript(name, manifest) {
84
90
  if (name !== 'start-stop-status') {
85
91
  if (name === 'preupgrade')
86
92
  return `#!/bin/sh\n${workerStop}${stop('stop', 'stop')}`;
87
- return `#!/bin/sh\n${workerStop}if [ "\${SYNOPKG_PKG_STATUS:-}" = UPGRADE ]; then\n${stop('stop', 'stop')}fi\n${stop(manifest.daemon?.uninstall_args ? 'uninstall' : 'stop', 'purge')}`;
93
+ const purge = manifest.uninstall
94
+ ? stop(manifest.daemon?.uninstall_args ? 'uninstall' : 'stop', 'purge $synopkgland_purge_args')
95
+ .replace(executor('purge $synopkgland_purge_args'), () => `synopkgland_uninstall_hook\n${executor('purge $synopkgland_purge_args')}`)
96
+ : stop(manifest.daemon?.uninstall_args ? 'uninstall' : 'stop', 'purge');
97
+ return `#!/bin/sh\n${workerStop}${renderUninstallHook(manifest)}if [ "\${SYNOPKG_PKG_STATUS:-}" = UPGRADE ]; then\n${stop('stop', 'stop')}fi\n${purge}`;
88
98
  }
89
99
  return `#!/bin/sh
90
100
  ${workerStop}${renderUserManagerRecovery()}${renderBackgroundWorkerStart(pkg)}case "$1" in
@@ -143,3 +153,27 @@ case "$1" in
143
153
  esac
144
154
  `;
145
155
  }
156
+ /**
157
+ * A real uninstall stops the daemon, runs the App hook, then asks the
158
+ * executor to purge; an upgrade only stops both.
159
+ */
160
+ function renderExecutorPreuninst(manifest, workerStop, stopWorker, workerResult, request) {
161
+ const pkg = manifest.package;
162
+ const choose = 'action=purge\n[ "${SYNOPKG_PKG_STATUS:-}" != UPGRADE ] || action=stop\n';
163
+ const hook = manifest.uninstall ? '[ "$action" = stop ] || synopkgland_uninstall_hook\n' : "";
164
+ const purge = request(manifest.uninstall ? '"$action" $synopkgland_purge_args' : '"$action"');
165
+ if (!manifest.daemon)
166
+ return `#!/bin/sh\n${workerStop}${renderUninstallHook(manifest)}${stopWorker}${choose}${hook}${purge}${workerResult}`;
167
+ const control = `"/var/packages/${pkg}/target/daemon/control"`;
168
+ const paths = `"\${SYNOPKG_PKGDEST:-/var/packages/${pkg}/target}" "\${SYNOPKG_PKGVAR:-/var/packages/${pkg}/var}"`;
169
+ const daemonAction = manifest.daemon.uninstall_args ? 'daemon_action=stop\n[ "$action" = stop ] || daemon_action=uninstall\n' : "daemon_action=stop\n";
170
+ return `#!/bin/sh\n${workerStop}${renderUninstallHook(manifest)}${stopWorker}${choose}${daemonAction}daemon_result=0\n${control} "$daemon_action" ${paths} || daemon_result=$?\n${hook}${purge}exit "$daemon_result"\n`;
171
+ }
172
+ /** The daemon stops (or uninstalls) first; the App hook follows on a real uninstall. */
173
+ function renderDaemonPreuninst(manifest) {
174
+ const pkg = manifest.package;
175
+ const control = `"/var/packages/${pkg}/target/daemon/control"`;
176
+ const paths = `"\${SYNOPKG_PKGDEST:-/var/packages/${pkg}/target}" "\${SYNOPKG_PKGVAR:-/var/packages/${pkg}/var}"`;
177
+ return `if [ "\${SYNOPKG_PKG_STATUS:-}" = UPGRADE ]; then\n exec ${control} stop ${paths}\nfi\n`
178
+ + `${control} ${manifest.daemon?.uninstall_args ? "uninstall" : "stop"} ${paths}\ndaemon_result=$?\nsynopkgland_uninstall_hook\nexit "$daemon_result"\n`;
179
+ }
@@ -6,10 +6,12 @@ import { type Catalogs, type InfoTranslations } from "./i18n.js";
6
6
  export type { InfoText } from "./i18n.js";
7
7
  import { type RootOp } from "./root-ops.js";
8
8
  import { type Daemon } from "./daemon.js";
9
+ import { type Uninstall } from "./uninstall.js";
9
10
  import { type Background } from "./background.js";
10
11
  import { type SharedFolder } from "./shared-folder.js";
11
12
  import { type ApiPermissions } from "./api-permissions.js";
12
13
  import { type PortalDeclaration } from "../portal/alias.js";
14
+ import { type SdkBackend } from "../runtime/sdk-source.js";
13
15
  /**
14
16
  * `dsm-package.yaml`: a Standalone Package, i.e. an unsigned native SPK that
15
17
  * runs as its own DSM package account with a CGI backend and a DSM window UI.
@@ -19,6 +21,12 @@ export type StandaloneBackend = {
19
21
  kind: "go";
20
22
  dir: string;
21
23
  main: string;
24
+ } | {
25
+ kind: "rust";
26
+ dir: string;
27
+ package: string;
28
+ bin: string;
29
+ worker: string;
22
30
  } | {
23
31
  kind: "binary";
24
32
  files: Partial<Record<NativeArchFamily, string>>;
@@ -63,6 +71,8 @@ export interface StandaloneManifest {
63
71
  tasks: ExecutorTask[];
64
72
  };
65
73
  daemon?: Daemon;
74
+ /** `uninstall`: the App's own purge hook and the state the wizard may delete. */
75
+ uninstall?: Uninstall;
66
76
  /** `install.sharedFolder`: the Shared Folder the install wizard selects or `data-share` creates. */
67
77
  sharedFolder?: SharedFolder;
68
78
  /** Who may use the app at all; absent keeps the route policy alone. */
@@ -97,6 +107,8 @@ export type ManifestResult = {
97
107
  };
98
108
  export declare const KNOWN_FIELDS: Set<string>;
99
109
  export declare function loadStandaloneManifest(root: string): ManifestResult;
110
+ /** Where an App's SDK version is declared: its Go or Rust backend; a prebuilt binary declares it in the project's go.mod. */
111
+ export declare function sdkBackend(m: Pick<StandaloneManifest, "backend" | "root">): SdkBackend;
100
112
  /**
101
113
  * The nearest directory at or above `start` that holds dsm-package.yaml,
102
114
  * unless a Managed Profile project (synology-app.yaml) is found first.