@synopackageland/cli 0.9.4 → 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 +58 -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 +11 -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 +2 -2
- package/skills/reference/portal.md +8 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,63 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.5 (2026-10-12)
|
|
4
|
+
|
|
5
|
+
Builds with SDK 0.9.6 and templates 0.9.5: `init` writes SDK v0.9.6,
|
|
6
|
+
`@synopackageland/sdk` 0.9.6 and `@synopackageland/ui` 0.9.2. The executor
|
|
7
|
+
digest table records SDK 0.9.6, whose executor registers the portal entry
|
|
8
|
+
and checks a grant's background work from the browser address it recorded,
|
|
9
|
+
so a rebuilt App carries new executor digests. The policy digest changes
|
|
10
|
+
only for an App whose executor registers its portal entry (a portal and
|
|
11
|
+
os_min_ver below 7.2): after the upgrade it asks for approval again. Other
|
|
12
|
+
Apps keep their policy digest.
|
|
13
|
+
|
|
14
|
+
繁中摘要:使用 SDK 0.9.6 與 templates 0.9.5(UI 0.9.2)。executor digest 表加入
|
|
15
|
+
SDK 0.9.6,重建的 App 會帶新的 executor digest。只有由 executor 註冊入口的
|
|
16
|
+
portal App(宣告 portal 且 os_min_ver 低於 7.2)policy digest 會變,升級後需重新
|
|
17
|
+
核准;其他 App 的 policy digest 不變。DSM 7.1 起可用 portal(executor 模式)。
|
|
18
|
+
|
|
19
|
+
- The generated DSM window stays inside the desktop: its width is at most the
|
|
20
|
+
viewport's and its height at most the viewport's less the DSM taskbar. This
|
|
21
|
+
applies to the default 960×640 and to the size DSM restores from
|
|
22
|
+
`restoreSizePos`, which may have been saved on a larger screen. Position and
|
|
23
|
+
maximized state are restored as before.
|
|
24
|
+
- Native root authorization (`createRootAccess` from
|
|
25
|
+
`@synopackageland/cli/authorization-sdk`): the first `run` after an `authorize`
|
|
26
|
+
runs the task that `authorize` just read back as exactly ours, instead of
|
|
27
|
+
reading the TaskScheduler list and task again. Any later run, a repair, and
|
|
28
|
+
a run after `remove` still read DSM first; `authorize` still reads the task
|
|
29
|
+
back after creating it and checks its identity and script.
|
|
30
|
+
- Native root authorization polls the task history every 0.5 s, then at most
|
|
31
|
+
once a second (was up to every 2 s), so a finished root task is seen up to
|
|
32
|
+
1 s sooner.
|
|
33
|
+
- A portal works from DSM 7.1-42661 on. Below 7.2-64570 (executor mode) the
|
|
34
|
+
SPK carries no web-config resource, `etc/portal.*`, alias wizard step or
|
|
35
|
+
alias lifecycle; the executor policy carries `portal: {application, alias}`
|
|
36
|
+
and the `portal.register` / `portal.unregister` operations, and the CGI is
|
|
37
|
+
built with `-X main.portal=executor`. A package name or displayname the
|
|
38
|
+
executor would refuse fails with `STANDALONE_PORTAL_ENTRY_INVALID`; below
|
|
39
|
+
7.1 `STANDALONE_PORTAL_MIN_DSM` still refuses the portal, and `add portal`
|
|
40
|
+
raises os_min_ver only to 7.1-42661. inspect reports the policy's entry
|
|
41
|
+
and flags an INFO os_min_ver below 7.1. Needs templates 0.9.5 and SDK 0.9.6.
|
|
42
|
+
- `ctx.portal` in executor mode: `status()` carries `entry`, administrators
|
|
43
|
+
get `register()` and `unregister()`, and the plain panel offers "Enable
|
|
44
|
+
entry", "Remove entry" or the alias form by entry state. New texts in nine
|
|
45
|
+
languages, including `PORTAL_FAILED`. The panel's Korean texts use the
|
|
46
|
+
formal -십시오 register.
|
|
47
|
+
- The uninstall wizard and the cleanup list of an executor-mode portal App
|
|
48
|
+
warn that a stopped or never-approved executor leaves the entry, and give
|
|
49
|
+
the root cleanup commands.
|
|
50
|
+
- native-layout: `portal: {}` or `{alias}` (DSM 7.1-42661+) writes
|
|
51
|
+
`synopkgland_portal_alias` into INFO for the product's own root helper;
|
|
52
|
+
inspect checks it against `native-layout.json`.
|
|
53
|
+
- DSM's web-config portal registration now needs `os_min_ver` 7.2-64570 or
|
|
54
|
+
later instead of 7.3.2-86009. DSM 7.1's package manager marks the web-config
|
|
55
|
+
worker Synology-only and refuses it in an unsigned SPK (error 320); synopkg
|
|
56
|
+
from DSM 7.2 on drops that mark, the only change to its worker table. The
|
|
57
|
+
7.2 floor comes from that source and is not yet verified on a 7.2 NAS.
|
|
58
|
+
inspect flags a web-config portal SPK whose INFO os_min_ver is lower, with
|
|
59
|
+
this reason.
|
|
60
|
+
|
|
3
61
|
## 0.9.4 (2026-10-12)
|
|
4
62
|
|
|
5
63
|
Builds with SDK 0.9.5 and templates 0.9.4: `init` writes SDK v0.9.5 and
|
package/README.md
CHANGED
|
@@ -72,7 +72,7 @@ Add them with `init --with …` or later with `synopkgland add <capability…>`.
|
|
|
72
72
|
| `login` | Sign users in with their DSM account, without the DSM desktop | Nobody; requires HTTPS for password sign-in |
|
|
73
73
|
| `delegate` | Move files into a user's chosen folder while the user is offline | An administrator, once |
|
|
74
74
|
| `root-ops` | Run fixed `root/*.sh` scripts for administrators | An administrator, once |
|
|
75
|
-
| `portal` | Open the app from its own Application Portal alias | An administrator sets the alias; needs `root-ops` or `delegate` and DSM 7.
|
|
75
|
+
| `portal` | Open the app from its own Application Portal alias | An administrator sets the alias; needs `root-ops` or `delegate` and DSM 7.1-42661+ (below 7.2-64570 the executor registers the entry) |
|
|
76
76
|
|
|
77
77
|
Delegate is implemented; its default operation is `files.moveFromStaging`.
|
|
78
78
|
Device lifecycle acceptance on real hardware is still pending for delegate and root operations.
|
package/dist/add.js
CHANGED
|
@@ -6,7 +6,7 @@ import { capabilityNames, capabilitySettings } from './capabilities.js';
|
|
|
6
6
|
import { initFromTemplate, nativeCapabilitySettings } from './init.js';
|
|
7
7
|
import { templatesRoot } from './template-paths.js';
|
|
8
8
|
import { borrowedTarget, capabilityOverlays } from './capability-overlays.js';
|
|
9
|
-
import { DEFAULT_MIN_DSM, isDsmAtLeast,
|
|
9
|
+
import { DEFAULT_MIN_DSM, isDsmAtLeast, PORTAL_EXECUTOR_MIN_DSM } from './build-spk/dsm-version.js';
|
|
10
10
|
import { loadStandaloneManifest } from './standalone/manifest.js';
|
|
11
11
|
import { PORTAL_REQUIRES_EXECUTOR } from './portal/alias.js';
|
|
12
12
|
function files(root, dir = root) {
|
|
@@ -51,7 +51,7 @@ function withNotes(result, notes) {
|
|
|
51
51
|
}
|
|
52
52
|
/**
|
|
53
53
|
* `portal: {}` in dsm-package.yaml; the Go and binary starters already carry the UI and backend side.
|
|
54
|
-
* A lower os_min_ver is raised to PORTAL_MIN_DSM
|
|
54
|
+
* A lower os_min_ver is raised to PORTAL_EXECUTOR_MIN_DSM; below PORTAL_MIN_DSM the executor registers the entry.
|
|
55
55
|
*/
|
|
56
56
|
function addPortal(root, result) {
|
|
57
57
|
const loaded = loadStandaloneManifest(root);
|
|
@@ -64,7 +64,7 @@ function addPortal(root, result) {
|
|
|
64
64
|
const manifestPath = join(root, 'dsm-package.yaml');
|
|
65
65
|
const doc = parseDocument(readFileSync(manifestPath, 'utf8'));
|
|
66
66
|
doc.set('portal', doc.createNode({}, { flow: true }));
|
|
67
|
-
const notes = raiseOsMinVer(doc, loaded.manifest.os_min_ver,
|
|
67
|
+
const notes = raiseOsMinVer(doc, loaded.manifest.os_min_ver, PORTAL_EXECUTOR_MIN_DSM, 'portal');
|
|
68
68
|
writeFileSync(manifestPath, String(doc));
|
|
69
69
|
return withNotes({ ...result, added: [...result.added, 'portal'] }, notes);
|
|
70
70
|
}
|
|
@@ -8,11 +8,21 @@ export declare const DEFAULT_MIN_DSM: string;
|
|
|
8
8
|
export declare const CONTAINER_MIN_DSM: string;
|
|
9
9
|
/**
|
|
10
10
|
* DSM registers an Application Portal through the package's web-config
|
|
11
|
-
* app-portal resource. DSM 7.1
|
|
12
|
-
* package (error 320, "internal
|
|
13
|
-
* 7.2
|
|
11
|
+
* app-portal resource. DSM 7.1's package manager marks the web-config worker
|
|
12
|
+
* Synology-only and refuses it in an unsigned package (error 320, "internal
|
|
13
|
+
* worker"); synopkg from DSM 7.2 on does not. That 7.2 floor comes from the
|
|
14
|
+
* source and is not yet verified on a 7.2 NAS.
|
|
14
15
|
*/
|
|
15
|
-
export declare const PORTAL_MIN_DSM
|
|
16
|
+
export declare const PORTAL_MIN_DSM: string;
|
|
17
|
+
/** Why DSM below PORTAL_MIN_DSM refuses a package that declares the web-config portal resource. */
|
|
18
|
+
export declare const PORTAL_MIN_DSM_REASON = "DSM 7.1 \u628A web-config \u5217\u70BA Synology \u5C08\u7528 worker\uFF0C\u4EE5\u932F\u8AA4 320 \u62D2\u88DD\u5E36 portal\uFF08web-config app-portal\uFF09\u7684\u672A\u7C3D\u7AE0\u5957\u4EF6\uFF1B7.2 \u8D77\u958B\u653E\u662F\u4F9D synopkg \u539F\u59CB\u78BC\u5224\u65B7\uFF0C\u5C1A\u672A\u5BE6\u6A5F\u9A57\u8B49";
|
|
19
|
+
/**
|
|
20
|
+
* Below {@link PORTAL_MIN_DSM} the root executor registers the portal entry in
|
|
21
|
+
* DSM's app.d instead; that file format is verified from DSM 7.1 on.
|
|
22
|
+
*/
|
|
23
|
+
export declare const PORTAL_EXECUTOR_MIN_DSM: string;
|
|
24
|
+
/** `true` when a portal package for `osMinVer` has its executor register the entry instead of web-config. */
|
|
25
|
+
export declare function portalByExecutor(osMinVer: string): boolean;
|
|
16
26
|
/** Why a portal package cannot declare `value`, and how to fix it; `field` names the portal key. */
|
|
17
27
|
export declare function portalMinDsmMessage(value: string, field: string): string;
|
|
18
28
|
/** `true` when `value` is not older than `floor`, comparing version parts then build. */
|
|
@@ -15,14 +15,26 @@ export const DEFAULT_MIN_DSM = FIRST_BUILD["7.1"];
|
|
|
15
15
|
export const CONTAINER_MIN_DSM = FIRST_BUILD["7.2.1"];
|
|
16
16
|
/**
|
|
17
17
|
* DSM registers an Application Portal through the package's web-config
|
|
18
|
-
* app-portal resource. DSM 7.1
|
|
19
|
-
* package (error 320, "internal
|
|
20
|
-
* 7.2
|
|
18
|
+
* app-portal resource. DSM 7.1's package manager marks the web-config worker
|
|
19
|
+
* Synology-only and refuses it in an unsigned package (error 320, "internal
|
|
20
|
+
* worker"); synopkg from DSM 7.2 on does not. That 7.2 floor comes from the
|
|
21
|
+
* source and is not yet verified on a 7.2 NAS.
|
|
21
22
|
*/
|
|
22
|
-
export const PORTAL_MIN_DSM = "7.
|
|
23
|
+
export const PORTAL_MIN_DSM = FIRST_BUILD["7.2"];
|
|
24
|
+
/** Why DSM below PORTAL_MIN_DSM refuses a package that declares the web-config portal resource. */
|
|
25
|
+
export const PORTAL_MIN_DSM_REASON = "DSM 7.1 把 web-config 列為 Synology 專用 worker,以錯誤 320 拒裝帶 portal(web-config app-portal)的未簽章套件;7.2 起開放是依 synopkg 原始碼判斷,尚未實機驗證";
|
|
26
|
+
/**
|
|
27
|
+
* Below {@link PORTAL_MIN_DSM} the root executor registers the portal entry in
|
|
28
|
+
* DSM's app.d instead; that file format is verified from DSM 7.1 on.
|
|
29
|
+
*/
|
|
30
|
+
export const PORTAL_EXECUTOR_MIN_DSM = FIRST_BUILD["7.1"];
|
|
31
|
+
/** `true` when a portal package for `osMinVer` has its executor register the entry instead of web-config. */
|
|
32
|
+
export function portalByExecutor(osMinVer) {
|
|
33
|
+
return !isDsmAtLeast(osMinVer, PORTAL_MIN_DSM);
|
|
34
|
+
}
|
|
23
35
|
/** Why a portal package cannot declare `value`, and how to fix it; `field` names the portal key. */
|
|
24
36
|
export function portalMinDsmMessage(value, field) {
|
|
25
|
-
return `${field} 需要 DSM ${
|
|
37
|
+
return `${field} 需要 DSM ${PORTAL_EXECUTOR_MIN_DSM} 以上,os_min_ver 是「${value}」。DSM ${PORTAL_MIN_DSM} 以前由 executor 寫入 DSM 的 app.d 註冊入口,這個格式只在 DSM 7.1 起驗證過。請把 os_min_ver 提高到 ${PORTAL_EXECUTOR_MIN_DSM} 以上,或移除 ${field}。`;
|
|
26
38
|
}
|
|
27
39
|
/** `true` when `value` is not older than `floor`, comparing version parts then build. */
|
|
28
40
|
export function isDsmAtLeast(value, floor) {
|
|
@@ -6,6 +6,8 @@ interface UninstallWizardOptions {
|
|
|
6
6
|
executor?: boolean;
|
|
7
7
|
/** The App declares a Login Portal alias (web-config app-portal); only the executor tier can. */
|
|
8
8
|
portal?: boolean;
|
|
9
|
+
/** Below DSM 7.2 the executor registered the App's Application Portal entry. */
|
|
10
|
+
portalEntry?: boolean;
|
|
9
11
|
}
|
|
10
12
|
/** `uninstall_uifile` (English) plus one `uninstall_uifile_<dsm language>` per locale. */
|
|
11
13
|
export declare function renderUninstallWizardFiles(packageName: string, kind: UninstallWizardKind, options?: UninstallWizardOptions): Record<string, string>;
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
import { portalEntryCleanupCommands } from "../runtime/uninstall-cleanup.js";
|
|
1
2
|
import { renderWizardFiles } from "./wizard-i18n.js";
|
|
2
3
|
/** The key DSM's built-in packages use for their "also remove my data" box. */
|
|
3
4
|
export const REMOVE_ADDITIONAL_KEY = "pkgwizard_remove_additional";
|
|
4
5
|
function list(items) {
|
|
5
6
|
return `<ul class="synopkg-ul-list">${items.map((item) => `<li>${item}</li>`).join("")}</ul>`;
|
|
6
7
|
}
|
|
7
|
-
|
|
8
|
+
const html = (text) => text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
9
|
+
function renderUninstallWizard(t, packageName, kind, { executor = false, portal = false, portalEntry = false }) {
|
|
8
10
|
const vars = { package: packageName };
|
|
9
11
|
return [{
|
|
10
12
|
step_title: t("uninstall.step"),
|
|
@@ -23,6 +25,10 @@ function renderUninstallWizard(t, packageName, kind, { executor = false, portal
|
|
|
23
25
|
...(executor ? [{ desc: t("uninstall.executor", vars) }] : []),
|
|
24
26
|
{ desc: t(executor ? "uninstall.appPrivilege.auto" : "uninstall.appPrivilege", vars) },
|
|
25
27
|
...(executor && portal ? [{ desc: t("uninstall.portal.auto", vars) }] : []),
|
|
28
|
+
...(executor && portalEntry ? [
|
|
29
|
+
{ desc: t("uninstall.portalEntry", vars) },
|
|
30
|
+
{ desc: portalEntryCleanupCommands(packageName).map((line) => `<code>${html(line)}</code>`).join("<br>") },
|
|
31
|
+
] : []),
|
|
26
32
|
{ desc: t("uninstall.backup") },
|
|
27
33
|
{
|
|
28
34
|
type: "multiselect",
|
|
@@ -417,6 +417,17 @@ export declare const WIZARD_MESSAGES: {
|
|
|
417
417
|
es: string;
|
|
418
418
|
it: string;
|
|
419
419
|
};
|
|
420
|
+
"uninstall.portalEntry": {
|
|
421
|
+
en: string;
|
|
422
|
+
"zh-TW": string;
|
|
423
|
+
"zh-CN": string;
|
|
424
|
+
ja: string;
|
|
425
|
+
ko: string;
|
|
426
|
+
de: string;
|
|
427
|
+
fr: string;
|
|
428
|
+
es: string;
|
|
429
|
+
it: string;
|
|
430
|
+
};
|
|
420
431
|
"uninstall.keep.native": {
|
|
421
432
|
en: string;
|
|
422
433
|
"zh-TW": string;
|
|
@@ -417,6 +417,17 @@ export const WIZARD_MESSAGES = {
|
|
|
417
417
|
es: "Después de que DSM elimine el alias del Portal de inicio de sesión de {package}, la desinstalación recarga automáticamente el servidor web, de modo que el alias deja de funcionar al instante. Solo si esta recarga falla, como indica la entrada synopkgland-executor del registro del sistema, un administrador debe guardar un alias de aplicación en Panel de control > Portal de inicio de sesión > Aplicaciones, lo que recarga el servidor web, o reiniciar el NAS.",
|
|
418
418
|
it: "Dopo che DSM ha rimosso l’alias del Portale di accesso di {package}, la disinstallazione ricarica automaticamente il server web, così l’alias smette subito di funzionare. Solo se questo ricaricamento non riesce, come segnala la voce synopkgland-executor del registro di sistema, un amministratore deve salvare un alias di applicazione in Pannello di controllo > Portale di accesso > Applicazioni, che ricarica il server web, oppure riavviare il NAS.",
|
|
419
419
|
},
|
|
420
|
+
"uninstall.portalEntry": {
|
|
421
|
+
en: "Uninstall asks the root executor to remove the Application Portal entry of {package}. If the executor is not running or was never approved, uninstall does not remove the entry; an administrator then runs these commands as root:",
|
|
422
|
+
"zh-TW": "解除安裝會由 root executor 移除 {package} 的應用程式入口。如果 executor 未執行或從未核准,解除安裝不會移除應用程式入口,管理員需以 root 執行下列指令:",
|
|
423
|
+
"zh-CN": "卸载会由 root executor 移除 {package} 的应用程序门户入口。如果 executor 未运行或从未核准,卸载不会移除应用程序门户入口,管理员需以 root 执行下列命令:",
|
|
424
|
+
ja: "アンインストール時に root executor が {package} のアプリケーションポータルのエントリを削除します。executor が実行されていない、または承認されていない場合、アンインストールではエントリが削除されないため、管理者が root で次のコマンドを実行してください:",
|
|
425
|
+
ko: "제거 시 root executor가 {package}의 응용 프로그램 포털 항목을 제거합니다. executor가 실행 중이 아니거나 승인된 적이 없으면 제거 과정에서 항목이 제거되지 않으므로 관리자가 root로 다음 명령을 실행하십시오.",
|
|
426
|
+
de: "Bei der Deinstallation entfernt der Root-Executor den Eintrag von {package} im Anwendungsportal. Läuft der Executor nicht oder wurde er nie genehmigt, entfernt die Deinstallation den Eintrag nicht; ein Administrator führt dann diese Befehle als root aus:",
|
|
427
|
+
fr: "La désinstallation demande à l’executor root de supprimer l’entrée de {package} dans le Portail d’application. Si l’executor ne s’exécute pas ou n’a jamais été approuvé, la désinstallation ne supprime pas l’entrée ; un administrateur exécute alors ces commandes en tant que root :",
|
|
428
|
+
es: "La desinstalación pide al executor root que elimine la entrada de {package} del Portal de aplicaciones. Si el executor no se está ejecutando o nunca se aprobó, la desinstalación no elimina la entrada; un administrador debe ejecutar estos comandos como root:",
|
|
429
|
+
it: "La disinstallazione chiede all’executor root di rimuovere la voce di {package} dal Portale applicazioni. Se l’executor non è in esecuzione o non è mai stato approvato, la disinstallazione non rimuove la voce; un amministratore deve eseguire questi comandi come root:",
|
|
430
|
+
},
|
|
420
431
|
"uninstall.keep.native": {
|
|
421
432
|
en: "Never deleted: Shared Folders, the package account, and other apps' data.",
|
|
422
433
|
"zh-TW": "不會被刪除:共用資料夾(Shared Folder)、套件帳號,以及其他 App 的資料。",
|
package/dist/error-registry.js
CHANGED
|
@@ -649,7 +649,8 @@ for (const [code, description, suggestion] of [
|
|
|
649
649
|
ERROR_REGISTRY[code] = { code, description, suggestion };
|
|
650
650
|
ERROR_REGISTRY["ADD_LAYOUT_UNSUPPORTED"] = { "code": "ADD_LAYOUT_UNSUPPORTED", "description": "SDK overlay 需要標準 backend 目錄。", "suggestion": "使用 backend.dir: backend,或手動合併 SDK overlay。" };
|
|
651
651
|
ERROR_REGISTRY["MIN_DSM_CONTAINER_FLOOR"] = { "code": "MIN_DSM_CONTAINER_FLOOR", "description": "Compose 最低 DSM 版本過低。", "suggestion": "Compose 需要 DSM 7.2.1-69057 或以上。" };
|
|
652
|
-
ERROR_REGISTRY.
|
|
652
|
+
ERROR_REGISTRY.STANDALONE_PORTAL_ENTRY_INVALID = { "code": "STANDALONE_PORTAL_ENTRY_INVALID", "description": "os_min_ver 低於 DSM 7.2-64570 時由 executor 註冊 portal,套件名稱或 displayname 不符合它的規則。", "suggestion": "套件名稱只能用英數字,以 . _ - 分隔,最多 64 字;displayname 1–64 個字,不可含控制字元。" };
|
|
653
|
+
ERROR_REGISTRY.STANDALONE_PORTAL_MIN_DSM = { "code": "STANDALONE_PORTAL_MIN_DSM", "description": "宣告 portal 的套件 os_min_ver 低於 DSM 7.1-42661。", "suggestion": "DSM 7.2-64570 以前由 executor 寫入 DSM 的 app.d 註冊入口,這個格式只在 DSM 7.1 起驗證過。把 os_min_ver 提高到 7.1-42661 以上,或移除 portal。" };
|
|
653
654
|
ERROR_REGISTRY["MIN_DSM_INVALID"] = { "code": "MIN_DSM_INVALID", "description": "最低 DSM 版本格式不合法。", "suggestion": "指定 7.x release 或完整 7.x-build。" };
|
|
654
655
|
ERROR_REGISTRY["NOTICE_UNSAFE"] = { "code": "NOTICE_UNSAFE", "description": "授權檔案不符合一般檔案限制。", "suggestion": "移除 symlink、特殊檔與超大授權檔案。" };
|
|
655
656
|
ERROR_REGISTRY["NOTICE_MISSING"] = { "code": "NOTICE_MISSING", "description": "缺少第三方授權資訊。", "suggestion": "重新安裝模板依賴並產生 third-party notices。" };
|
package/dist/info.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
import { portalByExecutor } from "./build-spk/dsm-version.js";
|
|
3
4
|
import { loadContract } from "./contract/load.js";
|
|
4
5
|
import { appOriginFromContract } from "./host/open-url.js";
|
|
5
6
|
import { resolveDeployIdentity } from "./deploy/identity.js";
|
|
@@ -169,7 +170,7 @@ export async function standaloneInfo(options) {
|
|
|
169
170
|
...state,
|
|
170
171
|
capabilities,
|
|
171
172
|
...(executor ? { executor } : {}),
|
|
172
|
-
uninstallCleanup: uninstallCleanup(m.package, { executor: Boolean(m.capabilities.rootOps || m.capabilities.delegate), portal: Boolean(m.portal) }),
|
|
173
|
+
uninstallCleanup: uninstallCleanup(m.package, { executor: Boolean(m.capabilities.rootOps || m.capabilities.delegate), portal: Boolean(m.portal && !portalByExecutor(m.os_min_ver)), portalEntry: Boolean(m.portal && portalByExecutor(m.os_min_ver)) }),
|
|
173
174
|
};
|
|
174
175
|
}
|
|
175
176
|
/** What the installed App's portal.status says; DSM owns the alias, an administrator sets it. */
|
package/dist/portal/alias.d.ts
CHANGED
|
@@ -25,3 +25,11 @@ export declare function isPortalAlias(value: unknown): value is string;
|
|
|
25
25
|
export declare function defaultPortalAlias(packageId: string): string;
|
|
26
26
|
/** `{}` or `{alias}`; undefined for any other shape. */
|
|
27
27
|
export declare function readPortalDeclaration(value: unknown, packageId: string): PortalDeclaration | undefined;
|
|
28
|
+
/** The Application Portal entry a root executor registers in DSM's app.d, as its approved policy states it. */
|
|
29
|
+
export interface PortalEntry {
|
|
30
|
+
/** Control Panel display name. */
|
|
31
|
+
application: string;
|
|
32
|
+
alias: string;
|
|
33
|
+
}
|
|
34
|
+
/** Why the executor would refuse this entry; undefined when it accepts it. */
|
|
35
|
+
export declare function portalEntryProblem(packageId: string, entry: PortalEntry): string | undefined;
|
package/dist/portal/alias.js
CHANGED
|
@@ -41,3 +41,19 @@ export function readPortalDeclaration(value, packageId) {
|
|
|
41
41
|
return { alias: defaultPortalAlias(packageId) };
|
|
42
42
|
return isPortalAlias(alias) ? { alias } : undefined;
|
|
43
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* The executor's own rules (executor/portal_entry.go): the package name and
|
|
46
|
+
* alias become DSM file and nginx names, the application a JSON string.
|
|
47
|
+
*/
|
|
48
|
+
const ENTRY_PACKAGE = /^[A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*$/;
|
|
49
|
+
const ENTRY_ALIAS = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
50
|
+
/** Why the executor would refuse this entry; undefined when it accepts it. */
|
|
51
|
+
export function portalEntryProblem(packageId, entry) {
|
|
52
|
+
if (packageId.length > 64 || !ENTRY_PACKAGE.test(packageId))
|
|
53
|
+
return `套件名稱「${packageId}」不能當作 Application Portal 的檔名:只能用英數字,以 . _ - 分隔,最多 64 字。`;
|
|
54
|
+
if (!ENTRY_ALIAS.test(entry.alias))
|
|
55
|
+
return `portal 別名「${entry.alias}」不能當作 Application Portal 的路徑。`;
|
|
56
|
+
if (!entry.application || [...entry.application].length > 64 || /\p{Cc}/u.test(entry.application))
|
|
57
|
+
return `displayname「${entry.application}」不能當作 Application Portal 的名稱:1–64 個字,不可含控制字元。`;
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
@@ -64,5 +64,16 @@
|
|
|
64
64
|
"armv7": "b65a14f0fc77a1d3221c7dc6781eb66059aa200956b094d58a62bd1cad10f116"
|
|
65
65
|
},
|
|
66
66
|
"goToolchain": "go1.26.9"
|
|
67
|
+
},
|
|
68
|
+
"0.9.6": {
|
|
69
|
+
"x86_64": "4241dfebd2b819c2566eca062ee0021b99e7f86bbe4703f50833874f79b3cf54",
|
|
70
|
+
"aarch64": "923514aca8cf6576c6c52c737d53c2c7cc4bfcec40591dcec995005be41add95",
|
|
71
|
+
"armv7": "df959648ac8e6201f1cf9b86b57730c09ba1afb7ecc151ba2f530ad15dd90ccf",
|
|
72
|
+
"lifecycle": {
|
|
73
|
+
"x86_64": "819459580135e016184ad80c258350d57194d6134e8aa4cf1caf5237085b1004",
|
|
74
|
+
"aarch64": "5abcfc21403e84a0af15d972bcb819ac6fedfd9aaf5898ddc8c4bb06130f9f59",
|
|
75
|
+
"armv7": "b65a14f0fc77a1d3221c7dc6781eb66059aa200956b094d58a62bd1cad10f116"
|
|
76
|
+
},
|
|
77
|
+
"goToolchain": "go1.26.9"
|
|
67
78
|
}
|
|
68
79
|
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import type { RootOp } from '../standalone/root-ops.js';
|
|
2
2
|
import type { EntryMode } from '../standalone/manifest.js';
|
|
3
|
+
import { type PortalDeclaration, type PortalEntry } from '../portal/alias.js';
|
|
3
4
|
export declare const FILE_OPERATIONS: readonly ["files.list", "files.info", "files.createFolder", "files.checkPermission", "files.write", "files.read", "files.rename", "files.copy", "files.move", "files.delete"];
|
|
4
5
|
/** Started File Station tasks are followed with these; they are implied, never declared. */
|
|
5
6
|
export declare const FILE_TASK_OPERATIONS: readonly ["files.taskStatus", "files.taskCancel"];
|
|
7
|
+
/** An executor that registers the portal entry serves both, and only with a policy `portal`. */
|
|
8
|
+
export declare const PORTAL_OPERATIONS: readonly ["portal.register", "portal.unregister"];
|
|
6
9
|
export interface DelegateCapability {
|
|
7
10
|
operations: string[];
|
|
8
11
|
scope: string;
|
|
@@ -21,6 +24,8 @@ export interface ExecutorPolicy {
|
|
|
21
24
|
lifetime?: 'until-revoked';
|
|
22
25
|
package: string;
|
|
23
26
|
operations: string[];
|
|
27
|
+
/** Below DSM 7.2 the executor registers this Application Portal entry; DSM 7.1 refuses web-config for unsigned packages. */
|
|
28
|
+
portal?: PortalEntry;
|
|
24
29
|
root: Record<string, string>;
|
|
25
30
|
}
|
|
26
31
|
/** The exact policy file bytes whose sha256 is the policy pin. */
|
|
@@ -34,6 +39,9 @@ export declare function executorPolicy(m: {
|
|
|
34
39
|
rootOps?: true;
|
|
35
40
|
};
|
|
36
41
|
rootOps?: RootOp[];
|
|
42
|
+
portal?: PortalDeclaration;
|
|
43
|
+
os_min_ver?: string;
|
|
44
|
+
displayname?: string;
|
|
37
45
|
}): ExecutorPolicy;
|
|
38
46
|
/** Inventory rejects malformed extensions even if a caller recomputes every pin. */
|
|
39
47
|
export declare function validExecutorPolicy(value: unknown, pkg: string): value is ExecutorPolicy;
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { MOVE_FROM_STAGING, DELEGATE_EXTENSION_OPERATIONS } from './delegate-contract.js';
|
|
3
|
+
import { portalByExecutor } from '../build-spk/dsm-version.js';
|
|
4
|
+
import { portalEntryProblem } from '../portal/alias.js';
|
|
3
5
|
export const FILE_OPERATIONS = ['files.list', 'files.info', 'files.createFolder', 'files.checkPermission', 'files.write', 'files.read', 'files.rename', 'files.copy', 'files.move', 'files.delete'];
|
|
4
6
|
/** Started File Station tasks are followed with these; they are implied, never declared. */
|
|
5
7
|
export const FILE_TASK_OPERATIONS = ['files.taskStatus', 'files.taskCancel'];
|
|
6
8
|
const TASK_STARTING_OPERATIONS = ['files.copy', 'files.move', 'files.delete'];
|
|
9
|
+
/** An executor that registers the portal entry serves both, and only with a policy `portal`. */
|
|
10
|
+
export const PORTAL_OPERATIONS = ['portal.register', 'portal.unregister'];
|
|
7
11
|
export function validDelegate(value) {
|
|
8
12
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
9
13
|
return false;
|
|
@@ -24,7 +28,8 @@ export const executorPolicyText = (policy) => JSON.stringify(policy) + '\n';
|
|
|
24
28
|
export function executorPolicy(m) {
|
|
25
29
|
if (m.capabilities.delegate && !validDelegate(m.capabilities.delegate))
|
|
26
30
|
throw new Error('[EXECUTOR_POLICY] Invalid delegate operations or scope.');
|
|
27
|
-
const
|
|
31
|
+
const portal = executorPortal(m);
|
|
32
|
+
const operations = ['status', ...(portal ? PORTAL_OPERATIONS : [])];
|
|
28
33
|
if (m.capabilities.delegate) {
|
|
29
34
|
operations.push('delegate.create', 'delegate.list', 'delegate.revoke', ...m.capabilities.delegate.operations);
|
|
30
35
|
if (m.capabilities.delegate.operations.some(op => TASK_STARTING_OPERATIONS.includes(op)))
|
|
@@ -41,21 +46,33 @@ export function executorPolicy(m) {
|
|
|
41
46
|
const d = m.capabilities.delegate;
|
|
42
47
|
return { v: 1, package: m.package, app: `SYNO.SDS.${m.package}.Instance`, ...(m.entry ? { entry: m.entry } : {}), ...(d ? { scope: d.scope } : {}),
|
|
43
48
|
...(d?.lifetime ? { destinations: [...d.destinations].sort(), lifetime: d.lifetime } : {}),
|
|
44
|
-
operations: [...new Set(operations)].sort(), root };
|
|
49
|
+
operations: [...new Set(operations)].sort(), ...(portal ? { portal } : {}), root };
|
|
50
|
+
}
|
|
51
|
+
/** The entry the executor registers: a portal App whose os_min_ver is below DSM 7.2. */
|
|
52
|
+
function executorPortal(m) {
|
|
53
|
+
if (!m.portal || !m.os_min_ver || !portalByExecutor(m.os_min_ver))
|
|
54
|
+
return undefined;
|
|
55
|
+
const entry = { application: m.displayname ?? '', alias: m.portal.alias };
|
|
56
|
+
const problem = portalEntryProblem(m.package, entry);
|
|
57
|
+
if (problem)
|
|
58
|
+
throw new Error(`[EXECUTOR_POLICY] ${problem}`);
|
|
59
|
+
return entry;
|
|
45
60
|
}
|
|
46
61
|
/** Inventory rejects malformed extensions even if a caller recomputes every pin. */
|
|
47
62
|
export function validExecutorPolicy(value, pkg) {
|
|
48
63
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
49
64
|
return false;
|
|
50
65
|
const p = value;
|
|
51
|
-
if (Object.keys(p).some(key => !['v', 'package', 'app', 'entry', 'scope', 'destinations', 'lifetime', 'operations', 'root'].includes(key))
|
|
66
|
+
if (Object.keys(p).some(key => !['v', 'package', 'app', 'entry', 'scope', 'destinations', 'lifetime', 'operations', 'portal', 'root'].includes(key))
|
|
52
67
|
|| p.v !== 1 || p.package !== pkg || p.app !== `SYNO.SDS.${pkg}.Instance`
|
|
53
68
|
|| 'entry' in p && p.entry !== 'admin-only' && p.entry !== 'app-privilege'
|
|
54
69
|
|| !Array.isArray(p.operations) || !p.operations.includes('status') || new Set(p.operations).size !== p.operations.length
|
|
55
70
|
|| !p.root || typeof p.root !== 'object' || Array.isArray(p.root))
|
|
56
71
|
return false;
|
|
57
72
|
const base = ['status', 'root.run', 'delegate.create', 'delegate.list', 'delegate.revoke'];
|
|
58
|
-
if (p.operations.some(op => ![...base, ...FILE_OPERATIONS, ...FILE_TASK_OPERATIONS, ...DELEGATE_EXTENSION_OPERATIONS].includes(op)))
|
|
73
|
+
if (p.operations.some(op => ![...base, ...FILE_OPERATIONS, ...FILE_TASK_OPERATIONS, ...DELEGATE_EXTENSION_OPERATIONS, ...PORTAL_OPERATIONS].includes(op)))
|
|
74
|
+
return false;
|
|
75
|
+
if (!PORTAL_OPERATIONS.every(op => p.operations.includes(op) === (p.portal !== undefined)) || p.portal !== undefined && !validPortalEntry(p.portal, pkg))
|
|
59
76
|
return false;
|
|
60
77
|
const delegated = p.operations.some(op => op.startsWith('files.') || op.startsWith('delegate.'));
|
|
61
78
|
if (!delegated)
|
|
@@ -65,5 +82,12 @@ export function validExecutorPolicy(value, pkg) {
|
|
|
65
82
|
if (!validDelegate(capability))
|
|
66
83
|
return false;
|
|
67
84
|
const expected = executorPolicy({ package: pkg, capabilities: { delegate: capability, ...(p.operations.includes('root.run') ? { rootOps: true } : {}) } }).operations;
|
|
68
|
-
|
|
85
|
+
const declared = p.operations.filter(op => !PORTAL_OPERATIONS.includes(op));
|
|
86
|
+
return expected.length === declared.length && expected.every(op => declared.includes(op));
|
|
87
|
+
}
|
|
88
|
+
function validPortalEntry(value, pkg) {
|
|
89
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(key => key !== 'application' && key !== 'alias'))
|
|
90
|
+
return false;
|
|
91
|
+
const { application, alias } = value;
|
|
92
|
+
return typeof application === 'string' && typeof alias === 'string' && portalEntryProblem(pkg, { application, alias }) === undefined;
|
|
69
93
|
}
|
|
@@ -9,5 +9,9 @@ export interface UninstallCleanupOptions {
|
|
|
9
9
|
executor?: boolean;
|
|
10
10
|
/** The App declares a Login Portal alias; only the executor tier can. */
|
|
11
11
|
portal?: boolean;
|
|
12
|
+
/** Below DSM 7.2 the executor registered the App's Application Portal entry. */
|
|
13
|
+
portalEntry?: boolean;
|
|
12
14
|
}
|
|
13
|
-
|
|
15
|
+
/** The root commands that remove an Application Portal entry the executor could not unregister. */
|
|
16
|
+
export declare function portalEntryCleanupCommands(pkg: string): string[];
|
|
17
|
+
export declare function uninstallCleanup(pkg: string, { executor, portal, portalEntry }?: UninstallCleanupOptions): UninstallCleanupItem[];
|
|
@@ -1,5 +1,14 @@
|
|
|
1
|
+
/** The root commands that remove an Application Portal entry the executor could not unregister. */
|
|
2
|
+
export function portalEntryCleanupCommands(pkg) {
|
|
3
|
+
return [
|
|
4
|
+
`rm -f /usr/syno/etc/www/app.d/${pkg}.json /usr/syno/etc/www/app.d/${pkg}.mustache /usr/syno/etc/packages/${pkg}/AppPortal.json`,
|
|
5
|
+
`for c in dsm www alias .alias; do n=$c.syno-app-portal.${pkg}.conf; synow3tool --disable-nginx-confd=$n; synow3tool --unreg-nginx-confd=$n; done`,
|
|
6
|
+
`for c in server .server; do n=$c.syno-app-portal.${pkg}.conf; synow3tool --disable-nginx-sites=$n; synow3tool --unreg-nginx-sites=$n; done`,
|
|
7
|
+
'nginx -t && synow3tool --nginx=reload',
|
|
8
|
+
];
|
|
9
|
+
}
|
|
1
10
|
const AUTOMATIC_FAILED = 'Only if automatic cleanup failed after uninstall; see the system log entry synopkgland-executor.';
|
|
2
|
-
export function uninstallCleanup(pkg, { executor = false, portal = false } = {}) {
|
|
11
|
+
export function uninstallCleanup(pkg, { executor = false, portal = false, portalEntry = false } = {}) {
|
|
3
12
|
const items = [
|
|
4
13
|
{ target: `@appdata/${pkg}, @appconf/${pkg}, @apphome/${pkg}`, condition: 'After uninstall, if retained data or empty DSM directories are no longer needed.', action: 'Back up retained data; an administrator may remove only these package-specific directories. The delete-data checkbox empties their contents and preserves DSM directory roots.' },
|
|
5
14
|
{ target: `@appshare/${pkg}, /usr/syno/etc/packages/${pkg}`, condition: 'After uninstall, if DSM leaves package infrastructure or a package-config symlink.', action: 'Administrator: inspect these package-specific paths and remove unused infrastructure; do not follow the symlink to delete shared data.' },
|
|
@@ -8,6 +17,7 @@ export function uninstallCleanup(pkg, { executor = false, portal = false } = {})
|
|
|
8
17
|
? { target: `SYNO.SDS.${pkg}.Instance rules in Application Privileges`, condition: AUTOMATIC_FAILED, action: 'The root executor removes these rules automatically through the Application Privileges API on uninstall. Administrator: remove surviving rules in Control Panel > Application Privileges; DSM would apply them again to a later install under the same name.' }
|
|
9
18
|
: { target: `SYNO.SDS.${pkg}.Instance rules in Application Privileges`, condition: 'After uninstall, if this package will not be reinstalled.', action: 'DSM keeps these rules in /etc/synoappprivilege.db and applies them again to a later install under the same name. Administrator: remove them in Control Panel > Application Privileges.' },
|
|
10
19
|
...(executor && portal ? [{ target: `${pkg} Login Portal alias`, condition: AUTOMATIC_FAILED, action: 'DSM removes the alias configuration without reloading its web server; the root executor then reloads the web server automatically. Administrator: if the alias still opens a DSM page, save an application alias in Control Panel > Login Portal > Applications to reload it, or restart the NAS.' }] : []),
|
|
20
|
+
...(executor && portalEntry ? [{ target: `${pkg} Application Portal entry`, condition: 'After uninstall, if the executor was not running or was never approved, so it could not remove the entry.', action: `Administrator, as root: ${portalEntryCleanupCommands(pkg).join('; ')}` }] : []),
|
|
11
21
|
{ target: `${pkg} package account/group`, condition: 'After uninstall, only if this package will not be reinstalled and the identity is unused.', action: 'Administrator: review DSM internal account/group and associated ACLs before cleanup. Package scripts do not delete system identities.' },
|
|
12
22
|
];
|
|
13
23
|
if (executor)
|
package/dist/standalone/build.js
CHANGED
|
@@ -4,7 +4,7 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { goBuildEnv } from "../build-spk/dsm-arch.js";
|
|
7
|
-
import { resolveMinDsm, DEFAULT_MIN_DSM, isDsmAtLeast,
|
|
7
|
+
import { resolveMinDsm, DEFAULT_MIN_DSM, isDsmAtLeast, PORTAL_EXECUTOR_MIN_DSM, portalByExecutor, portalMinDsmMessage } from "../build-spk/dsm-version.js";
|
|
8
8
|
import { checkCgiBinary } from "./binary.js";
|
|
9
9
|
import { loadStandaloneManifest } from "./manifest.js";
|
|
10
10
|
import { assembleStandaloneSpk, StandaloneBuildError } from "./spk.js";
|
|
@@ -80,7 +80,7 @@ export async function buildStandalone(options) {
|
|
|
80
80
|
if (manifest.capabilities.rootOps === true && !isDsmAtLeast(manifest.os_min_ver, DEFAULT_MIN_DSM)) {
|
|
81
81
|
throw new StandaloneBuildError("STANDALONE_OS_MIN_VER_INVALID", `root templates require DSM ${DEFAULT_MIN_DSM} or later.`);
|
|
82
82
|
}
|
|
83
|
-
if (manifest.portal && !isDsmAtLeast(manifest.os_min_ver,
|
|
83
|
+
if (manifest.portal && !isDsmAtLeast(manifest.os_min_ver, PORTAL_EXECUTOR_MIN_DSM)) {
|
|
84
84
|
throw new StandaloneBuildError("STANDALONE_PORTAL_MIN_DSM", `[STANDALONE_PORTAL_MIN_DSM] ${portalMinDsmMessage(manifest.os_min_ver, "portal")}`);
|
|
85
85
|
}
|
|
86
86
|
const jobs = checkJobs(options.jobs);
|
|
@@ -160,7 +160,7 @@ async function backendFor(manifest, arch, work, run) {
|
|
|
160
160
|
...(manifest.capabilities.identity ? [`-X main.identity=${manifest.capabilities.identity}`] : []),
|
|
161
161
|
...(manifest.capabilities.files || manifest.capabilities.delegate ? [`-X main.fileStation=session`] : []),
|
|
162
162
|
...(manifest.entry ? [`-X main.entry=${manifest.entry}`] : []),
|
|
163
|
-
...(manifest.portal ? [
|
|
163
|
+
...(manifest.portal ? [`-X main.portal=${portalByExecutor(manifest.os_min_ver) ? "executor" : "on"}`] : []),
|
|
164
164
|
].join(" ");
|
|
165
165
|
const result = await run("go", ["build", "-trimpath", "-buildvcs=false", "-ldflags", ldflags, "-o", out, backend.main], {
|
|
166
166
|
cwd: backend.dir,
|
|
@@ -4,6 +4,7 @@ import { PASSWORD_CONFIRM, ROOT_PANEL, ROOT_RUNTIME, rootPanelCatalogs } from ".
|
|
|
4
4
|
import { HOST_ADAPTER_RUNTIME } from "./host-adapter.js";
|
|
5
5
|
import { permitsOrdinaryUsers } from "./api-permissions.js";
|
|
6
6
|
import { PORTAL_RUNTIME } from "./portal-window.js";
|
|
7
|
+
import { portalByExecutor } from "../build-spk/dsm-version.js";
|
|
7
8
|
import { uiTitleFields } from "../build-spk/ui-texts.js";
|
|
8
9
|
/** Same path the generated wrapper POSTs to, and the payload location of the CGI. */
|
|
9
10
|
export const CGI_PAYLOAD_PATH = "ui/cgi/api.cgi";
|
|
@@ -172,6 +173,15 @@ ${root ? root.definitions : ""}${manifest.portal ? renderPortalDefinition(ns) :
|
|
|
172
173
|
document.head.appendChild(js);
|
|
173
174
|
};
|
|
174
175
|
|
|
176
|
+
// DSM reopens a window at the size it was last left at, even one saved on a
|
|
177
|
+
// larger screen, and only moves a window that does not fit: keep it inside.
|
|
178
|
+
${ns}.fitDesktop = function (size) {
|
|
179
|
+
var taskbar = SYNO.SDS.TaskBar ? SYNO.SDS.TaskBar.height() : 0;
|
|
180
|
+
size.width = Math.min(size.width, Ext.lib.Dom.getViewWidth());
|
|
181
|
+
size.height = Math.min(size.height, Ext.lib.Dom.getViewHeight() - taskbar);
|
|
182
|
+
return size;
|
|
183
|
+
};
|
|
184
|
+
|
|
175
185
|
Ext.define(${JSON.stringify(`${ns}.Instance`)}, {
|
|
176
186
|
extend: "SYNO.SDS.AppInstance",
|
|
177
187
|
appWindowName: ${JSON.stringify(`${ns}.MainWindow`)}
|
|
@@ -180,10 +190,13 @@ Ext.define(${JSON.stringify(`${ns}.Instance`)}, {
|
|
|
180
190
|
Ext.define(${JSON.stringify(`${ns}.MainWindow`)}, {
|
|
181
191
|
extend: "SYNO.SDS.AppWindow",
|
|
182
192
|
constructor: function (config) {
|
|
183
|
-
this.callParent([Ext.apply({
|
|
193
|
+
this.callParent([${ns}.fitDesktop(Ext.apply({
|
|
184
194
|
width: 960, height: 640, minWidth: 700, minHeight: 420, layout: "fit",
|
|
185
195
|
${root ? root.html : `html: '<div class="dsmapp-root" style="height:100%"></div>'`}
|
|
186
|
-
}, config)]);
|
|
196
|
+
}, config))]);
|
|
197
|
+
},
|
|
198
|
+
getRestoreSizePos: function (config) {
|
|
199
|
+
return ${ns}.fitDesktop(Ext.apply({ width: config.width, height: config.height }, this.callParent(arguments)));
|
|
187
200
|
},
|
|
188
201
|
onOpen: function () {
|
|
189
202
|
this.callParent(arguments);
|
|
@@ -267,7 +280,7 @@ ${PORTAL_RUNTIME}
|
|
|
267
280
|
function renderPortalAccess(ns, manifest) {
|
|
268
281
|
return `${ns}.createPortalAccess({
|
|
269
282
|
appId: ${JSON.stringify(`${ns}.Instance`)},
|
|
270
|
-
defaultAlias: ${JSON.stringify(manifest.portal.alias)},
|
|
283
|
+
defaultAlias: ${JSON.stringify(manifest.portal.alias)},${portalByExecutor(manifest.os_min_ver) ? "\n entry: true," : ""}
|
|
271
284
|
isAdmin: function () { return ${ns}.sessionUser().isAdmin; },
|
|
272
285
|
send: ${ns}.webApi(me),
|
|
273
286
|
call: ${ns}.call,
|
|
@@ -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": {
|
|
@@ -24,7 +24,7 @@
|
|
|
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`).
|