@synopackageland/cli 0.9.3 → 0.9.5

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 (35) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +1 -1
  3. package/dist/add.js +3 -3
  4. package/dist/build-spk/dsm-version.d.ts +14 -4
  5. package/dist/build-spk/dsm-version.js +17 -5
  6. package/dist/build-spk/uninstall-wizard.d.ts +2 -0
  7. package/dist/build-spk/uninstall-wizard.js +7 -1
  8. package/dist/build-spk/wizard-messages.d.ts +11 -0
  9. package/dist/build-spk/wizard-messages.js +11 -0
  10. package/dist/error-registry.js +2 -1
  11. package/dist/info.js +2 -1
  12. package/dist/portal/alias.d.ts +8 -0
  13. package/dist/portal/alias.js +16 -0
  14. package/dist/runtime/executor-digests.json +22 -0
  15. package/dist/runtime/executor-policy.d.ts +8 -0
  16. package/dist/runtime/executor-policy.js +29 -5
  17. package/dist/runtime/uninstall-cleanup.d.ts +5 -1
  18. package/dist/runtime/uninstall-cleanup.js +11 -1
  19. package/dist/standalone/build.js +3 -3
  20. package/dist/standalone/dsm-window.js +16 -3
  21. package/dist/standalone/inspect-spk.js +8 -4
  22. package/dist/standalone/manifest.js +7 -3
  23. package/dist/standalone/native-layout.d.ts +3 -0
  24. package/dist/standalone/native-layout.js +37 -5
  25. package/dist/standalone/native-root-window.d.ts +2 -1
  26. package/dist/standalone/native-root-window.js +15 -5
  27. package/dist/standalone/portal-window.d.ts +4 -2
  28. package/dist/standalone/portal-window.js +54 -12
  29. package/dist/standalone/portal.d.ts +4 -1
  30. package/dist/standalone/portal.js +8 -2
  31. package/dist/standalone/spk.js +2 -1
  32. package/package.json +3 -3
  33. package/skills/reference/portal.md +8 -4
  34. package/skills/reference/ui-json.md +5 -1
  35. package/skills/reference/ui-vue.md +7 -0
@@ -6,7 +6,7 @@ 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";
@@ -73,7 +73,7 @@ export async function inspectSpk(spkPath, expectedKind) {
73
73
  // A Compose App has no executor, so its SPK never carries a portal.
74
74
  const portal = standalone ? await recoverPortal(dir, info, `SYNO.SDS.${info.package}.Instance`, paths, payload, findings) : undefined;
75
75
  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 未驗證`);
76
+ findings.push(`INFO os_min_ver「${info.os_min_ver}」低於 portal 需要的 DSM ${PORTAL_MIN_DSM};${PORTAL_MIN_DSM_REASON}`);
77
77
  }
78
78
  checkConf(dir, findings, kind, background, sharedFolder, info.package, portal);
79
79
  checkScripts(dir, findings);
@@ -96,6 +96,9 @@ export async function inspectSpk(spkPath, expectedKind) {
96
96
  executor = inspected.executor;
97
97
  if (executor)
98
98
  findings.push(...inspectExecutorAnchor(readFileSync(join(dir, "INFO"), "utf8"), executor.executorDigest));
99
+ 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)) {
100
+ findings.push(`INFO os_min_ver「${info.os_min_ver}」低於 executor 註冊 portal 需要的 DSM ${PORTAL_EXECUTOR_MIN_DSM}`);
101
+ }
99
102
  if (payload.find(p => p.path === "executor/executor")?.mode !== "0755")
100
103
  findings.push("Executor must be executable.");
101
104
  if (payload.find(p => p.path === "executor/lifecycle")?.mode !== "0755")
@@ -107,7 +110,7 @@ export async function inspectSpk(spkPath, expectedKind) {
107
110
  findings.push(`Executor scripts/${hook} differs from fixed lifecycle.`);
108
111
  }
109
112
  }
110
- for (const [name, text] of Object.entries(renderUninstallWizardFiles(info.package, "native", { executor: true, portal: Boolean(portal) }))) {
113
+ for (const [name, text] of Object.entries(renderUninstallWizardFiles(info.package, "native", { executor: true, portal: Boolean(portal), portalEntry: Boolean(executor?.policy.portal) }))) {
111
114
  if (readOptional(join(dir, "WIZARD_UIFILES", name)) !== text)
112
115
  findings.push(`Executor WIZARD_UIFILES/${name} is missing cleanup guidance.`);
113
116
  }
@@ -132,8 +135,9 @@ export async function inspectSpk(spkPath, expectedKind) {
132
135
  icon,
133
136
  ...(executor ? { executor } : {}),
134
137
  ...(portal ? { portal: { appId: portal.appId, alias: portal.alias } } : {}),
138
+ ...(executor?.policy.portal ? { portal: { appId: executor.policy.app, alias: executor.policy.portal.alias } } : {}),
135
139
  ...(metadata.entry ? { entry: metadata.entry } : {}),
136
- ...(standalone ? { uninstallCleanup: uninstallCleanup(info.package, { executor: hasExecutor, portal: Boolean(portal) }) } : {}),
140
+ ...(standalone ? { uninstallCleanup: uninstallCleanup(info.package, { executor: hasExecutor, portal: Boolean(portal), portalEntry: Boolean(executor?.policy.portal) }) } : {}),
137
141
  package: info.package,
138
142
  version: info.version,
139
143
  arch: info.arch,
@@ -8,10 +8,10 @@ import { checkGlossary, loadCatalogs, readInfoTranslations } from "./i18n.js";
8
8
  import { loadRootOps, ROOT_OPS_DIR } from "./root-ops.js";
9
9
  import { readDaemon } from "./daemon.js";
10
10
  import { readBackground } from "./background.js";
11
- import { DEFAULT_MIN_DSM, isDsmAtLeast, PORTAL_MIN_DSM, portalMinDsmMessage } from "../build-spk/dsm-version.js";
11
+ import { DEFAULT_MIN_DSM, isDsmAtLeast, PORTAL_EXECUTOR_MIN_DSM, portalByExecutor, portalMinDsmMessage } from "../build-spk/dsm-version.js";
12
12
  import { readSharedFolder } from "./shared-folder.js";
13
13
  import { floorAtAdministrators, permitsOrdinaryUsers, readApiPermissions } from "./api-permissions.js";
14
- import { PORTAL_REQUIRES_EXECUTOR, readPortalDeclaration } from "../portal/alias.js";
14
+ import { PORTAL_REQUIRES_EXECUTOR, portalEntryProblem, readPortalDeclaration } from "../portal/alias.js";
15
15
  import { renderPackageIcons } from "../build-spk/dsm-icons.js";
16
16
  import { validateReproducible } from "../reproducible-config.js";
17
17
  import { SDK_MODULE, sdkRequirement } from "../runtime/sdk-source.js";
@@ -180,9 +180,13 @@ function readManifest(raw, c) {
180
180
  if (portal && !capabilities.rootOps && !capabilities.delegate) {
181
181
  c.fail("STANDALONE_PORTAL_REQUIRES_EXECUTOR", PORTAL_REQUIRES_EXECUTOR);
182
182
  }
183
- if (portal && osMinVer && !isDsmAtLeast(osMinVer, PORTAL_MIN_DSM)) {
183
+ if (portal && osMinVer && !isDsmAtLeast(osMinVer, PORTAL_EXECUTOR_MIN_DSM)) {
184
184
  c.fail("STANDALONE_PORTAL_MIN_DSM", portalMinDsmMessage(osMinVer, "portal"));
185
185
  }
186
+ const entryProblem = portal && packageId && displayname && osMinVer && portalByExecutor(osMinVer)
187
+ ? portalEntryProblem(packageId, { application: displayname, alias: portal.alias }) : undefined;
188
+ if (entryProblem)
189
+ c.fail("STANDALONE_PORTAL_ENTRY_INVALID", entryProblem);
186
190
  if (!packageId || !version || !build || !osMinVer || !displayname || !description || !maintainer
187
191
  || !arches || !backend || !ui || (capabilities.rootOps === true && !rootOps)) {
188
192
  return undefined;
@@ -1,5 +1,6 @@
1
1
  import { type PackageMetadata } from "../package-metadata.js";
2
2
  import type { CommandRunner } from './build.js';
3
+ import { type PortalDeclaration } from '../portal/alias.js';
3
4
  export declare const LAYOUT_ARCHES: {
4
5
  readonly x86_64: readonly [2, 62];
5
6
  readonly aarch64: readonly [2, 183];
@@ -50,6 +51,8 @@ export interface NativeLayout {
50
51
  hook: Hook;
51
52
  toolkits: Partial<Record<LayoutArch, Toolkit[]>>;
52
53
  };
54
+ /** The product's own root helper registers this Application Portal entry; the CLI only names it in INFO. */
55
+ portal?: PortalDeclaration;
53
56
  }
54
57
  export declare function isNativeLayout(root: string): boolean;
55
58
  export declare function loadNativeLayout(root: string): {
@@ -9,7 +9,8 @@ import { createReproducibleTar } from '../build-spk/reproducible-archive.js';
9
9
  import { runCommand, runnerFor } from './build.js';
10
10
  import { checkJobs, commitOutputs, forEachArch } from './arch-jobs.js';
11
11
  import { validateReproducible } from '../reproducible-config.js';
12
- import { resolveMinDsm } from '../build-spk/dsm-version.js';
12
+ import { isDsmAtLeast, PORTAL_EXECUTOR_MIN_DSM, resolveMinDsm } from '../build-spk/dsm-version.js';
13
+ import { portalEntryProblem, readPortalDeclaration } from '../portal/alias.js';
13
14
  import { infoArchValue } from '../build-spk/dsm-arch.js';
14
15
  import { LIFECYCLE_SCRIPTS, REPLACE_SCRIPTS } from '../spk/dsm-spec.js';
15
16
  import { renderPackageIcons, writePackageCenterIcons } from '../build-spk/dsm-icons.js';
@@ -58,6 +59,19 @@ function anchors(value) {
58
59
  }
59
60
  return a;
60
61
  }
62
+ // Like the anchors, INFO is the root helper's trusted copy of the entry it
63
+ // registers in DSM's app.d: the alias, under the INFO displayname.
64
+ const PORTAL_INFO_KEY = 'synopkgland_portal_alias';
65
+ function portal(value, packageId) {
66
+ return readPortalDeclaration(value, packageId) ?? fail('portal must be {} or {alias: <lowercase letters, digits and -, 1-64, not a DSM path>}');
67
+ }
68
+ function checkPortal(p, packageId, displayname, osMinVer) {
69
+ if (!isDsmAtLeast(osMinVer, PORTAL_EXECUTOR_MIN_DSM))
70
+ fail(`portal needs DSM ${PORTAL_EXECUTOR_MIN_DSM} or later, where the app.d entry format is verified; os_min_ver is ${osMinVer}`);
71
+ const problem = portalEntryProblem(packageId, { application: displayname, alias: p.alias });
72
+ if (problem)
73
+ fail(problem);
74
+ }
61
75
  function hook(value) {
62
76
  const h = object(value, ['command', 'cwd', 'env']);
63
77
  if (!Array.isArray(h.command) || !h.command.length || !h.command.every((x) => typeof x === 'string' && x.length > 0 && !x.includes('\0')))
@@ -90,7 +104,7 @@ export function isNativeLayout(root) {
90
104
  }
91
105
  export function loadNativeLayout(root) {
92
106
  try {
93
- const m = object(parse(readFileSync(join(root, 'dsm-package.yaml'), 'utf8')), ['schema', 'backend', 'package', 'version', 'build', 'displayname', 'description', 'maintainer', 'metadata', 'os_min_ver', 'privilege', 'arches', 'layout', 'native', 'icon', 'reproducible']);
107
+ const m = object(parse(readFileSync(join(root, 'dsm-package.yaml'), 'utf8')), ['schema', 'backend', 'package', 'version', 'build', 'displayname', 'description', 'maintainer', 'metadata', 'os_min_ver', 'privilege', 'arches', 'layout', 'native', 'icon', 'reproducible', 'portal']);
94
108
  if (m.schema !== 2)
95
109
  fail('schema 2 required; migrate schema: 1 to schema: 2 and add backend: {kind: native}');
96
110
  const backend = object(m.backend, ['kind']);
@@ -113,6 +127,10 @@ export function loadNativeLayout(root) {
113
127
  m.icon = icon(root, m.icon);
114
128
  if (m.reproducible !== undefined)
115
129
  validateReproducible(m.reproducible);
130
+ if (m.portal !== undefined) {
131
+ m.portal = portal(m.portal, m.package);
132
+ checkPortal(m.portal, m.package, m.displayname, m.os_min_ver);
133
+ }
116
134
  const layout = object(m.layout, ['payload', 'metadata', 'binaries', 'anchors']);
117
135
  layout.payload = pathText(layout.payload);
118
136
  layout.metadata = pathText(layout.metadata);
@@ -363,6 +381,8 @@ export async function buildNativeLayout(options) {
363
381
  fail('invalid build override');
364
382
  m.build = options.build;
365
383
  }
384
+ if (m.portal)
385
+ checkPortal(m.portal, m.package, m.displayname, m.os_min_ver);
366
386
  const arches = options.arches ?? m.arches;
367
387
  if (!arches.length || new Set(arches).size !== arches.length || arches.some(a => !m.arches.includes(a)))
368
388
  fail('architecture not declared');
@@ -402,11 +422,11 @@ export async function buildNativeLayout(options) {
402
422
  await createReproducibleTar(target, join(stage, 'package.tgz'), { gzip: true });
403
423
  rmSync(target, { recursive: true });
404
424
  const fields = { package: m.package, version, arch: layoutInfoArch(arch), os_min_ver: m.os_min_ver, displayname: m.displayname, description: m.description, maintainer: m.maintainer, dsmuidir: 'ui', dsmappname: `SYNO.SDS.${m.package}.Instance`, startable: 'yes', thirdparty: 'yes', checksum: createHash('md5').update(readFileSync(join(stage, 'package.tgz'))).digest('hex') };
405
- const info = [...Object.entries(fields), ...metadataInfoFields(m.metadata), ...anchorFields(m.layout.anchors ?? {}, files)];
425
+ const info = [...Object.entries(fields), ...metadataInfoFields(m.metadata), ...anchorFields(m.layout.anchors ?? {}, files), ...portalFields(m.portal)];
406
426
  if (new Set(info.map(([k]) => k)).size !== info.length)
407
427
  fail('INFO anchor collides with an INFO field');
408
428
  writeFileSync(join(stage, 'INFO'), info.map(([k, v]) => `${k}="${v.replace(/([\\"$`])/g, '\\$1')}"\n`).join(''));
409
- writeFileSync(join(stage, 'native-layout.json'), JSON.stringify({ format: 1, arch, binaries: binaries.map(({ path, linkage }) => ({ path, linkage })), files, ...(m.layout.anchors ? { anchors: m.layout.anchors } : {}) }));
429
+ writeFileSync(join(stage, 'native-layout.json'), JSON.stringify({ format: 1, arch, binaries: binaries.map(({ path, linkage }) => ({ path, linkage })), files, ...(m.layout.anchors ? { anchors: m.layout.anchors } : {}), ...(m.portal ? { portal: m.portal } : {}) }));
410
430
  const output = join(outDir, `${m.package}-${version}-${arch}.spk`);
411
431
  await createReproducibleTar(stage, output);
412
432
  return output;
@@ -423,7 +443,7 @@ export function inspectNativeLayout(dir, payload, info) {
423
443
  try {
424
444
  if (!/^[A-Za-z][A-Za-z0-9]{1,63}$/.test(info.package ?? '') || !/^\d+(\.\d+){0,3}-\d+$/.test(info.version ?? '') || !/^7\.\d+(\.\d+)?-\d+$/.test(info.os_min_ver ?? ''))
425
445
  fail('invalid native INFO identity/version/DSM');
426
- const inv = object(JSON.parse(readFileSync(join(dir, 'native-layout.json'), 'utf8')), ['format', 'arch', 'binaries', 'files', 'anchors']);
446
+ const inv = object(JSON.parse(readFileSync(join(dir, 'native-layout.json'), 'utf8')), ['format', 'arch', 'binaries', 'files', 'anchors', 'portal']);
427
447
  if (inv.format !== 1 || !Object.hasOwn(LAYOUT_ARCHES, inv.arch) || layoutInfoArch(inv.arch) !== info.arch)
428
448
  fail('invalid native layout inventory');
429
449
  checkLayoutMetadata(dir, true, info.package);
@@ -442,6 +462,10 @@ export function inspectNativeLayout(dir, payload, info) {
442
462
  if (createHash('md5').update(readFileSync(join(dir, 'package.tgz'))).digest('hex') !== info.checksum)
443
463
  fail('package checksum mismatch');
444
464
  checkInfoAnchors(readFileSync(join(dir, 'INFO'), 'utf8'), inv.anchors === undefined ? {} : anchors(inv.anchors), actual);
465
+ const declared = inv.portal === undefined ? undefined : portal(inv.portal, info.package);
466
+ if (declared)
467
+ checkPortal(declared, info.package, info.displayname ?? '', info.os_min_ver);
468
+ checkInfoPortal(readFileSync(join(dir, 'INFO'), 'utf8'), declared);
445
469
  return [];
446
470
  }
447
471
  catch (e) {
@@ -457,3 +481,11 @@ function checkInfoAnchors(info, declared, files) {
457
481
  if (JSON.stringify(lines.sort()) !== JSON.stringify(expected.sort()))
458
482
  fail('INFO anchors do not match the payload');
459
483
  }
484
+ function portalFields(declared) {
485
+ return declared ? [[PORTAL_INFO_KEY, declared.alias]] : [];
486
+ }
487
+ function checkInfoPortal(info, declared) {
488
+ const lines = info.split('\n').filter(line => line.startsWith('synopkgland_portal_'));
489
+ if (JSON.stringify(lines) !== JSON.stringify(portalFields(declared).map(([key, value]) => `${key}="${value}"`)))
490
+ fail('INFO portal alias does not match the declared portal');
491
+ }
@@ -16,7 +16,8 @@ export interface RootTask {
16
16
  * - authorize: DSM's own password confirmation, then one disabled root task
17
17
  * with exactly the embedded script. The one-use token goes into that single
18
18
  * create call and is never kept.
19
- * - run: re-reads the task and runs it only if it is still exactly ours, then
19
+ * - run: re-reads the task and runs it only if it is still exactly ours (the
20
+ * first run after an authorize uses the task that authorize just read back), then
20
21
  * waits for its history entry and asks the backend for the output. A queued
21
22
  * or running task keeps the wait going, up to RUN_LIMIT_MS (loaded NAS took
22
23
  * 4.5 minutes), and `options.progress({op, state, elapsedMs})` hears each poll. Repair
@@ -7,7 +7,8 @@ import { versionUnsupported } from "../runtime/dsm-version.js";
7
7
  * - authorize: DSM's own password confirmation, then one disabled root task
8
8
  * with exactly the embedded script. The one-use token goes into that single
9
9
  * create call and is never kept.
10
- * - run: re-reads the task and runs it only if it is still exactly ours, then
10
+ * - run: re-reads the task and runs it only if it is still exactly ours (the
11
+ * first run after an authorize uses the task that authorize just read back), then
11
12
  * waits for its history entry and asks the backend for the output. A queued
12
13
  * or running task keeps the wait going, up to RUN_LIMIT_MS (loaded NAS took
13
14
  * 4.5 minutes), and `options.progress({op, state, elapsedMs})` hears each poll. Repair
@@ -18,8 +19,9 @@ import { versionUnsupported } from "../runtime/dsm-version.js";
18
19
  */
19
20
  export const ROOT_RUNTIME = `function createRootAccess(options) {
20
21
  var API = "SYNO.Core.TaskScheduler";
21
- var POLL_MS = 500, MAX_POLL_MS = 2000, RUN_LIMIT_MS = 10 * 60 * 1000;
22
- var byOp = {}, running = {};
22
+ var POLL_MS = 500, MAX_POLL_MS = 1000, RUN_LIMIT_MS = 10 * 60 * 1000;
23
+ // The task an authorize just read back as exactly ours, for the run that follows.
24
+ var byOp = {}, running = {}, verified = {};
23
25
  var legacy = null, probe = null;
24
26
  var versionUnsupported = ${versionUnsupported.toString()};
25
27
  options.tasks.forEach(function (task) { byOp[task.op] = task; });
@@ -218,9 +220,14 @@ export const ROOT_RUNTIME = `function createRootAccess(options) {
218
220
  });
219
221
  });
220
222
  }
223
+ function verify(op) {
224
+ var ref = verified[op];
225
+ delete verified[op];
226
+ return ref && op !== "helper_repair" ? Promise.resolve({ state: "ready", ref: ref }) : find(byOp[op]);
227
+ }
221
228
  function performRun(op) {
222
229
  return begin(op).then(function (task) {
223
- return find(task).then(function (found) {
230
+ return verify(op).then(function (found) {
224
231
  if (found.state !== "ready") throw fail("root_not_ready");
225
232
  var consent = op === "helper_repair" ? options.confirmPassword() : Promise.resolve({ confirmed: true });
226
233
  return consent.then(function (confirmation) {
@@ -239,9 +246,10 @@ export const ROOT_RUNTIME = `function createRootAccess(options) {
239
246
  return begin(op).then(find).then(function (found) { return found.state; });
240
247
  },
241
248
  authorize: function (op) {
249
+ delete verified[op];
242
250
  return begin(op).then(function (task) {
243
251
  return find(task).then(function (found) {
244
- if (found.state === "ready") return "ready";
252
+ if (found.state === "ready") { verified[op] = found.ref; return "ready"; }
245
253
  if (found.state === "conflict") throw fail("root_conflict");
246
254
  return options.confirmPassword().then(function (confirmation) {
247
255
  if (!confirmation || confirmation.confirmed !== true) return "cancelled";
@@ -259,6 +267,7 @@ export const ROOT_RUNTIME = `function createRootAccess(options) {
259
267
  return find(task);
260
268
  }).then(function (after) {
261
269
  if (after.state !== "ready") throw fail("root_verify_failed");
270
+ verified[op] = after.ref;
262
271
  return "ready";
263
272
  });
264
273
  });
@@ -277,6 +286,7 @@ export const ROOT_RUNTIME = `function createRootAccess(options) {
277
286
  return pending;
278
287
  },
279
288
  remove: function (op) {
289
+ delete verified[op];
280
290
  return begin(op).then(function (task) {
281
291
  return find(task).then(function (found) {
282
292
  if (found.state === "missing") return "missing";
@@ -12,8 +12,10 @@ export declare const PORTAL_MESSAGES: Record<string, Record<PortalLocale, string
12
12
  * Control Panel shortcut.
13
13
  *
14
14
  * `status()` resolves `{state, alias?, url?, canManage, controlPanel,
15
- * defaultAlias}`; `apply(alias)` and `disable()` resolve the new status or
16
- * reject `{code}`; `openControlPanel()` opens Login Portal > Applications.
15
+ * defaultAlias, entry?}`; `apply(alias)` and `disable()` resolve the new
16
+ * status or reject `{code}`. With `options.entry` (below DSM 7.2) the
17
+ * backend's `portal.status` also reports the executor's entry, and
18
+ * administrators get `register()` and `unregister()`; `openControlPanel()` opens Login Portal > Applications.
17
19
  * `messages` is the panel text in the window's locale; `mountPanel(element)`
18
20
  * renders a plain panel for UIs without their own.
19
21
  */
@@ -4,17 +4,22 @@ export const PORTAL_MESSAGES = {
4
4
  title: { en: "Application Portal", "zh-TW": "應用程式入口", "zh-CN": "应用程序门户", ja: "アプリケーションポータル", ko: "응용 프로그램 포털", de: "Anwendungsportal", fr: "Portail d'application", es: "Portal de aplicaciones", it: "Portale applicazioni" },
5
5
  link: { en: "Direct link after DSM sign-in:", "zh-TW": "DSM 登入後的直接連結:", "zh-CN": "DSM 登录后的直接链接:", ja: "DSM サインイン後の直接リンク:", ko: "DSM 로그인 후 바로 가기 링크:", de: "Direkter Link nach der DSM-Anmeldung:", fr: "Lien direct après connexion à DSM :", es: "Enlace directo tras iniciar sesión en DSM:", it: "Link diretto dopo l'accesso a DSM:" },
6
6
  inactive: { en: "Not active. Apply an alias to let users open this app directly after DSM sign-in.", "zh-TW": "尚未啟用。套用別名後,使用者可在 DSM 登入後直接開啟這個 App。", "zh-CN": "尚未启用。应用别名后,用户可在 DSM 登录后直接打开此应用。", ja: "無効です。エイリアスを適用すると、DSM サインイン後にこのアプリを直接開けます。", ko: "비활성 상태입니다. 별칭을 적용하면 DSM 로그인 후 이 앱을 바로 열 수 있습니다.", de: "Nicht aktiv. Wenden Sie einen Alias an, damit Benutzer diese App nach der DSM-Anmeldung direkt öffnen können.", fr: "Inactif. Appliquez un alias pour ouvrir cette application directement après connexion à DSM.", es: "Inactivo. Aplique un alias para abrir esta aplicación directamente tras iniciar sesión en DSM.", it: "Non attivo. Applica un alias per aprire questa app direttamente dopo l'accesso a DSM." },
7
- askAdmin: { en: "Ask an administrator to set this up", "zh-TW": "請管理員設定", "zh-CN": "请管理员设置", ja: "管理者に設定を依頼してください", ko: "관리자에게 설정을 요청하세요", de: "Bitten Sie einen Administrator, dies einzurichten", fr: "Demandez à un administrateur de le configurer", es: "Pida a un administrador que lo configure", it: "Chiedi a un amministratore di configurarlo" },
7
+ askAdmin: { en: "Ask an administrator to set this up", "zh-TW": "請管理員設定", "zh-CN": "请管理员设置", ja: "管理者に設定を依頼してください", ko: "관리자에게 설정을 요청하십시오", de: "Bitten Sie einen Administrator, dies einzurichten", fr: "Demandez à un administrateur de le configurer", es: "Pida a un administrador que lo configure", it: "Chiedi a un amministratore di configurarlo" },
8
8
  alias: { en: "Alias", "zh-TW": "別名", "zh-CN": "别名", ja: "エイリアス", ko: "별칭", de: "Alias", fr: "Alias", es: "Alias", it: "Alias" },
9
9
  apply: { en: "Apply", "zh-TW": "套用", "zh-CN": "应用", ja: "適用", ko: "적용", de: "Übernehmen", fr: "Appliquer", es: "Aplicar", it: "Applica" },
10
10
  disable: { en: "Turn off", "zh-TW": "停用", "zh-CN": "停用", ja: "無効にする", ko: "끄기", de: "Ausschalten", fr: "Désactiver", es: "Desactivar", it: "Disattiva" },
11
11
  advanced: { en: "Advanced settings in Control Panel", "zh-TW": "在控制台進行進階設定", "zh-CN": "在控制面板中进行高级设置", ja: "コントロールパネルの詳細設定", ko: "제어판의 고급 설정", de: "Erweiterte Einstellungen in der Systemsteuerung", fr: "Paramètres avancés dans le Panneau de configuration", es: "Ajustes avanzados en el Panel de control", it: "Impostazioni avanzate nel Pannello di controllo" },
12
12
  controlPanel: { en: "Open Control Panel Application Portal", "zh-TW": "開啟控制台 Application Portal", "zh-CN": "打开控制面板 Application Portal", ja: "コントロールパネルの Application Portal を開く", ko: "제어판 Application Portal 열기", de: "Application Portal in der Systemsteuerung öffnen", fr: "Ouvrir Application Portal dans le Panneau de configuration", es: "Abrir Application Portal en el Panel de control", it: "Apri Application Portal nel Pannello di controllo" },
13
- portal_alias_invalid: { en: "Use 1-64 lowercase letters, digits or hyphens, not starting or ending with a hyphen.", "zh-TW": "請使用 1–64 個小寫英文字母、數字或連字號,且不可以連字號開頭或結尾。", "zh-CN": "请使用 1–64 个小写英文字母、数字或连字符,且不能以连字符开头或结尾。", ja: "1~64 文字の小文字英字・数字・ハイフンを使用し、先頭と末尾にハイフンは使えません。", ko: "1~64자의 영문 소문자, 숫자 또는 하이픈을 사용하고 하이픈으로 시작하거나 끝나지 않게 하세요.", de: "Verwenden Sie 1–64 Kleinbuchstaben, Ziffern oder Bindestriche, ohne Bindestrich am Anfang oder Ende.", fr: "Utilisez 1 à 64 lettres minuscules, chiffres ou tirets, sans tiret au début ni à la fin.", es: "Use de 1 a 64 letras minúsculas, dígitos o guiones, sin guion al principio ni al final.", it: "Usa da 1 a 64 lettere minuscole, cifre o trattini, senza trattino all'inizio o alla fine." },
14
- portal_alias_reserved: { en: "DSM already uses this alias. Choose another one.", "zh-TW": "DSM 已使用這個別名,請改用其他別名。", "zh-CN": "DSM 已使用此别名,请改用其他别名。", ja: "このエイリアスは DSM で使用されています。別のものを選んでください。", ko: "DSM에서 이미 사용하는 별칭입니다. 다른 별칭을 선택하세요.", de: "DSM verwendet diesen Alias bereits. Wählen Sie einen anderen.", fr: "DSM utilise déjà cet alias. Choisissez-en un autre.", es: "DSM ya usa este alias. Elija otro.", it: "DSM usa già questo alias. Scegline un altro." },
15
- portal_alias_in_use: { en: "Another app already uses this alias. Choose another one.", "zh-TW": "另一個 App 已使用這個別名,請改用其他別名。", "zh-CN": "另一个应用已使用此别名,请改用其他别名。", ja: "別のアプリがこのエイリアスを使用しています。別のものを選んでください。", ko: "다른 앱이 이미 이 별칭을 사용합니다. 다른 별칭을 선택하세요.", de: "Eine andere App verwendet diesen Alias bereits. Wählen Sie einen anderen.", fr: "Une autre application utilise déjà cet alias. Choisissez-en un autre.", es: "Otra aplicación ya usa este alias. Elija otro.", it: "Un'altra app usa già questo alias. Scegline un altro." },
13
+ entryOff: { en: "This app has no Application Portal entry yet. Enable the entry so that an alias can open it after DSM sign-in.", "zh-TW": "這個 App 尚未加入應用程式入口。啟用入口後,即可用別名在 DSM 登入後直接開啟。", "zh-CN": "此应用尚未加入应用程序门户。启用入口后,即可用别名在 DSM 登录后直接打开。", ja: "このアプリはまだアプリケーションポータルに登録されていません。エントリを有効にすると、DSM サインイン後にエイリアスで直接開けます。", ko: "이 앱은 아직 응용 프로그램 포털에 등록되지 않았습니다. 항목을 활성화하면 DSM 로그인 후 별칭으로 바로 열 수 있습니다.", de: "Diese App hat noch keinen Eintrag im Anwendungsportal. Aktivieren Sie den Eintrag, damit ein Alias sie nach der DSM-Anmeldung öffnet.", fr: "Cette application n'a pas encore d'entrée dans le Portail d'application. Activez l'entrée pour qu'un alias l'ouvre après connexion à DSM.", es: "Esta aplicación aún no tiene una entrada en el Portal de aplicaciones. Active la entrada para que un alias la abra tras iniciar sesión en DSM.", it: "Questa app non ha ancora una voce nel Portale applicazioni. Attivare la voce per aprirla con un alias dopo l'accesso a DSM." },
14
+ cleanupRequired: { en: "The Application Portal entry was not fully removed. Remove the entry again.", "zh-TW": "應用程式入口未完全移除。請再移除一次入口。", "zh-CN": "应用程序门户入口未完全移除。请再移除一次入口。", ja: "アプリケーションポータルのエントリが完全に削除されていません。もう一度エントリを削除してください。", ko: "응용 프로그램 포털 항목이 완전히 제거되지 않았습니다. 항목을 다시 제거하십시오.", de: "Der Eintrag im Anwendungsportal wurde nicht vollständig entfernt. Entfernen Sie den Eintrag erneut.", fr: "L'entrée du Portail d'application n'a pas été entièrement supprimée. Supprimez de nouveau l'entrée.", es: "La entrada del Portal de aplicaciones no se eliminó por completo. Elimine la entrada de nuevo.", it: "La voce del Portale applicazioni non è stata rimossa completamente. Rimuovere di nuovo la voce." },
15
+ register: { en: "Enable entry", "zh-TW": "啟用入口", "zh-CN": "启用入口", ja: "エントリを有効にする", ko: "항목 활성화", de: "Eintrag aktivieren", fr: "Activer l'entrée", es: "Activar entrada", it: "Attiva voce" },
16
+ unregister: { en: "Remove entry", "zh-TW": "移除入口", "zh-CN": "移除入口", ja: "エントリを削除", ko: "항목 제거", de: "Eintrag entfernen", fr: "Supprimer l'entrée", es: "Eliminar entrada", it: "Rimuovi voce" },
17
+ portal_alias_invalid: { en: "Use 1-64 lowercase letters, digits or hyphens, not starting or ending with a hyphen.", "zh-TW": "請使用 1–64 個小寫英文字母、數字或連字號,且不可以連字號開頭或結尾。", "zh-CN": "请使用 1–64 个小写英文字母、数字或连字符,且不能以连字符开头或结尾。", ja: "1~64 文字の小文字英字・数字・ハイフンを使用し、先頭と末尾にハイフンは使えません。", ko: "1~64자의 영문 소문자, 숫자 또는 하이픈을 사용하고 하이픈으로 시작하거나 끝나지 않게 하십시오.", de: "Verwenden Sie 1–64 Kleinbuchstaben, Ziffern oder Bindestriche, ohne Bindestrich am Anfang oder Ende.", fr: "Utilisez 1 à 64 lettres minuscules, chiffres ou tirets, sans tiret au début ni à la fin.", es: "Use de 1 a 64 letras minúsculas, dígitos o guiones, sin guion al principio ni al final.", it: "Usa da 1 a 64 lettere minuscole, cifre o trattini, senza trattino all'inizio o alla fine." },
18
+ portal_alias_reserved: { en: "DSM already uses this alias. Choose another one.", "zh-TW": "DSM 已使用這個別名,請改用其他別名。", "zh-CN": "DSM 已使用此别名,请改用其他别名。", ja: "このエイリアスは DSM で使用されています。別のものを選んでください。", ko: "DSM에서 이미 사용하는 별칭입니다. 다른 별칭을 선택하십시오.", de: "DSM verwendet diesen Alias bereits. Wählen Sie einen anderen.", fr: "DSM utilise déjà cet alias. Choisissez-en un autre.", es: "DSM ya usa este alias. Elija otro.", it: "DSM usa già questo alias. Scegline un altro." },
19
+ portal_alias_in_use: { en: "Another app already uses this alias. Choose another one.", "zh-TW": "另一個 App 已使用這個別名,請改用其他別名。", "zh-CN": "另一个应用已使用此别名,请改用其他别名。", ja: "別のアプリがこのエイリアスを使用しています。別のものを選んでください。", ko: "다른 앱이 이미 이 별칭을 사용합니다. 다른 별칭을 선택하십시오.", de: "Eine andere App verwendet diesen Alias bereits. Wählen Sie einen anderen.", fr: "Une autre application utilise déjà cet alias. Choisissez-en un autre.", es: "Otra aplicación ya usa este alias. Elija otro.", it: "Un'altra app usa già questo alias. Scegline un altro." },
16
20
  admin_required: { en: "Only a DSM administrator can change this.", "zh-TW": "只有 DSM 管理員可以變更這項設定。", "zh-CN": "只有 DSM 管理员可以更改此设置。", ja: "これを変更できるのは DSM 管理者のみです。", ko: "DSM 관리자만 변경할 수 있습니다.", de: "Nur ein DSM-Administrator kann dies ändern.", fr: "Seul un administrateur DSM peut modifier ce paramètre.", es: "Solo un administrador de DSM puede cambiar esto.", it: "Solo un amministratore DSM può modificarlo." },
17
- portal_unavailable: { en: "DSM did not accept the change. Try again or use Control Panel.", "zh-TW": "DSM 未接受變更。請再試一次或改用控制台。", "zh-CN": "DSM 未接受更改。请重试或改用控制面板。", ja: "DSM は変更を受け付けませんでした。再試行するか、コントロールパネルを使用してください。", ko: "DSM이 변경을 받아들이지 않았습니다. 다시 시도하거나 제어판을 사용하세요.", de: "DSM hat die Änderung nicht übernommen. Versuchen Sie es erneut oder verwenden Sie die Systemsteuerung.", fr: "DSM n'a pas accepté la modification. Réessayez ou utilisez le Panneau de configuration.", es: "DSM no aceptó el cambio. Inténtelo de nuevo o use el Panel de control.", it: "DSM non ha accettato la modifica. Riprova o usa il Pannello di controllo." },
21
+ portal_unavailable: { en: "DSM did not accept the change. Try again or use Control Panel.", "zh-TW": "DSM 未接受變更。請再試一次或改用控制台。", "zh-CN": "DSM 未接受更改。请重试或改用控制面板。", ja: "DSM は変更を受け付けませんでした。再試行するか、コントロールパネルを使用してください。", ko: "DSM이 변경을 받아들이지 않았습니다. 다시 시도하거나 제어판을 사용하십시오.", de: "DSM hat die Änderung nicht übernommen. Versuchen Sie es erneut oder verwenden Sie die Systemsteuerung.", fr: "DSM n'a pas accepté la modification. Réessayez ou utilisez le Panneau de configuration.", es: "DSM no aceptó el cambio. Inténtelo de nuevo o use el Panel de control.", it: "DSM non ha accettato la modifica. Riprova o usa il Pannello di controllo." },
22
+ PORTAL_FAILED: { en: "DSM did not accept the Application Portal change. Try again.", "zh-TW": "DSM 未接受應用程式入口的變更。請再試一次。", "zh-CN": "DSM 未接受应用程序门户的更改。请重试。", ja: "DSM はアプリケーションポータルの変更を受け付けませんでした。再試行してください。", ko: "DSM이 응용 프로그램 포털 변경을 받아들이지 않았습니다. 다시 시도하십시오.", de: "DSM hat die Änderung am Anwendungsportal nicht übernommen. Versuchen Sie es erneut.", fr: "DSM n'a pas accepté la modification du Portail d'application. Réessayez.", es: "DSM no aceptó el cambio en el Portal de aplicaciones. Inténtelo de nuevo.", it: "DSM non ha accettato la modifica al Portale applicazioni. Riprovare." },
18
23
  };
19
24
  function localizedMessages() {
20
25
  const out = {};
@@ -35,8 +40,10 @@ function localizedMessages() {
35
40
  * Control Panel shortcut.
36
41
  *
37
42
  * `status()` resolves `{state, alias?, url?, canManage, controlPanel,
38
- * defaultAlias}`; `apply(alias)` and `disable()` resolve the new status or
39
- * reject `{code}`; `openControlPanel()` opens Login Portal > Applications.
43
+ * defaultAlias, entry?}`; `apply(alias)` and `disable()` resolve the new
44
+ * status or reject `{code}`. With `options.entry` (below DSM 7.2) the
45
+ * backend's `portal.status` also reports the executor's entry, and
46
+ * administrators get `register()` and `unregister()`; `openControlPanel()` opens Login Portal > Applications.
40
47
  * `messages` is the panel text in the window's locale; `mountPanel(element)`
41
48
  * renders a plain panel for UIs without their own.
42
49
  */
@@ -46,6 +53,7 @@ export const PORTAL_RUNTIME = `function createPortalAccess(options) {
46
53
  var LINKABLE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
47
54
  var CATALOGS = ${JSON.stringify(localizedMessages())};
48
55
  var messages = CATALOGS[options.locale] || CATALOGS.en;
56
+ var ENTRIES = ["registered", "unregistered", "cleanup-required"];
49
57
  var ERRORS = { dsm_4100: "portal_alias_invalid", dsm_4102: "portal_alias_reserved", dsm_4103: "portal_alias_in_use", dsm_105: "admin_required" };
50
58
  function result(state, alias, managed) {
51
59
  var out = { state: state, canManage: managed === true, controlPanel: managed !== undefined, defaultAlias: options.defaultAlias };
@@ -55,11 +63,12 @@ export const PORTAL_RUNTIME = `function createPortalAccess(options) {
55
63
  }
56
64
  return out;
57
65
  }
66
+ function readBackend(data) {
67
+ if (data && data.state === "active" && typeof data.alias === "string" && LINKABLE.test(data.alias)) return result("active", data.alias);
68
+ return result(data && data.state === "inactive" ? "inactive" : "unavailable");
69
+ }
58
70
  function fromBackend() {
59
- return options.call("portal.status", {}).then(function (data) {
60
- if (data && data.state === "active" && typeof data.alias === "string" && LINKABLE.test(data.alias)) return result("active", data.alias);
61
- return result(data && data.state === "inactive" ? "inactive" : "unavailable");
62
- }, function () {
71
+ return options.call("portal.status", {}).then(readBackend, function () {
63
72
  return result("unavailable");
64
73
  });
65
74
  }
@@ -79,9 +88,33 @@ export const PORTAL_RUNTIME = `function createPortalAccess(options) {
79
88
  return result("unavailable", undefined, false);
80
89
  });
81
90
  }
91
+ function withEntry(out, entry) {
92
+ out.entry = entry;
93
+ return out;
94
+ }
95
+ // Below DSM 7.2 the executor registers the entry; DSM lists the App only while it is registered.
96
+ function fromEntry() {
97
+ return options.call("portal.status", {}).then(function (data) {
98
+ var entry = data && ENTRIES.indexOf(data.entry) >= 0 ? data.entry : "";
99
+ if (!options.isAdmin()) return readBackend(data);
100
+ if (entry === "registered") return fromDsm().then(function (out) { return withEntry(out, entry); });
101
+ if (!entry) return result("unavailable", undefined, false);
102
+ var read = readBackend(data);
103
+ return withEntry(result(entry === "unregistered" ? "inactive" : read.state, read.alias, true), entry);
104
+ }, function () {
105
+ return options.isAdmin() ? result("unavailable", undefined, false) : result("unavailable");
106
+ });
107
+ }
82
108
  function status() {
109
+ if (options.entry) return fromEntry();
83
110
  return options.isAdmin() ? fromDsm() : fromBackend();
84
111
  }
112
+ function change(method) {
113
+ if (!options.isAdmin()) return Promise.reject({ code: "admin_required" });
114
+ return options.call(method, {}).then(status, function (error) {
115
+ throw { code: error && error.code === "ADMIN_REQUIRED" ? "admin_required" : "PORTAL_FAILED" };
116
+ });
117
+ }
85
118
  function set(alias) {
86
119
  if (!options.isAdmin()) return Promise.reject({ code: "admin_required" });
87
120
  return options.send("SYNO.Core.AppPortal", "set", 2, { id: options.appId, alias: alias }).then(status, function (error) {
@@ -106,6 +139,10 @@ export const PORTAL_RUNTIME = `function createPortalAccess(options) {
106
139
  return mountPanel(element);
107
140
  }
108
141
  };
142
+ if (options.entry) {
143
+ access.register = function () { return change("portal.register"); };
144
+ access.unregister = function () { return change("portal.unregister"); };
145
+ }
109
146
  function node(tag, attrs, children) {
110
147
  var out = document.createElement(tag);
111
148
  for (var key in attrs) {
@@ -129,7 +166,11 @@ export const PORTAL_RUNTIME = `function createPortalAccess(options) {
129
166
  if (current.state === "active") {
130
167
  parts.push(node("p", {}, [node("span", { text: messages.link + " " }), node("a", { href: current.url, target: "_blank", rel: "noopener", text: current.url })]));
131
168
  }
132
- if (current.canManage) {
169
+ if (current.canManage && current.entry && current.entry !== "registered") {
170
+ var off = current.entry === "unregistered";
171
+ parts.push(node("p", { text: off ? messages.entryOff : messages.cleanupRequired }));
172
+ parts.push(off ? button(messages.register, function () { settle(access.register()); }) : button(messages.unregister, function () { settle(access.unregister()); }));
173
+ } else if (current.canManage) {
133
174
  if (current.state !== "active") parts.push(node("p", { text: messages.inactive }));
134
175
  var input = node("input", { type: "text", maxlength: "64", autocomplete: "off", "aria-label": messages.alias });
135
176
  input.value = current.alias || current.defaultAlias;
@@ -137,6 +178,7 @@ export const PORTAL_RUNTIME = `function createPortalAccess(options) {
137
178
  form.addEventListener("submit", function (event) { event.preventDefault(); settle(access.apply(input.value)); });
138
179
  if (current.state === "active") form.appendChild(button(messages.disable, function () { settle(access.disable()); }));
139
180
  parts.push(form, button(messages.advanced, access.openControlPanel));
181
+ if (current.entry) parts.push(button(messages.unregister, function () { settle(access.unregister()); }));
140
182
  } else if (current.controlPanel) {
141
183
  parts.push(button(messages.controlPanel, access.openControlPanel));
142
184
  } else if (current.state !== "active") {
@@ -1,4 +1,7 @@
1
1
  import type { StandaloneManifest } from "./manifest.js";
2
2
  import type { PortalPackage } from "../portal/package.js";
3
- /** A native portal opens the package's own DSM window. */
3
+ /**
4
+ * A native portal registered through DSM's web-config resource; it opens the
5
+ * package's own DSM window. Below DSM 7.2 the executor registers it instead.
6
+ */
4
7
  export declare function nativePortal(manifest: StandaloneManifest): PortalPackage | undefined;
@@ -1,4 +1,10 @@
1
- /** A native portal opens the package's own DSM window. */
1
+ import { portalByExecutor } from "../build-spk/dsm-version.js";
2
+ /**
3
+ * A native portal registered through DSM's web-config resource; it opens the
4
+ * package's own DSM window. Below DSM 7.2 the executor registers it instead.
5
+ */
2
6
  export function nativePortal(manifest) {
3
- return manifest.portal && { appId: `SYNO.SDS.${manifest.package}.Instance`, application: manifest.displayname, alias: manifest.portal.alias };
7
+ if (!manifest.portal || portalByExecutor(manifest.os_min_ver))
8
+ return undefined;
9
+ return { appId: `SYNO.SDS.${manifest.package}.Instance`, application: manifest.displayname, alias: manifest.portal.alias };
4
10
  }
@@ -22,6 +22,7 @@ import { copyDaemonPayload } from "./daemon.js";
22
22
  import { BACKGROUND_UNIT_DIR, BACKGROUND_WORKER_PAYLOAD, backgroundUnitName, renderBackgroundUnit } from "./background.js";
23
23
  import { renderStandaloneResource } from "./resource.js";
24
24
  import { nativePortal } from "./portal.js";
25
+ import { portalByExecutor } from "../build-spk/dsm-version.js";
25
26
  import { PORTAL_JSON_RELPATH, PORTAL_MUSTACHE_RELPATH, renderPortalJson, renderPortalMustache, renderPortalWizardStep } from "../portal/package.js";
26
27
  import { executorPlan } from "../runtime/executor-build.js";
27
28
  import { EXECUTOR_ANCHOR } from "../runtime/executor-inspect.js";
@@ -131,7 +132,7 @@ export async function assembleStandaloneSpk(options) {
131
132
  ...install.static,
132
133
  ...install.dynamic,
133
134
  ...(folder ? renderSharedFolderUpgradeWizardFiles(manifest.package, folder) : {}),
134
- ...renderUninstallWizardFiles(manifest.package, "native", { executor: Boolean(options.executor), portal: Boolean(portal) }),
135
+ ...renderUninstallWizardFiles(manifest.package, "native", { executor: Boolean(options.executor), portal: Boolean(portal), portalEntry: Boolean(manifest.portal && portalByExecutor(manifest.os_min_ver)) }),
135
136
  });
136
137
  writePackageCenterIcons(staging, icons);
137
138
  mkdirSync(dirname(options.outputPath), { recursive: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synopackageland/cli",
3
- "version": "0.9.3",
3
+ "version": "0.9.5",
4
4
  "description": "Syno Package Land Developer Kit CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,12 +19,12 @@
19
19
  "scripts": {
20
20
  "build": "rm -rf dist && tsc",
21
21
  "test": "npm run build && node --import tsx --test test/**/*.test.ts",
22
- "test:scripts": "node --test scripts/test/release.test.mjs scripts/test/pack-docker.test.mjs scripts/test/npm-pack.test.mjs scripts/test/executor-bump.test.mjs scripts/test/smoke-exports.test.mjs scripts/test/executor-digests.test.mjs",
22
+ "test:scripts": "node --test scripts/test/gate.test.mjs scripts/test/release.test.mjs scripts/test/pack-docker.test.mjs scripts/test/npm-pack.test.mjs scripts/test/executor-bump.test.mjs scripts/test/smoke-exports.test.mjs scripts/test/executor-digests.test.mjs",
23
23
  "gates": "bash scripts/ci/gate.sh npm && bash scripts/ci/gate.sh native-go && bash scripts/ci/gate.sh daemon-gateway",
24
24
  "prepublishOnly": "npm run build"
25
25
  },
26
26
  "dependencies": {
27
- "@synopackageland/templates": "0.9.3",
27
+ "@synopackageland/templates": "0.9.5",
28
28
  "ajv": "^8.17.1",
29
29
  "commander": "^13.1.0",
30
30
  "tar": "^7.4.3",
@@ -2,20 +2,24 @@
2
2
 
3
3
  Tier: only an App with the executor (`capabilities.rootOps` or `capabilities.delegate`) may declare a portal, because after uninstall only root can make nginx stop serving the alias. A no-privilege native App fails check/build with `STANDALONE_PORTAL_REQUIRES_EXECUTOR`, `add portal` refuses it unless the same call adds `root-ops` or `delegate`, and inspect flags such an SPK with `PORTAL_REQUIRES_EXECUTOR`. A Compose App has no executor: `integration.web.portal` always fails with `CONTRACT_PORTAL_REQUIRES_EXECUTOR`. Without the executor, an administrator can set up a reverse proxy in DSM instead.
4
4
 
5
- Declare: native-go/binary with rootOps or delegate, `portal: {}` or `portal: {alias: my-app}` in dsm-package.yaml (`synopkgland add root-ops portal`, or `add portal` once the executor is declared). The alias defaults to the package name in lowercase. Aliases are 1–64 lowercase letters, digits or `-`, not starting or ending with `-`, and not a DSM path or Synology App alias (`webman`, `file`, …). The native-layout `backend: {kind: native}` owns its conf/scripts: the SDK does not register a portal for it.
5
+ Declare: native-go/binary with rootOps or delegate, `portal: {}` or `portal: {alias: my-app}` in dsm-package.yaml (`synopkgland add root-ops portal`, or `add portal` once the executor is declared). The alias defaults to the package name in lowercase. Aliases are 1–64 lowercase letters, digits or `-`, not starting or ending with `-`, and not a DSM path or Synology App alias (`webman`, `file`, …). The native-layout `backend: {kind: native}` owns its conf/scripts and its own root helper: `portal: {}` or `{alias}` there needs os_min_ver 7.1-42661+ and writes only `synopkgland_portal_alias="<alias>"` into INFO, which Package Center writes as root, so the product's root helper can trust it; the helper registers the app.d entry (application = INFO displayname) and owns its uninstall cleanup and wizard. inspect checks that INFO line against `native-layout.json`.
6
6
 
7
- Build: the SPK carries `etc/portal.json` (every field DSM's app-portal worker needs) and `etc/portal.mustache`, `conf/resource` registers them, and the install wizard adds a prefilled alias step. preinst validates the answer; postinst stores it as the package's own draft and default alias. Nothing is active until an administrator applies it. `inspect <spk> --json` reports `portal: {appId, alias}` and refuses any other portal content.
7
+ Build (os_min_ver 7.2-64570+): the SPK carries `etc/portal.json` (every field DSM's app-portal worker needs) and `etc/portal.mustache`, `conf/resource` registers them, and the install wizard adds a prefilled alias step. preinst validates the answer; postinst stores it as the package's own draft and default alias. Nothing is active until an administrator applies it. `inspect <spk> --json` reports `portal: {appId, alias}` and refuses any other portal content.
8
8
 
9
9
  Activate: DSM owns the saved setting. Administrators use the app's portal panel (`ctx.portal.apply(alias)` / `disable()`, DSM `SYNO.Core.AppPortal set` with their own session; DSM refuses non-administrators with 105) or Control Panel > Login Portal > Applications (`ctx.portal.openControlPanel()`). DSM errors map to `portal_alias_invalid`, `portal_alias_reserved`, `portal_alias_in_use`, `admin_required`, `portal_unavailable`.
10
10
 
11
11
  Read: ordinary users call the Go backend's `portal.status` (`authenticated`), which reads `/var/packages/<package>/etc/AppPortal.json` read-only, bounded (64 KiB, no symlinks, one JSON object, matching app id). Anything else is `unavailable` and the UI shows "ask an administrator". A binary CGI must implement the same `{state, alias}` answer itself or ordinary users never see the link.
12
12
 
13
- DSM floor: os_min_ver must be 7.3.2-86009 or later. DSM 7.1.1-42962 refuses an unsigned SPK whose conf/resource carries web-config app-portal (Package Center error 320, "non-synology-signed package should not use internal worker"); 7.3.2-86009 and 7.4 install it; 7.2 and 7.3.0/7.3.1 are untested (unknown). validate/build fail with `STANDALONE_PORTAL_MIN_DSM`, `--min-dsm` cannot go below it, `add portal` raises a lower os_min_ver, and inspect flags a lower INFO os_min_ver. Fix: raise os_min_ver or remove the portal.
13
+ Executor mode (7.1-42661 ≤ os_min_ver < 7.2-64570): no web-config resource, no `etc/portal.*`, no alias wizard step or alias lifecycle. The executor policy carries `portal: {application: <displayname>, alias}` and the operations `portal.register` and `portal.unregister`; the executor writes `/usr/syno/etc/www/app.d/<package>.{json,mustache}`. The package name must match `^[A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*$` (≤ 64) and displayname be 1–64 characters without control characters, or build fails with `STANDALONE_PORTAL_ENTRY_INVALID`. The CGI is built with `-X main.portal=executor`: `portal.status` adds `entry` (`registered`, `unregistered`, `cleanup-required`; an unreadable executor is `unavailable`), and `portal.register` / `portal.unregister` take the administrator's DSM session and return the fresh status. `ctx.portal.register()` / `unregister()` call them; the plain panel shows text and "Enable entry" while unregistered, the alias form and "Remove entry" once registered, text and "Remove entry" when cleanup is required, and no entry buttons for non-administrators. Executor refusals keep their code (`PORTAL_FAILED`). Needs templates with the executor portal routes (after 0.9.4) and an SDK with the `portal` entry API (after 0.9.5).
14
+
15
+ DSM floor: os_min_ver must be 7.1-42661 or later; below 7.2-64570 the executor registers the entry (above). DSM 7.1 marks the web-config worker Synology-only, so it refuses an unsigned SPK whose conf/resource carries web-config (Package Center error 320, "non-synology-signed package should not use internal worker"; seen on 7.1.1-42962). synopkg from DSM 7.2 on drops that mark, the only change to its worker table; the 7.2 floor comes from that source and is not yet verified on a 7.2 NAS. 7.3.2-86009 and 7.4 install it. Below 7.1 validate/build fail with `STANDALONE_PORTAL_MIN_DSM`, `--min-dsm` cannot go below 7.1, `add portal` raises a lower os_min_ver to 7.1-42661, and inspect flags an INFO os_min_ver below the floor of the SPK's mode. Fix: raise os_min_ver or remove the portal.
14
16
 
15
17
  Capability: beyond that floor, no version gate. The panel needs SYNO.API.Info to list `SYNO.Core.AppPortal` (v2) and the app's registration to exist; otherwise administrators get only the Control Panel shortcut.
16
18
 
17
19
  UI: plain JS calls `ctx.portal && ctx.portal.mountPanel(element)` and disposes it on unmount; Vue renders `portal/PortalSettings.vue`. `ctx.portal.messages` carries the SDK text in all nine languages.
18
20
 
19
- Never: write `AppPortal.json`, `@appconf/<package>` or `/usr/syno/etc/www/app.d`; add a backend route that changes the alias; relay QuickConnect responses or build QuickConnect links; reboot or change other packages' portal entries while testing. DSM keeps the alias across upgrades and removes the entry on uninstall, but it does not reload nginx, and it keeps the App's Application Privileges rules.
21
+ Never: write `AppPortal.json`, `@appconf/<package>` or `/usr/syno/etc/www/app.d` from the App (only the executor's approved `portal.*` operations write app.d); add a backend route that changes the alias; relay QuickConnect responses or build QuickConnect links; reboot or change other packages' portal entries while testing. DSM keeps the alias across upgrades and removes the entry on uninstall, but it does not reload nginx, and it keeps the App's Application Privileges rules.
20
22
 
21
23
  Uninstall: the root executor's purge deletes the rules of `SYNO.SDS.<package>.Instance` through the Application Privileges API and, once DSM has removed the alias files, reloads nginx (`synosystemctl reload nginx`), so the alias stops at once. Failures are only logged to the system log entry `synopkgland-executor`; then an administrator saves an alias in Control Panel > Login Portal > Applications (which reloads nginx) or restarts the NAS, and removes the rules in Control Panel > Application Privileges. The uninstall wizard and the `status`/`inspect` cleanup list say this in that order: automatic first, manual only as the fallback.
24
+
25
+ Executor mode uninstall: the purge reads the retained policy and unregisters the entry. If the executor is not running or was never approved, uninstall does not remove the entry; the wizard and cleanup list say so and give the root commands (`rm -f /usr/syno/etc/www/app.d/<package>.json /usr/syno/etc/www/app.d/<package>.mustache /usr/syno/etc/packages/<package>/AppPortal.json`, `synow3tool --disable-nginx-confd/--unreg-nginx-confd` for `dsm www alias .alias`, `--disable-nginx-sites/--unreg-nginx-sites` for `server .server` on `<c>.syno-app-portal.<package>.conf`, then `nginx -t && synow3tool --nginx=reload`).
@@ -68,7 +68,11 @@ also exports `@synopackageland/ui/json-catalog.schema.json` and
68
68
 
69
69
  In DSM windows, keep a bounded `AppRoot` (for example `height: 100%`), use its
70
70
  default app modal scope or explicit `modalScope: "app"`, and set JSON `AppShell`
71
- `wideLayout: "window"`. Vue `AppLayout` uses `wide-layout="window"`. Reserve
71
+ `wideLayout: "window"`. Vue `AppLayout` uses `wide-layout="window"`. In a DSM
72
+ window (`ctx.host.platform().host === 'dsm'`), the Vue wrapper gives `AppRoot`
73
+ `dialog-bounds="viewport"` and connects its `activate` event to
74
+ `ctx.host.activateWindow()`, so Dialogs can leave the window and pressing one
75
+ brings the window to the front; below 600px they are bottom sheets. Reserve
72
76
  root sizing for the Vue/host wrapper; only use props exposed by the current JSON
73
77
  schema inside the spec. Reserve
74
78
  `modalScope: "document"` and `wideLayout: "web"` for whole-page web/Portal apps.
@@ -34,6 +34,13 @@ do not guess APIs or copy a version-specific component inventory.
34
34
  - Keep a bounded `AppRoot`, for example `<AppRoot style="height: 100%">`, with
35
35
  a sized host/mount container. Use its default app modal scope (`modalScope`);
36
36
  avoid overriding it to document scope in a DSM window.
37
+ - In a DSM window (`ctx.host.platform().host === 'dsm'`), give `AppRoot`
38
+ `dialog-bounds="viewport"` so Dialogs can be dragged out of the window as
39
+ DSM's own are, and connect its `activate` event to
40
+ `ctx.host.activateWindow()` so pressing a Dialog brings the window to the
41
+ front. Below 600px of viewport width the UI turns them into bottom sheets.
42
+ Keep the default `app` bounds outside DSM windows (standalone web, Portal,
43
+ previews).
37
44
  - Import only `@synopackageland/ui/themes/dsm7-embedded.css` for the template.
38
45
  The standalone `theme.css` and `dsm7.css` entrypoints affect the host page.
39
46
  - Set a unique `app.config.idPrefix` for every independent Vue app before