@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.
- package/CHANGELOG.md +80 -0
- package/README.md +1 -1
- package/dist/add.js +3 -3
- package/dist/build-spk/dsm-version.d.ts +14 -4
- package/dist/build-spk/dsm-version.js +17 -5
- package/dist/build-spk/uninstall-wizard.d.ts +2 -0
- package/dist/build-spk/uninstall-wizard.js +7 -1
- package/dist/build-spk/wizard-messages.d.ts +11 -0
- package/dist/build-spk/wizard-messages.js +11 -0
- package/dist/error-registry.js +2 -1
- package/dist/info.js +2 -1
- package/dist/portal/alias.d.ts +8 -0
- package/dist/portal/alias.js +16 -0
- package/dist/runtime/executor-digests.json +22 -0
- package/dist/runtime/executor-policy.d.ts +8 -0
- package/dist/runtime/executor-policy.js +29 -5
- package/dist/runtime/uninstall-cleanup.d.ts +5 -1
- package/dist/runtime/uninstall-cleanup.js +11 -1
- package/dist/standalone/build.js +3 -3
- package/dist/standalone/dsm-window.js +16 -3
- package/dist/standalone/inspect-spk.js +8 -4
- package/dist/standalone/manifest.js +7 -3
- package/dist/standalone/native-layout.d.ts +3 -0
- package/dist/standalone/native-layout.js +37 -5
- package/dist/standalone/native-root-window.d.ts +2 -1
- package/dist/standalone/native-root-window.js +15 -5
- package/dist/standalone/portal-window.d.ts +4 -2
- package/dist/standalone/portal-window.js +54 -12
- package/dist/standalone/portal.d.ts +4 -1
- package/dist/standalone/portal.js +8 -2
- package/dist/standalone/spk.js +2 -1
- package/package.json +3 -3
- package/skills/reference/portal.md +8 -4
- package/skills/reference/ui-json.md +5 -1
- 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}
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
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 =
|
|
22
|
-
|
|
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
|
|
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
|
|
16
|
-
* reject `{code}
|
|
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: "관리자에게 설정을
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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이 변경을 받아들이지 않았습니다. 다시 시도하거나 제어판을
|
|
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
|
|
39
|
-
* reject `{code}
|
|
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 (
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/standalone/spk.js
CHANGED
|
@@ -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
|
+
"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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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"`.
|
|
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
|