@synopackageland/cli 0.10.1 → 0.10.3
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 +219 -1669
- package/README.md +21 -2
- package/assets/login-style/mask.png +0 -0
- package/assets/login-style/wallpaper-thumbnail.jpg +0 -0
- package/assets/login-style/wallpaper.jpg +0 -0
- package/dist/build-spk/generator.js +10 -1
- package/dist/build-spk/login-style.d.ts +55 -0
- package/dist/build-spk/login-style.js +115 -0
- package/dist/cli.js +22 -1
- package/dist/error-registry.js +37 -0
- package/dist/host/dsm-client.js +6 -9
- package/dist/init.js +9 -5
- package/dist/runtime/delegate-contract.d.ts +2 -92
- package/dist/runtime/delegate-contract.js +2 -24
- package/dist/runtime/executor-build.js +10 -0
- package/dist/runtime/executor-digests.json +22 -0
- package/dist/runtime/executor-inspect.js +3 -0
- package/dist/runtime/executor-policy-check.d.ts +14 -0
- package/dist/runtime/executor-policy-check.js +70 -0
- package/dist/runtime/executor-policy.d.ts +2 -0
- package/dist/runtime/executor-policy.js +5 -1
- package/dist/runtime/root-programs.d.ts +2 -0
- package/dist/runtime/sdk-source.d.ts +4 -0
- package/dist/runtime/sdk-source.js +7 -0
- package/dist/runtime/sdk-timeouts.d.ts +23 -0
- package/dist/runtime/sdk-timeouts.js +44 -0
- package/dist/skill.js +1 -0
- package/dist/standalone/app-scripts.d.ts +26 -0
- package/dist/standalone/app-scripts.js +141 -0
- package/dist/standalone/bridge-app.d.ts +2 -0
- package/dist/standalone/bridge-app.js +9 -1
- package/dist/standalone/bridge-backend.d.ts +1 -1
- package/dist/standalone/build.js +5 -2
- package/dist/standalone/dsm-window.d.ts +14 -3
- package/dist/standalone/dsm-window.js +34 -12
- package/dist/standalone/host-adapter.d.ts +1 -1
- package/dist/standalone/host-adapter.js +1 -1
- package/dist/standalone/host-file-navigation.d.ts +6 -2
- package/dist/standalone/host-file-navigation.js +8 -2
- package/dist/standalone/inspect-spk.d.ts +9 -0
- package/dist/standalone/inspect-spk.js +60 -6
- package/dist/standalone/lifecycle.js +3 -1
- package/dist/standalone/manifest.d.ts +6 -0
- package/dist/standalone/manifest.js +51 -3
- package/dist/standalone/portal-window.d.ts +6 -6
- package/dist/standalone/portal-window.js +12 -32
- package/dist/standalone/request-timeout.d.ts +6 -3
- package/dist/standalone/request-timeout.js +10 -4
- package/dist/standalone/root-ops.d.ts +3 -2
- package/dist/standalone/root-ops.js +9 -3
- package/dist/standalone/root-window.d.ts +2 -2
- package/dist/standalone/root-window.js +173 -49
- package/dist/standalone/sdk-check.js +4 -1
- package/dist/standalone/spk.d.ts +3 -0
- package/dist/standalone/spk.js +10 -3
- package/dist/validate.js +6 -1
- package/package.json +2 -3
- package/skills/SKILL.md +3 -1
- package/skills/reference/create.md +5 -1
- package/skills/reference/identity.md +1 -1
- package/skills/reference/lifecycle.md +110 -0
- package/skills/reference/portal.md +12 -0
- package/dist/authorization-sdk.d.ts +0 -6
- package/dist/authorization-sdk.js +0 -6
- package/dist/standalone/native-authorization-window.d.ts +0 -2
- package/dist/standalone/native-authorization-window.js +0 -36
- package/dist/standalone/native-root-window.d.ts +0 -29
- package/dist/standalone/native-root-window.js +0 -307
|
@@ -3,18 +3,22 @@ import { DSM_LANGS } from "./i18n.js";
|
|
|
3
3
|
import { PASSWORD_CONFIRM, ROOT_PANEL, ROOT_RUNTIME, rootPanelCatalogs } from "./root-window.js";
|
|
4
4
|
import { HOST_ADAPTER_RUNTIME } from "./host-adapter.js";
|
|
5
5
|
import { permitsOrdinaryUsers } from "./api-permissions.js";
|
|
6
|
-
import {
|
|
6
|
+
import { portalRuntime } from "./portal-window.js";
|
|
7
7
|
import { portalByExecutor } from "../build-spk/dsm-version.js";
|
|
8
8
|
import { uiTitleFields } from "../build-spk/ui-texts.js";
|
|
9
9
|
import { packageIcons, windowIconFile, windowIconTemplate } from "../build-spk/dsm-icons.js";
|
|
10
10
|
import { DEFAULT_WINDOW, FULL_PAGE_MIN_WIDTH } from "./window-size.js";
|
|
11
11
|
import { requestTimeoutSeconds } from "./request-timeout.js";
|
|
12
|
+
import { appSdkTimeouts } from "../runtime/sdk-timeouts.js";
|
|
12
13
|
/** Same path the generated wrapper POSTs to, and the payload location of the CGI. */
|
|
13
14
|
export const CGI_PAYLOAD_PATH = "ui/cgi/api.cgi";
|
|
14
|
-
/**
|
|
15
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Relative to the page, as loadApp's assets and DSM's own WebAPI calls are: an
|
|
17
|
+
* Application Portal alias (https://nas/<alias>/) routes only its own paths,
|
|
18
|
+
* and /webman/... there answers 403.
|
|
19
|
+
*/
|
|
16
20
|
export function cgiUrl(packageId) {
|
|
17
|
-
return
|
|
21
|
+
return `webman/3rdparty/${packageId}/cgi/api.cgi`;
|
|
18
22
|
}
|
|
19
23
|
const LOCALE_BY_DSM_LANG = Object.fromEntries(Object.entries(DSM_LANGS).map(([locale, lang]) => [lang, locale]));
|
|
20
24
|
/**
|
|
@@ -72,8 +76,21 @@ export function entryLauncherAccess(entry) {
|
|
|
72
76
|
export function appTitle(manifest) {
|
|
73
77
|
return { displayname: manifest.displayname, description: manifest.description, info: manifest.i18n?.info };
|
|
74
78
|
}
|
|
75
|
-
/**
|
|
76
|
-
|
|
79
|
+
/** The app name by locale, as `ui/texts` names the window: `createI18n(lang, ...).t("app_title")`. */
|
|
80
|
+
function displayNameCatalogs(manifest) {
|
|
81
|
+
const catalogs = { en: { app_title: manifest.displayname } };
|
|
82
|
+
for (const [locale, text] of Object.entries(manifest.i18n?.info ?? {})) {
|
|
83
|
+
if (text?.displayname)
|
|
84
|
+
catalogs[locale] = { app_title: text.displayname };
|
|
85
|
+
}
|
|
86
|
+
return catalogs;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* `ui/config`: registers the DSM main-menu app and its window class. A portal
|
|
90
|
+
* App's launcher also carries the Login Portal style and its icon as the
|
|
91
|
+
* login page icon (DSM shows `loginIcon`, never `icon`, there).
|
|
92
|
+
*/
|
|
93
|
+
export function renderUiConfig(manifest, icons = packageIcons(manifest.icon), loginStyle) {
|
|
77
94
|
const icon = windowIconTemplate(icons);
|
|
78
95
|
const ns = `SYNO.SDS.${manifest.package}`;
|
|
79
96
|
const titleFields = uiTitleFields(appTitle(manifest));
|
|
@@ -86,6 +103,7 @@ export function renderUiConfig(manifest, icons = packageIcons(manifest.icon)) {
|
|
|
86
103
|
appWindow: `${ns}.MainWindow`,
|
|
87
104
|
allowMultiInstance: false,
|
|
88
105
|
...launcherAccess(manifest),
|
|
106
|
+
...(loginStyle ? { loginIcon: icon, loginStyle } : {}),
|
|
89
107
|
depend: [`${ns}.MainWindow`],
|
|
90
108
|
},
|
|
91
109
|
[`${ns}.MainWindow`]: {
|
|
@@ -108,14 +126,18 @@ export function renderUiConfig(manifest, icons = packageIcons(manifest.icon)) {
|
|
|
108
126
|
* launch owns the whole page: `ctx.fullPage` tells the app to lay out full-page
|
|
109
127
|
* (and add a device-width viewport), and `ctx.appIcon` is the package icon a
|
|
110
128
|
* full-page shell shows beside the name, where a DSM window has a title bar.
|
|
129
|
+
* A portal App's `ctx.portal` shows `portalText`, the SDK's panel text.
|
|
111
130
|
*/
|
|
112
|
-
export function renderWindowScript(manifest, assetVersion, icons = packageIcons(manifest.icon)) {
|
|
131
|
+
export function renderWindowScript(manifest, assetVersion, icons = packageIcons(manifest.icon), portalText) {
|
|
113
132
|
const pkg = manifest.package;
|
|
114
133
|
const ns = `SYNO.SDS.${pkg}`;
|
|
115
134
|
const p = JSON.stringify(pkg);
|
|
116
135
|
const root = manifest.executor ? renderRootParts(ns, manifest) : undefined;
|
|
117
136
|
const size = manifest.ui.window ?? DEFAULT_WINDOW;
|
|
118
137
|
const requestTimeout = requestTimeoutSeconds(manifest.backend) * 1000;
|
|
138
|
+
const clientMargin = appSdkTimeouts(manifest.backend).browserMarginMs;
|
|
139
|
+
if (manifest.portal && !portalText)
|
|
140
|
+
throw new Error("A portal App's window needs the SDK's portal panel text.");
|
|
119
141
|
return `/* Generated by synopkgland build. Edit your UI in the project's ui folder. */
|
|
120
142
|
Ext.ns(${JSON.stringify(ns)});
|
|
121
143
|
|
|
@@ -142,7 +164,7 @@ ${ns}.call = function (method, params, options, requestTimeout) {
|
|
|
142
164
|
var leave = function () { stop(canceled); };
|
|
143
165
|
var timer = setTimeout(function () {
|
|
144
166
|
stop({ code: "request_timeout", message: "The NAS did not answer in time" });
|
|
145
|
-
}, (requestTimeout || ${requestTimeout}) + ${
|
|
167
|
+
}, (requestTimeout || ${requestTimeout}) + ${clientMargin});
|
|
146
168
|
if (signal) signal.addEventListener("abort", leave);
|
|
147
169
|
var done = function () {
|
|
148
170
|
clearTimeout(timer);
|
|
@@ -192,7 +214,7 @@ ${ns}.sessionUser = function () {
|
|
|
192
214
|
return { name: typeof session.user === "string" ? session.user : "", isAdmin: session.is_admin === true };
|
|
193
215
|
};
|
|
194
216
|
|
|
195
|
-
${root ? root.definitions : ""}${manifest.portal ? renderPortalDefinition(ns) : ""}${ns}.loadApp = function (done) {
|
|
217
|
+
${root ? root.definitions : ""}${manifest.portal ? renderPortalDefinition(ns, portalText) : ""}${ns}.loadApp = function (done) {
|
|
196
218
|
var apps = window.DsmPackageApps || {};
|
|
197
219
|
if (apps[${p}]) {
|
|
198
220
|
done(apps[${p}]);
|
|
@@ -268,7 +290,7 @@ ${root ? root.mount : ""} ${ns}.loadApp(function (app) {
|
|
|
268
290
|
root.lang = i18n.locale;
|
|
269
291
|
try {
|
|
270
292
|
me.mounted = app.mount(root, Ext.apply(ctx, {
|
|
271
|
-
displayName: ${JSON.stringify(manifest.
|
|
293
|
+
displayName: ${ns}.createI18n(lang, ${JSON.stringify(displayNameCatalogs(manifest))}).t("app_title"),
|
|
272
294
|
call: call,
|
|
273
295
|
files: {endpoint: ${JSON.stringify(cgiUrl(pkg))}, token: function () { return SYNO.SDS.Session && SYNO.SDS.Session.SynoToken; }},
|
|
274
296
|
reopen: me.reopenEvents.subscribe,
|
|
@@ -312,9 +334,9 @@ ${root ? root.mount : ""} ${ns}.loadApp(function (app) {
|
|
|
312
334
|
});
|
|
313
335
|
`;
|
|
314
336
|
}
|
|
315
|
-
function renderPortalDefinition(ns) {
|
|
337
|
+
function renderPortalDefinition(ns, text) {
|
|
316
338
|
return `${ns}.createPortalAccess = (function () {
|
|
317
|
-
${
|
|
339
|
+
${portalRuntime(text)}
|
|
318
340
|
return createPortalAccess;
|
|
319
341
|
})();
|
|
320
342
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export type { HostAdapter, HostTitleResult, HostRequestResult, HostURLResult, HostEvent, HostSubscriptionResult, HostFileNavigationResult, HostUnsupportedResult } from "./host-types.js";
|
|
2
|
-
export declare const HOST_ADAPTER_RUNTIME = "function inspectHostPlatform(ctx) {\n ctx = ctx || {};\n var web = typeof window !== \"undefined\";\n var host = false;\n try {\n host = !!(web && ctx.appWindow && typeof SYNO !== \"undefined\" &&\n SYNO.SDS && typeof SYNO.SDS.AppWindow === \"function\" &&\n ctx.appWindow instanceof SYNO.SDS.AppWindow);\n } catch (error) {\n // An unsupported host constructor must not break a standalone shell.\n }\n function read(key) {\n if (!host || typeof _S !== \"function\") return null;\n try { return _S(key); } catch (error) { return null; }\n }\n function text(value) {\n return typeof value === \"string\" && /\\S/.test(value) ? value : null;\n }\n function versionPart(value) {\n if (typeof value === \"number\") {\n if (!isFinite(value) || value < 0 || Math.floor(value) !== value) return null;\n value = String(value);\n }\n return typeof value === \"string\" && value.length > 0 && !/\\D/.test(value) ? value : null;\n }\n var standalone = read(\"standalone\");\n var language = text(read(\"lang\"));\n var major = versionPart(read(\"majorversion\"));\n var minor = versionPart(read(\"minorversion\"));\n var fullVersion = text(read(\"fullversion\"));\n return Object.freeze({\n web: web,\n host: host ? \"dsm\" : web ? \"standalone-web\" : \"unavailable\",\n dsmStandalone: typeof standalone === \"boolean\" ? standalone : null,\n dsmLanguage: language,\n appLocale: text(ctx.locale),\n systemVersion: major !== null && minor !== null ? major + \".\" + minor : null,\n fullVersion: fullVersion,\n theme: null\n });\n}\n\nfunction requestHostFileNavigation(launch, path, isDirectory) {\n if (typeof path !== \"string\" || path.length > 4096 || path.charAt(0) !== \"/\" ||\n /[\\x00-\\x1f\\x7f\\\\]/.test(path)) return { status: \"invalid-input\" };\n var parts = path.split(\"/\");\n if (/^volume[0-9]+$/i.test(parts[1]) || parts.slice(1).some(function (part) {\n return !part || part === \".\" || part === \"..\";\n })) return { status: \"invalid-input\" };\n var result = launch(\"SYNO.SDS.App.FileStation3.Instance\", isDirectory ? { opendir: path } : { openfile: path });\n if (result.status !== \"requested\") return result;\n return { status: \"requested\", action: isDirectory ? \"locate-folder\" : \"highlight-file\" };\n}\n\n\nvar REVEAL_RECOVERY_TIMEOUT_MS = 5000;\nvar REVEAL_RECOVERY_POLL_MS = 100;\nvar REVEAL_RECOVERY_QUIET_MS = 300;\nvar hostFileRevealOwners = null, hostFileRevealSequence = 0;\ntry { if (typeof WeakMap === \"function\") hostFileRevealOwners = new WeakMap(); } catch (error) {}\nfunction createHostFileRevealRecovery(path, canAct) {\n var active = true, accepted = false, win = null, dom = null, tuple = null;\n var poll = null, deadline = null, removers = [], reached = false, pending = 0;\n var previous = null, quietSince = null, expires = 0, order = ++hostFileRevealSequence;\n var parent = path.slice(0, path.lastIndexOf(\"/\"));\n function cancel() {\n if (!active) return;\n active = false;\n try { if (poll !== null) clearTimeout(poll); } catch (error) {}\n try { if (deadline !== null) clearTimeout(deadline); } catch (error) {}\n poll = deadline = null;\n removers.splice(0).forEach(function (remove) { try { remove(); } catch (error) {} });\n try {\n if (win && hostFileRevealOwners.get(win).order === order) {\n // Preserve ordering for a late older callback, without retaining its closures.\n hostFileRevealOwners.set(win, { order: order, cancel: null });\n }\n } catch (error) {}\n win = dom = tuple = previous = null;\n }\n function requireMethods(target, names) {\n if (!target || names.some(function (name) { return typeof target[name] !== \"function\"; })) throw new Error(\"unsupported receiver\");\n }\n function guard(fn) {\n return function () {\n if (!active) return;\n try { fn.apply(null, arguments); } catch (error) { cancel(); }\n };\n }\n function listen(target, event, fn) {\n requireMethods(target, [\"on\", \"un\"]);\n var handler = guard(fn), remove = target.un;\n removers.push(function () { remove.call(target, event, handler, target); });\n target.on(event, handler, target);\n }\n function resetQuiet() { previous = null; quietSince = null; }\n function inspect() {\n requireMethods(win, [\"getPanelInstance\", \"getEl\", \"on\", \"un\"]);\n if (win.isDestroyed || win.destroyed || !canAct()) { cancel(); return null; }\n var element = win.getEl(), nextDom = element && element.dom;\n if (!nextDom) { if (dom) cancel(); return null; }\n requireMethods(nextDom, [\"addEventListener\", \"removeEventListener\"]);\n if (dom && nextDom !== dom) { cancel(); return null; }\n if (!dom) {\n dom = nextDom;\n [\"pointerdown\", \"mousedown\", \"touchstart\", \"keydown\", \"wheel\", \"click\", \"input\"].forEach(function (event) {\n var remove = dom.removeEventListener, handler = guard(cancel), target = dom;\n removers.push(function () { remove.call(target, event, handler, true); });\n dom.addEventListener(event, handler, true);\n });\n }\n var panel = win.getPanelInstance();\n if (!panel) { if (tuple) cancel(); return null; }\n requireMethods(panel, [\"getCurrentDir\", \"on\", \"un\"]);\n var view = panel.activeView, store = panel.activeDS;\n requireMethods(view, [\"getStore\", \"getSelectionModel\", \"scrollToItem\"]);\n requireMethods(store, [\"on\", \"un\", \"indexOfId\", \"getAt\", \"getCount\"]);\n var model = view.getSelectionModel();\n requireMethods(model, [\"on\", \"un\", \"getSelections\", \"selectRow\"]);\n if (view.getStore() !== store || panel.isDestroyed || store.isDestroyed || model.isDestroyed) { cancel(); return null; }\n if (tuple && (tuple.panel !== panel || tuple.view !== view || tuple.store !== store || tuple.model !== model)) { cancel(); return null; }\n if (!tuple) {\n tuple = { panel: panel, view: view, store: store, model: model };\n listen(panel, \"destroy\", cancel);\n listen(panel, \"onNodeClick\", function (node) {\n if (!node || !node.attributes || (reached && node.attributes.path !== parent)) cancel();\n });\n listen(store, \"beforeload\", function () {\n pending++; resetQuiet(); inspect();\n });\n listen(store, \"load\", function () {\n if (pending > 0) pending--;\n resetQuiet(); check();\n });\n [\"exception\", \"loadexception\", \"destroy\"].forEach(function (event) { listen(store, event, cancel); });\n listen(model, \"selectionchange\", function () { resetQuiet(); check(); });\n }\n var dir = panel.getCurrentDir(), params = store.baseParams;\n // New File Station panels expose their APIs before the first directory is set.\n // Keep the bound receiver guards and original deadline while startup finishes.\n if (!reached && dir === undefined && (!params || params.folder_path === undefined)) return null;\n if (!params || typeof params.folder_path !== \"string\") throw new Error(\"unsupported store path\");\n // Native reconfigure/selection events can precede the store's path assignment.\n if (dir === parent && params.folder_path === parent) reached = true;\n if (reached && (dir !== parent || params.folder_path !== parent)) { cancel(); return null; }\n var selections = model.getSelections();\n if (!Array.isArray(selections)) throw new Error(\"unsupported selection\");\n if (selections.some(function (record) { return !record || record.id !== path; })) { cancel(); return null; }\n var index = store.indexOfId(path), count = store.getCount();\n if (!Number.isInteger(index) || index < -1 || !Number.isInteger(count) || count < 0) throw new Error(\"unsupported store index\");\n var record = index >= 0 ? store.getAt(index) : null;\n if (index >= 0 && (!record || record.id !== path || index >= count)) throw new Error(\"inconsistent store\");\n return { dir: dir, folder: params.folder_path, count: count, index: index, record: record, selected: selections.length > 0 };\n }\n function check() {\n if (!active) return;\n if (Date.now() >= expires || !canAct()) { cancel(); return; }\n if (!win) return;\n var state = inspect();\n if (!active || !state) return;\n if (pending || state.dir !== parent || state.folder !== parent || state.index < 0) { resetQuiet(); return; }\n if (!previous || Object.keys(state).some(function (key) { return state[key] !== previous[key]; })) {\n previous = state; quietSince = Date.now();\n }\n if (Date.now() - quietSince < REVEAL_RECOVERY_QUIET_MS) return;\n var target = tuple, receiverWindow = win;\n cancel(); // Release every observer before the one permitted selection mutation.\n if (state.selected || !canAct()) return;\n target.model.selectRow(state.index);\n if (canAct() && !receiverWindow.isDestroyed && receiverWindow.getPanelInstance() === target.panel &&\n target.panel.activeView === target.view && target.panel.activeDS === target.store &&\n target.view.getSelectionModel() === target.model) target.view.scrollToItem(state.index);\n }\n function tick() {\n poll = null;\n try {\n check();\n if (active) poll = setTimeout(tick, REVEAL_RECOVERY_POLL_MS);\n } catch (error) { cancel(); }\n }\n function receive(instance) {\n if (!active || accepted) return;\n accepted = true;\n try {\n if (Date.now() >= expires || !canAct()) { cancel(); return; }\n win = instance && instance.window;\n requireMethods(win, [\"getPanelInstance\", \"getEl\", \"on\", \"un\"]);\n var owner = hostFileRevealOwners.get(win);\n if (owner && owner.order > order) { cancel(); return; }\n if (owner && owner.cancel) owner.cancel();\n hostFileRevealOwners.set(win, { order: order, cancel: cancel });\n [\"beforeclose\", \"close\", \"beforedestroy\", \"destroy\", \"hide\"].forEach(function (event) { listen(win, event, cancel); });\n check();\n } catch (error) { cancel(); }\n }\n try {\n if (!parent || !hostFileRevealOwners || typeof setTimeout !== \"function\" || typeof clearTimeout !== \"function\" || typeof Date.now !== \"function\") {\n cancel();\n } else {\n expires = Date.now() + REVEAL_RECOVERY_TIMEOUT_MS;\n deadline = setTimeout(cancel, REVEAL_RECOVERY_TIMEOUT_MS);\n poll = setTimeout(tick, REVEAL_RECOVERY_POLL_MS);\n }\n } catch (error) { cancel(); }\n return { receive: receive, cancel: cancel };\n}\n\nfunction createHostAdapter(ctx) {\n ctx = ctx || {};\n var disposed = false, subscriptions = [], revealRecovery = null;\n function launchApp(appId, config, receiverCallback) {\n if (disposed) return { status: \"unavailable\" };\n if (typeof appId !== \"string\" || !appId || /\\s/.test(appId)) return { status: \"invalid-input\" };\n try {\n if (inspectHostPlatform(ctx).host !== \"dsm\") return { status: \"unsupported\" };\n var sds = SYNO.SDS, map = sds.Config && sds.Config.FnMap, notifier = sds.StatusNotifier;\n if (typeof sds.AppLaunch !== \"function\" || !map ||\n !Object.prototype.hasOwnProperty.call(map, appId) || !map[appId] || !notifier ||\n typeof notifier.isAppEnabled !== \"function\" || !notifier.isAppEnabled(appId) ||\n typeof notifier.isAppLaunchable !== \"function\" || !notifier.isAppLaunchable(appId)) {\n return { status: \"unsupported\" };\n }\n if (receiverCallback) sds.AppLaunch(appId, config, undefined, receiverCallback);\n else sds.AppLaunch(appId, config);\n return { status: \"requested\" };\n } catch (error) { return { status: \"failed\" }; }\n }\n function navigateFileStation(path, isDirectory) {\n if (disposed) return { status: \"unavailable\" };\n if (revealRecovery) revealRecovery.cancel();\n revealRecovery = null;\n function launchFile(appId, config) {\n if (isDirectory) return launchApp(appId, config);\n var recovery = createHostFileRevealRecovery(path, function () { return !disposed && revealRecovery === recovery; });\n revealRecovery = recovery;\n var result = launchApp(appId, config, recovery.receive);\n if (result.status !== \"requested\") recovery.cancel();\n return result;\n }\n return requestHostFileNavigation(launchFile, path, isDirectory);\n }\n function unsupportedOperation() { return { status: disposed ? \"unavailable\" : \"unsupported\" }; }\n return Object.freeze({\n platform: function () {\n if (!disposed) return inspectHostPlatform(ctx);\n return Object.freeze({\n web: typeof window !== \"undefined\", host: \"unavailable\",\n dsmStandalone: null, dsmLanguage: null, appLocale: null,\n systemVersion: null, fullVersion: null, theme: null\n });\n },\n setTitle: function (title) {\n if (disposed) return { status: \"unavailable\" };\n if (typeof title !== \"string\") return { status: \"invalid-input\" };\n try {\n var platform = inspectHostPlatform(ctx);\n if (platform.host === \"dsm\") {\n if (typeof ctx.appWindow.setTitle !== \"function\") return { status: \"unsupported\" };\n ctx.appWindow.setTitle(title, undefined, true);\n return { status: \"ok\", target: \"dsm-window\" };\n }\n if (!platform.web) return { status: \"unavailable\" };\n if (typeof document === \"undefined\") return { status: \"unsupported\" };\n document.title = title;\n return { status: \"ok\", target: \"document\" };\n } catch (error) {\n return { status: \"failed\" };\n }\n },\n activateWindow: function () {\n if (disposed) return { status: \"unavailable\" };\n try {\n if (inspectHostPlatform(ctx).host !== \"dsm\") return { status: \"unsupported\" };\n var win = ctx.appWindow;\n var activate = typeof win.toFront === \"function\" ? win.toFront : win.activate;\n if (typeof activate !== \"function\") return { status: \"unsupported\" };\n // DSM minimizes by hiding; show preserves maximized geometry, unlike restore.\n if (win.hidden === true || win.minimized === true ||\n (typeof win.isVisible === \"function\" && win.isVisible() === false)) {\n if (typeof win.show !== \"function\") return { status: \"unsupported\" };\n win.show();\n }\n activate.call(win);\n return { status: \"requested\" };\n } catch (error) { return { status: \"failed\" }; }\n },\n requestClose: function () {\n if (disposed) return { status: \"unavailable\" };\n try {\n if (inspectHostPlatform(ctx).host !== \"dsm\") return { status: \"unsupported\" };\n var win = ctx.appWindow;\n // DSM returns a short-circuit value; only truthy means standalone main.\n if (typeof win.close !== \"function\" || typeof win.isStandaloneMainWindow !== \"function\" ||\n win.isStandaloneMainWindow()) return { status: \"unsupported\" };\n if (win.canClose === false) return { status: \"blocked\" };\n win.close();\n return { status: \"requested\" };\n } catch (error) { return { status: \"failed\" }; }\n },\n openApp: function (appId) { return launchApp(appId, {}); },\n openURL: function (raw) {\n if (disposed || typeof window === \"undefined\") return { status: \"unavailable\" };\n if (typeof raw !== \"string\" || !raw || /[\\s\\x00-\\x1f\\x7f]/.test(raw)) return { status: \"invalid-input\" };\n if (typeof URL !== \"function\") return { status: \"unsupported\" };\n var url;\n try { url = new URL(raw); } catch (error) { return { status: \"invalid-input\" }; }\n if ((url.protocol !== \"http:\" && url.protocol !== \"https:\") || url.username || url.password) return { status: \"invalid-input\" };\n if (typeof window.open !== \"function\") return { status: \"unsupported\" };\n try {\n var opened = window.open(url.href, \"_blank\", \"noopener,noreferrer\");\n return { status: \"requested\", windowHandleAvailable: !!opened };\n } catch (error) { return { status: \"failed\" }; }\n },\n openFileManager: function (path) { return navigateFileStation(path, true); },\n revealFile: function (path) { return navigateFileStation(path, false); },\n openFile: unsupportedOperation,\n showFileDetails: unsupportedOperation,\n openAppSetting: unsupportedOperation,\n subscribe: function (event, listener) {\n if (disposed) return { status: \"unavailable\" };\n if (typeof listener !== \"function\") return { status: \"invalid-input\" };\n if (event !== \"window-resize\") return { status: \"unsupported\" };\n var win, callback = listener, active = true;\n function handler() { if (!disposed && callback) callback(); }\n function unsubscribe() {\n if (!active) return;\n active = false;\n callback = null;\n var target = win;\n win = null;\n var index = subscriptions.indexOf(unsubscribe);\n if (index !== -1) subscriptions.splice(index, 1);\n try { if (target) target.un(\"resize\", handler, target); } catch (error) {}\n }\n try {\n if (inspectHostPlatform(ctx).host !== \"dsm\") return { status: \"unsupported\" };\n win = ctx.appWindow;\n if (typeof win.on !== \"function\" || typeof win.un !== \"function\") return { status: \"unsupported\" };\n subscriptions.push(unsubscribe);\n win.on(\"resize\", handler, win);\n if (disposed) return { status: \"unavailable\" };\n return Object.freeze({ status: \"subscribed\", dispose: unsubscribe });\n } catch (error) {\n unsubscribe();\n return { status: \"failed\" };\n }\n },\n dispose: function () {\n if (disposed) return;\n disposed = true;\n if (revealRecovery) revealRecovery.cancel();\n revealRecovery = null;\n subscriptions.slice().forEach(function (unsubscribe) { unsubscribe(); });\n ctx = null;\n }\n });\n}";
|
|
2
|
+
export declare const HOST_ADAPTER_RUNTIME = "function inspectHostPlatform(ctx) {\n ctx = ctx || {};\n var web = typeof window !== \"undefined\";\n var host = false;\n try {\n host = !!(web && ctx.appWindow && typeof SYNO !== \"undefined\" &&\n SYNO.SDS && typeof SYNO.SDS.AppWindow === \"function\" &&\n ctx.appWindow instanceof SYNO.SDS.AppWindow);\n } catch (error) {\n // An unsupported host constructor must not break a standalone shell.\n }\n function read(key) {\n if (!host || typeof _S !== \"function\") return null;\n try { return _S(key); } catch (error) { return null; }\n }\n function text(value) {\n return typeof value === \"string\" && /\\S/.test(value) ? value : null;\n }\n function versionPart(value) {\n if (typeof value === \"number\") {\n if (!isFinite(value) || value < 0 || Math.floor(value) !== value) return null;\n value = String(value);\n }\n return typeof value === \"string\" && value.length > 0 && !/\\D/.test(value) ? value : null;\n }\n var standalone = read(\"standalone\");\n var language = text(read(\"lang\"));\n var major = versionPart(read(\"majorversion\"));\n var minor = versionPart(read(\"minorversion\"));\n var fullVersion = text(read(\"fullversion\"));\n return Object.freeze({\n web: web,\n host: host ? \"dsm\" : web ? \"standalone-web\" : \"unavailable\",\n dsmStandalone: typeof standalone === \"boolean\" ? standalone : null,\n dsmLanguage: language,\n appLocale: text(ctx.locale),\n systemVersion: major !== null && minor !== null ? major + \".\" + minor : null,\n fullVersion: fullVersion,\n theme: null\n });\n}\n\nfunction requestHostFileNavigation(launch, path, isDirectory) {\n if (typeof path !== \"string\" || path.length > 4096 || path.charAt(0) !== \"/\" ||\n /[\\x00-\\x1f\\x7f\\\\]/.test(path)) return { status: \"invalid-input\" };\n var volume = /^\\/volume(?:USB|SATA)?[0-9]+(?=\\/|$)/.exec(path);\n if (volume) path = path.slice(volume[0].length);\n var parts = path.split(\"/\");\n if (parts.length < 2 || parts.slice(1).some(function (part) {\n return !part || part === \".\" || part === \"..\";\n })) return { status: \"invalid-input\" };\n var result = launch(\"SYNO.SDS.App.FileStation3.Instance\", isDirectory ? { opendir: path } : { openfile: path });\n if (result.status !== \"requested\") return result;\n return { status: \"requested\", action: isDirectory ? \"locate-folder\" : \"highlight-file\" };\n}\n\n\nvar REVEAL_RECOVERY_TIMEOUT_MS = 5000;\nvar REVEAL_RECOVERY_POLL_MS = 100;\nvar REVEAL_RECOVERY_QUIET_MS = 300;\nvar hostFileRevealOwners = null, hostFileRevealSequence = 0;\ntry { if (typeof WeakMap === \"function\") hostFileRevealOwners = new WeakMap(); } catch (error) {}\nfunction createHostFileRevealRecovery(path, canAct) {\n var active = true, accepted = false, win = null, dom = null, tuple = null;\n var poll = null, deadline = null, removers = [], reached = false, pending = 0;\n var previous = null, quietSince = null, expires = 0, order = ++hostFileRevealSequence;\n var parent = path.slice(0, path.lastIndexOf(\"/\"));\n function cancel() {\n if (!active) return;\n active = false;\n try { if (poll !== null) clearTimeout(poll); } catch (error) {}\n try { if (deadline !== null) clearTimeout(deadline); } catch (error) {}\n poll = deadline = null;\n removers.splice(0).forEach(function (remove) { try { remove(); } catch (error) {} });\n try {\n if (win && hostFileRevealOwners.get(win).order === order) {\n // Preserve ordering for a late older callback, without retaining its closures.\n hostFileRevealOwners.set(win, { order: order, cancel: null });\n }\n } catch (error) {}\n win = dom = tuple = previous = null;\n }\n function requireMethods(target, names) {\n if (!target || names.some(function (name) { return typeof target[name] !== \"function\"; })) throw new Error(\"unsupported receiver\");\n }\n function guard(fn) {\n return function () {\n if (!active) return;\n try { fn.apply(null, arguments); } catch (error) { cancel(); }\n };\n }\n function listen(target, event, fn) {\n requireMethods(target, [\"on\", \"un\"]);\n var handler = guard(fn), remove = target.un;\n removers.push(function () { remove.call(target, event, handler, target); });\n target.on(event, handler, target);\n }\n function resetQuiet() { previous = null; quietSince = null; }\n function inspect() {\n requireMethods(win, [\"getPanelInstance\", \"getEl\", \"on\", \"un\"]);\n if (win.isDestroyed || win.destroyed || !canAct()) { cancel(); return null; }\n var element = win.getEl(), nextDom = element && element.dom;\n if (!nextDom) { if (dom) cancel(); return null; }\n requireMethods(nextDom, [\"addEventListener\", \"removeEventListener\"]);\n if (dom && nextDom !== dom) { cancel(); return null; }\n if (!dom) {\n dom = nextDom;\n [\"pointerdown\", \"mousedown\", \"touchstart\", \"keydown\", \"wheel\", \"click\", \"input\"].forEach(function (event) {\n var remove = dom.removeEventListener, handler = guard(cancel), target = dom;\n removers.push(function () { remove.call(target, event, handler, true); });\n dom.addEventListener(event, handler, true);\n });\n }\n var panel = win.getPanelInstance();\n if (!panel) { if (tuple) cancel(); return null; }\n requireMethods(panel, [\"getCurrentDir\", \"on\", \"un\"]);\n var view = panel.activeView, store = panel.activeDS;\n requireMethods(view, [\"getStore\", \"getSelectionModel\", \"scrollToItem\"]);\n requireMethods(store, [\"on\", \"un\", \"indexOfId\", \"getAt\", \"getCount\"]);\n var model = view.getSelectionModel();\n requireMethods(model, [\"on\", \"un\", \"getSelections\", \"selectRow\"]);\n if (view.getStore() !== store || panel.isDestroyed || store.isDestroyed || model.isDestroyed) { cancel(); return null; }\n if (tuple && (tuple.panel !== panel || tuple.view !== view || tuple.store !== store || tuple.model !== model)) { cancel(); return null; }\n if (!tuple) {\n tuple = { panel: panel, view: view, store: store, model: model };\n listen(panel, \"destroy\", cancel);\n listen(panel, \"onNodeClick\", function (node) {\n if (!node || !node.attributes || (reached && node.attributes.path !== parent)) cancel();\n });\n listen(store, \"beforeload\", function () {\n pending++; resetQuiet(); inspect();\n });\n listen(store, \"load\", function () {\n if (pending > 0) pending--;\n resetQuiet(); check();\n });\n [\"exception\", \"loadexception\", \"destroy\"].forEach(function (event) { listen(store, event, cancel); });\n listen(model, \"selectionchange\", function () { resetQuiet(); check(); });\n }\n var dir = panel.getCurrentDir(), params = store.baseParams;\n // New File Station panels expose their APIs before the first directory is set.\n // Keep the bound receiver guards and original deadline while startup finishes.\n if (!reached && dir === undefined && (!params || params.folder_path === undefined)) return null;\n if (!params || typeof params.folder_path !== \"string\") throw new Error(\"unsupported store path\");\n // Native reconfigure/selection events can precede the store's path assignment.\n if (dir === parent && params.folder_path === parent) reached = true;\n if (reached && (dir !== parent || params.folder_path !== parent)) { cancel(); return null; }\n var selections = model.getSelections();\n if (!Array.isArray(selections)) throw new Error(\"unsupported selection\");\n if (selections.some(function (record) { return !record || record.id !== path; })) { cancel(); return null; }\n var index = store.indexOfId(path), count = store.getCount();\n if (!Number.isInteger(index) || index < -1 || !Number.isInteger(count) || count < 0) throw new Error(\"unsupported store index\");\n var record = index >= 0 ? store.getAt(index) : null;\n if (index >= 0 && (!record || record.id !== path || index >= count)) throw new Error(\"inconsistent store\");\n return { dir: dir, folder: params.folder_path, count: count, index: index, record: record, selected: selections.length > 0 };\n }\n function check() {\n if (!active) return;\n if (Date.now() >= expires || !canAct()) { cancel(); return; }\n if (!win) return;\n var state = inspect();\n if (!active || !state) return;\n if (pending || state.dir !== parent || state.folder !== parent || state.index < 0) { resetQuiet(); return; }\n if (!previous || Object.keys(state).some(function (key) { return state[key] !== previous[key]; })) {\n previous = state; quietSince = Date.now();\n }\n if (Date.now() - quietSince < REVEAL_RECOVERY_QUIET_MS) return;\n var target = tuple, receiverWindow = win;\n cancel(); // Release every observer before the one permitted selection mutation.\n if (state.selected || !canAct()) return;\n target.model.selectRow(state.index);\n if (canAct() && !receiverWindow.isDestroyed && receiverWindow.getPanelInstance() === target.panel &&\n target.panel.activeView === target.view && target.panel.activeDS === target.store &&\n target.view.getSelectionModel() === target.model) target.view.scrollToItem(state.index);\n }\n function tick() {\n poll = null;\n try {\n check();\n if (active) poll = setTimeout(tick, REVEAL_RECOVERY_POLL_MS);\n } catch (error) { cancel(); }\n }\n function receive(instance) {\n if (!active || accepted) return;\n accepted = true;\n try {\n if (Date.now() >= expires || !canAct()) { cancel(); return; }\n win = instance && instance.window;\n requireMethods(win, [\"getPanelInstance\", \"getEl\", \"on\", \"un\"]);\n var owner = hostFileRevealOwners.get(win);\n if (owner && owner.order > order) { cancel(); return; }\n if (owner && owner.cancel) owner.cancel();\n hostFileRevealOwners.set(win, { order: order, cancel: cancel });\n [\"beforeclose\", \"close\", \"beforedestroy\", \"destroy\", \"hide\"].forEach(function (event) { listen(win, event, cancel); });\n check();\n } catch (error) { cancel(); }\n }\n try {\n if (!parent || !hostFileRevealOwners || typeof setTimeout !== \"function\" || typeof clearTimeout !== \"function\" || typeof Date.now !== \"function\") {\n cancel();\n } else {\n expires = Date.now() + REVEAL_RECOVERY_TIMEOUT_MS;\n deadline = setTimeout(cancel, REVEAL_RECOVERY_TIMEOUT_MS);\n poll = setTimeout(tick, REVEAL_RECOVERY_POLL_MS);\n }\n } catch (error) { cancel(); }\n return { receive: receive, cancel: cancel };\n}\n\nfunction createHostAdapter(ctx) {\n ctx = ctx || {};\n var disposed = false, subscriptions = [], revealRecovery = null;\n function launchApp(appId, config, receiverCallback) {\n if (disposed) return { status: \"unavailable\" };\n if (typeof appId !== \"string\" || !appId || /\\s/.test(appId)) return { status: \"invalid-input\" };\n try {\n if (inspectHostPlatform(ctx).host !== \"dsm\") return { status: \"unsupported\" };\n var sds = SYNO.SDS, map = sds.Config && sds.Config.FnMap, notifier = sds.StatusNotifier;\n if (typeof sds.AppLaunch !== \"function\" || !map ||\n !Object.prototype.hasOwnProperty.call(map, appId) || !map[appId] || !notifier ||\n typeof notifier.isAppEnabled !== \"function\" || !notifier.isAppEnabled(appId) ||\n typeof notifier.isAppLaunchable !== \"function\" || !notifier.isAppLaunchable(appId)) {\n return { status: \"unsupported\" };\n }\n if (receiverCallback) sds.AppLaunch(appId, config, undefined, receiverCallback);\n else sds.AppLaunch(appId, config);\n return { status: \"requested\" };\n } catch (error) { return { status: \"failed\" }; }\n }\n function navigateFileStation(path, isDirectory) {\n if (disposed) return { status: \"unavailable\" };\n if (revealRecovery) revealRecovery.cancel();\n revealRecovery = null;\n function launchFile(appId, config) {\n if (isDirectory) return launchApp(appId, config);\n var recovery = createHostFileRevealRecovery(config.openfile, function () { return !disposed && revealRecovery === recovery; });\n revealRecovery = recovery;\n var result = launchApp(appId, config, recovery.receive);\n if (result.status !== \"requested\") recovery.cancel();\n return result;\n }\n return requestHostFileNavigation(launchFile, path, isDirectory);\n }\n function unsupportedOperation() { return { status: disposed ? \"unavailable\" : \"unsupported\" }; }\n return Object.freeze({\n platform: function () {\n if (!disposed) return inspectHostPlatform(ctx);\n return Object.freeze({\n web: typeof window !== \"undefined\", host: \"unavailable\",\n dsmStandalone: null, dsmLanguage: null, appLocale: null,\n systemVersion: null, fullVersion: null, theme: null\n });\n },\n setTitle: function (title) {\n if (disposed) return { status: \"unavailable\" };\n if (typeof title !== \"string\") return { status: \"invalid-input\" };\n try {\n var platform = inspectHostPlatform(ctx);\n if (platform.host === \"dsm\") {\n if (typeof ctx.appWindow.setTitle !== \"function\") return { status: \"unsupported\" };\n ctx.appWindow.setTitle(title, undefined, true);\n return { status: \"ok\", target: \"dsm-window\" };\n }\n if (!platform.web) return { status: \"unavailable\" };\n if (typeof document === \"undefined\") return { status: \"unsupported\" };\n document.title = title;\n return { status: \"ok\", target: \"document\" };\n } catch (error) {\n return { status: \"failed\" };\n }\n },\n activateWindow: function () {\n if (disposed) return { status: \"unavailable\" };\n try {\n if (inspectHostPlatform(ctx).host !== \"dsm\") return { status: \"unsupported\" };\n var win = ctx.appWindow;\n var activate = typeof win.toFront === \"function\" ? win.toFront : win.activate;\n if (typeof activate !== \"function\") return { status: \"unsupported\" };\n // DSM minimizes by hiding; show preserves maximized geometry, unlike restore.\n if (win.hidden === true || win.minimized === true ||\n (typeof win.isVisible === \"function\" && win.isVisible() === false)) {\n if (typeof win.show !== \"function\") return { status: \"unsupported\" };\n win.show();\n }\n activate.call(win);\n return { status: \"requested\" };\n } catch (error) { return { status: \"failed\" }; }\n },\n requestClose: function () {\n if (disposed) return { status: \"unavailable\" };\n try {\n if (inspectHostPlatform(ctx).host !== \"dsm\") return { status: \"unsupported\" };\n var win = ctx.appWindow;\n // DSM returns a short-circuit value; only truthy means standalone main.\n if (typeof win.close !== \"function\" || typeof win.isStandaloneMainWindow !== \"function\" ||\n win.isStandaloneMainWindow()) return { status: \"unsupported\" };\n if (win.canClose === false) return { status: \"blocked\" };\n win.close();\n return { status: \"requested\" };\n } catch (error) { return { status: \"failed\" }; }\n },\n openApp: function (appId) { return launchApp(appId, {}); },\n openURL: function (raw) {\n if (disposed || typeof window === \"undefined\") return { status: \"unavailable\" };\n if (typeof raw !== \"string\" || !raw || /[\\s\\x00-\\x1f\\x7f]/.test(raw)) return { status: \"invalid-input\" };\n if (typeof URL !== \"function\") return { status: \"unsupported\" };\n var url;\n try { url = new URL(raw); } catch (error) { return { status: \"invalid-input\" }; }\n if ((url.protocol !== \"http:\" && url.protocol !== \"https:\") || url.username || url.password) return { status: \"invalid-input\" };\n if (typeof window.open !== \"function\") return { status: \"unsupported\" };\n try {\n var opened = window.open(url.href, \"_blank\", \"noopener,noreferrer\");\n return { status: \"requested\", windowHandleAvailable: !!opened };\n } catch (error) { return { status: \"failed\" }; }\n },\n openFileManager: function (path) { return navigateFileStation(path, true); },\n revealFile: function (path) { return navigateFileStation(path, false); },\n openFile: unsupportedOperation,\n showFileDetails: unsupportedOperation,\n openAppSetting: unsupportedOperation,\n subscribe: function (event, listener) {\n if (disposed) return { status: \"unavailable\" };\n if (typeof listener !== \"function\") return { status: \"invalid-input\" };\n if (event !== \"window-resize\") return { status: \"unsupported\" };\n var win, callback = listener, active = true;\n function handler() { if (!disposed && callback) callback(); }\n function unsubscribe() {\n if (!active) return;\n active = false;\n callback = null;\n var target = win;\n win = null;\n var index = subscriptions.indexOf(unsubscribe);\n if (index !== -1) subscriptions.splice(index, 1);\n try { if (target) target.un(\"resize\", handler, target); } catch (error) {}\n }\n try {\n if (inspectHostPlatform(ctx).host !== \"dsm\") return { status: \"unsupported\" };\n win = ctx.appWindow;\n if (typeof win.on !== \"function\" || typeof win.un !== \"function\") return { status: \"unsupported\" };\n subscriptions.push(unsubscribe);\n win.on(\"resize\", handler, win);\n if (disposed) return { status: \"unavailable\" };\n return Object.freeze({ status: \"subscribed\", dispose: unsubscribe });\n } catch (error) {\n unsubscribe();\n return { status: \"failed\" };\n }\n },\n dispose: function () {\n if (disposed) return;\n disposed = true;\n if (revealRecovery) revealRecovery.cancel();\n revealRecovery = null;\n subscriptions.slice().forEach(function (unsubscribe) { unsubscribe(); });\n ctx = null;\n }\n });\n}";
|
|
3
3
|
/** A static, per-package factory for independent web shells; no Ext or transport. */
|
|
4
4
|
export declare function renderHostAdapterScript(packageId: string): string;
|
|
5
5
|
export declare function hostScriptName(build: number): string;
|
|
@@ -29,7 +29,7 @@ function createHostAdapter(ctx) {
|
|
|
29
29
|
revealRecovery = null;
|
|
30
30
|
function launchFile(appId, config) {
|
|
31
31
|
if (isDirectory) return launchApp(appId, config);
|
|
32
|
-
var recovery = createHostFileRevealRecovery(
|
|
32
|
+
var recovery = createHostFileRevealRecovery(config.openfile, function () { return !disposed && revealRecovery === recovery; });
|
|
33
33
|
revealRecovery = recovery;
|
|
34
34
|
var result = launchApp(appId, config, recovery.receive);
|
|
35
35
|
if (result.status !== "requested") recovery.cancel();
|
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Shared virtual-path validation and File Station request shaping. The rule is
|
|
3
|
+
* the SDK's (ts/src/dsm/file-station.ts): a DSM volume mount is stripped, and
|
|
4
|
+
* anything but one canonical shared-folder path is invalid-input.
|
|
5
|
+
*/
|
|
6
|
+
export declare const HOST_FILE_NAVIGATION_RUNTIME = "\nfunction requestHostFileNavigation(launch, path, isDirectory) {\n if (typeof path !== \"string\" || path.length > 4096 || path.charAt(0) !== \"/\" ||\n /[\\x00-\\x1f\\x7f\\\\]/.test(path)) return { status: \"invalid-input\" };\n var volume = /^\\/volume(?:USB|SATA)?[0-9]+(?=\\/|$)/.exec(path);\n if (volume) path = path.slice(volume[0].length);\n var parts = path.split(\"/\");\n if (parts.length < 2 || parts.slice(1).some(function (part) {\n return !part || part === \".\" || part === \"..\";\n })) return { status: \"invalid-input\" };\n var result = launch(\"SYNO.SDS.App.FileStation3.Instance\", isDirectory ? { opendir: path } : { openfile: path });\n if (result.status !== \"requested\") return result;\n return { status: \"requested\", action: isDirectory ? \"locate-folder\" : \"highlight-file\" };\n}\n";
|
|
3
7
|
export declare const REVEAL_RECOVERY_TIMEOUT_MS = 5000;
|
|
4
8
|
export declare const REVEAL_RECOVERY_POLL_MS = 100;
|
|
5
9
|
export declare const REVEAL_RECOVERY_QUIET_MS = 300;
|
|
@@ -1,10 +1,16 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* Shared virtual-path validation and File Station request shaping. The rule is
|
|
3
|
+
* the SDK's (ts/src/dsm/file-station.ts): a DSM volume mount is stripped, and
|
|
4
|
+
* anything but one canonical shared-folder path is invalid-input.
|
|
5
|
+
*/
|
|
2
6
|
export const HOST_FILE_NAVIGATION_RUNTIME = `
|
|
3
7
|
function requestHostFileNavigation(launch, path, isDirectory) {
|
|
4
8
|
if (typeof path !== "string" || path.length > 4096 || path.charAt(0) !== "/" ||
|
|
5
9
|
/[\\x00-\\x1f\\x7f\\\\]/.test(path)) return { status: "invalid-input" };
|
|
10
|
+
var volume = /^\\/volume(?:USB|SATA)?[0-9]+(?=\\/|$)/.exec(path);
|
|
11
|
+
if (volume) path = path.slice(volume[0].length);
|
|
6
12
|
var parts = path.split("/");
|
|
7
|
-
if (
|
|
13
|
+
if (parts.length < 2 || parts.slice(1).some(function (part) {
|
|
8
14
|
return !part || part === "." || part === "..";
|
|
9
15
|
})) return { status: "invalid-input" };
|
|
10
16
|
var result = launch("SYNO.SDS.App.FileStation3.Instance", isDirectory ? { opendir: path } : { openfile: path });
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type ExecutorInspection } from "../runtime/executor-inspect.js";
|
|
2
|
+
import { type AppScriptName } from "./app-scripts.js";
|
|
2
3
|
import type { EntryMode } from "./manifest.js";
|
|
3
4
|
import { type UninstallCleanupItem } from "../runtime/uninstall-cleanup.js";
|
|
4
5
|
export type SpkKind = "native-go" | "native-python" | "native-node" | "native-rust" | "binary" | "container" | "native-layout";
|
|
@@ -30,11 +31,19 @@ export interface SpkInspection {
|
|
|
30
31
|
};
|
|
31
32
|
/** Declared `entry` mode; `admin-only` is the administrator floor of every route. */
|
|
32
33
|
entry?: EntryMode;
|
|
34
|
+
/** The App's own scripts/app/<DSM script name>, each called from the generated script of that name. */
|
|
35
|
+
appScripts?: AppScriptName[];
|
|
33
36
|
/** PACKAGE_ICON_256.PNG: the SDK default, a custom icon (by digest) or none. */
|
|
34
37
|
icon?: {
|
|
35
38
|
source: "default" | "custom" | "none";
|
|
36
39
|
sha256?: string;
|
|
37
40
|
};
|
|
41
|
+
/** The launcher's Login Portal style: its colour, whether the wallpaper is the CLI default, and what titles the login page. */
|
|
42
|
+
loginStyle?: {
|
|
43
|
+
color: string;
|
|
44
|
+
wallpaper: "default" | "custom";
|
|
45
|
+
title: "custom" | "app-icon" | "none";
|
|
46
|
+
};
|
|
38
47
|
}
|
|
39
48
|
/** Reads an SPK without installing it and reports how it would run on DSM. */
|
|
40
49
|
export declare function inspectStandaloneSpk(spkPath: string): Promise<SpkInspection>;
|
|
@@ -2,7 +2,8 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { inspectNativeLayout } from "./native-layout.js";
|
|
3
3
|
import { checkBridgeApp, checkBridgeExecutor } from "./inspect-bridge.js";
|
|
4
4
|
import { inspectExecutor, inspectExecutorAnchor } from "../runtime/executor-inspect.js";
|
|
5
|
-
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
5
|
+
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
6
|
+
import { unwrapAppScripts } from "./app-scripts.js";
|
|
6
7
|
import { tmpdir } from "node:os";
|
|
7
8
|
import { join } from "node:path";
|
|
8
9
|
import * as tar from "tar";
|
|
@@ -26,6 +27,7 @@ import { uninstallCleanup } from "../runtime/uninstall-cleanup.js";
|
|
|
26
27
|
import { PORTAL_JSON_RELPATH, PORTAL_MUSTACHE_RELPATH, renderPortalJson, renderPortalLifecycle, renderPortalMustache, renderPortalWizardStep } from "../portal/package.js";
|
|
27
28
|
import { isPortalAlias } from "../portal/alias.js";
|
|
28
29
|
import { defaultPackageIcons, MAX_ICON_BYTES } from "../build-spk/dsm-icons.js";
|
|
30
|
+
import { isDefaultLoginAsset } from "../build-spk/login-style.js";
|
|
29
31
|
import { decodePng } from "../build-spk/png.js";
|
|
30
32
|
/** The SPK kind of each sdk-package.json backend a CGI is built for: Go itself, or the runtime of an App in another language. */
|
|
31
33
|
const COMPILED_KIND = { go: "native-go", python: "native-python", node: "native-node", rust: "native-rust" };
|
|
@@ -80,6 +82,8 @@ export async function inspectSpk(spkPath, expectedKind) {
|
|
|
80
82
|
const info = readInfo(join(dir, "INFO"), findings, kind);
|
|
81
83
|
await checkArch(dir, info, kind, paths.has(CGI_PAYLOAD_PATH), findings);
|
|
82
84
|
const background = compiledKind && paths.has(BACKGROUND_WORKER_PAYLOAD);
|
|
85
|
+
// Before any fixed-lifecycle comparison: those see the SDK steps alone.
|
|
86
|
+
const appScripts = standalone || kind === "container" ? unwrapAppScripts(join(dir, "scripts"), findings) : [];
|
|
83
87
|
const sharedFolder = standalone ? recoverSharedFolder(readOptional(join(dir, "scripts/postinst"))) : undefined;
|
|
84
88
|
// A Compose App has no executor, so its SPK never carries a portal.
|
|
85
89
|
const portal = standalone ? await recoverPortal(dir, info, `SYNO.SDS.${info.package}.Instance`, paths, payload, findings) : undefined;
|
|
@@ -136,6 +140,7 @@ export async function inspectSpk(spkPath, expectedKind) {
|
|
|
136
140
|
}
|
|
137
141
|
if (metadata.entry)
|
|
138
142
|
await checkEntryLauncher(dir, info.package, metadata.entry, findings);
|
|
143
|
+
const loginStyle = standalone ? await inspectLoginStyle(dir, info.package, Boolean(portal || executor?.policy.portal), findings) : undefined;
|
|
139
144
|
if (standalone)
|
|
140
145
|
checkSharedFolder(dir, findings, info.package, sharedFolder, executor?.policy.scope === 'install-selected', portal, hasExecutor);
|
|
141
146
|
const icon = inspectPackageIcons(dir, findings);
|
|
@@ -153,6 +158,8 @@ export async function inspectSpk(spkPath, expectedKind) {
|
|
|
153
158
|
...(portal ? { portal: { appId: portal.appId, alias: portal.alias } } : {}),
|
|
154
159
|
...(executor?.policy.portal ? { portal: { appId: executor.policy.app, alias: executor.policy.portal.alias } } : {}),
|
|
155
160
|
...(metadata.entry ? { entry: metadata.entry } : {}),
|
|
161
|
+
...(appScripts.length ? { appScripts } : {}),
|
|
162
|
+
...(loginStyle ? { loginStyle } : {}),
|
|
156
163
|
...(standalone ? { uninstallCleanup: uninstallCleanup(info.package, { executor: hasExecutor, portal: Boolean(portal), portalEntry: Boolean(executor?.policy.portal) }) } : {}),
|
|
157
164
|
package: info.package,
|
|
158
165
|
version: info.version,
|
|
@@ -185,6 +192,47 @@ async function checkEntryLauncher(dir, pkg, entry, findings) {
|
|
|
185
192
|
findings.push(`ENTRY_LAUNCHER_MISMATCH sdk-package.json declares entry: ${entry}, but the ui/config launcher is not ${JSON.stringify(expected)}; regenerate it with synopkgland build`);
|
|
186
193
|
}
|
|
187
194
|
}
|
|
195
|
+
const LOGIN_STYLE_FILES = ["defaultLoginWallpaper", "defaultLoginWallpaperThumbnail", "loginPreviewThumbnailMaskPath", "titleImgPath"];
|
|
196
|
+
/**
|
|
197
|
+
* The launcher's loginStyle and the ui/ files it names, at 1x and 2x. A portal
|
|
198
|
+
* App without one shows a blank Control Panel > Login Portal thumbnail.
|
|
199
|
+
*/
|
|
200
|
+
async function inspectLoginStyle(dir, pkg, portal, findings) {
|
|
201
|
+
const target = join(dir, "login-style-inspection");
|
|
202
|
+
mkdirSync(target);
|
|
203
|
+
await tar.x({ file: join(dir, "package.tgz"), cwd: target, filter: path => path.replace(/^\.\//, "").startsWith("ui/") });
|
|
204
|
+
let launcher;
|
|
205
|
+
try {
|
|
206
|
+
const config = JSON.parse(readFileSync(join(target, "ui/config"), "utf8"));
|
|
207
|
+
launcher = Object.values(config)[0]?.[`SYNO.SDS.${pkg}.Instance`];
|
|
208
|
+
}
|
|
209
|
+
catch { /* reported by the other checks */ }
|
|
210
|
+
const style = launcher?.loginStyle;
|
|
211
|
+
if (typeof style !== "object" || style === null) {
|
|
212
|
+
if (portal)
|
|
213
|
+
findings.push("LOGIN_STYLE_MISSING the Application Portal launcher has no loginStyle in ui/config, so Control Panel > Login Portal shows a blank thumbnail; rebuild with synopkgland build");
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
const fields = style;
|
|
217
|
+
const read = (field) => {
|
|
218
|
+
const path = fields[field];
|
|
219
|
+
if (path === undefined)
|
|
220
|
+
return undefined;
|
|
221
|
+
const files = typeof path === "string" && !path.split("/").includes("..") && !path.startsWith("/")
|
|
222
|
+
? ["1x", "2x"].map(scale => join(target, "ui", path.replace("{0}", scale))) : [];
|
|
223
|
+
if (!files.length || !files.every(file => existsSync(file))) {
|
|
224
|
+
findings.push(`LOGIN_STYLE_FILE_MISSING ui/config loginStyle.${field} names ui/${String(path)}, which the payload does not carry`);
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
return readFileSync(files[0]);
|
|
228
|
+
};
|
|
229
|
+
const [wallpaper] = LOGIN_STYLE_FILES.map(read);
|
|
230
|
+
return {
|
|
231
|
+
color: String(fields.mainColor ?? ""),
|
|
232
|
+
wallpaper: wallpaper && isDefaultLoginAsset("wallpaper.jpg", wallpaper) ? "default" : "custom",
|
|
233
|
+
title: fields.titleImgPath !== undefined ? "custom" : launcher.loginIcon !== undefined ? "app-icon" : "none",
|
|
234
|
+
};
|
|
235
|
+
}
|
|
188
236
|
/** Package Center shows PACKAGE_ICON at 64 and 256 px; the package source refuses a release whose icon is over 256 KiB. */
|
|
189
237
|
function inspectPackageIcons(dir, findings) {
|
|
190
238
|
for (const [name, size] of [["PACKAGE_ICON.PNG", 64], ["PACKAGE_ICON_256.PNG", 256]]) {
|
|
@@ -479,12 +527,18 @@ function checkScripts(dir, findings) {
|
|
|
479
527
|
if (!existsSync(join(scriptsDir, "start-stop-status"))) {
|
|
480
528
|
findings.push("scripts/start-stop-status is missing");
|
|
481
529
|
}
|
|
482
|
-
|
|
483
|
-
const
|
|
484
|
-
if (
|
|
485
|
-
|
|
530
|
+
const scan = (relative) => {
|
|
531
|
+
const path = join(dir, relative);
|
|
532
|
+
if (lstatSync(path).isDirectory()) {
|
|
533
|
+
for (const name of readdirSync(path))
|
|
534
|
+
scan(`${relative}/${name}`);
|
|
486
535
|
}
|
|
487
|
-
|
|
536
|
+
else if (ROOT_SCRIPT_HINTS.some((pattern) => pattern.test(readFileSync(path, "utf8")))) {
|
|
537
|
+
findings.push(`${relative} tries to gain root (sudo, chown root or setuid)`);
|
|
538
|
+
}
|
|
539
|
+
};
|
|
540
|
+
if (existsSync(scriptsDir))
|
|
541
|
+
scan("scripts");
|
|
488
542
|
}
|
|
489
543
|
const DATA_KEYS = ["pkgwizard_remove_additional", "delete_app_data"];
|
|
490
544
|
const DELETE_COMMAND = /\brm\s+-[a-zA-Z]*[rR]|\bfind\b[^\n]*(-delete|-exec\s+rm)/;
|
|
@@ -5,12 +5,14 @@ import { renderDelegateInstallDraft } from '../build-spk/delegate-scope.js';
|
|
|
5
5
|
import { renderPortalLifecycle } from "../portal/package.js";
|
|
6
6
|
import { nativePortal } from "./portal.js";
|
|
7
7
|
import { renderUninstallHook } from "./uninstall-hook.js";
|
|
8
|
+
import { composeAppScript } from "./app-scripts.js";
|
|
8
9
|
const UNINSTALL_ONLY = '[ "${SYNOPKG_PKG_STATUS:-}" = UPGRADE ] || synopkgland_uninstall_hook\n';
|
|
9
10
|
/** Package-account lifecycle hooks for CGI, daemon and background packages. */
|
|
10
11
|
export function renderStandaloneScript(name, manifest) {
|
|
11
12
|
const portal = nativePortal(manifest);
|
|
12
13
|
const script = renderCapabilityScript(name, manifest);
|
|
13
|
-
|
|
14
|
+
const sdk = portal ? script.replace("#!/bin/sh\n", () => `#!/bin/sh\n${renderPortalLifecycle(name, portal)}`) : script;
|
|
15
|
+
return manifest.appScripts?.some(app => app === name) ? composeAppScript(name, sdk) : sdk;
|
|
14
16
|
}
|
|
15
17
|
function renderCapabilityScript(name, manifest) {
|
|
16
18
|
if (name === 'preupgrade' && (manifest.capabilities.rootOps || manifest.capabilities.delegate)) {
|
|
@@ -8,10 +8,12 @@ import { type RootOp } from "./root-ops.js";
|
|
|
8
8
|
import type { RootSettings } from "../runtime/root-programs.js";
|
|
9
9
|
import { type Daemon } from "./daemon.js";
|
|
10
10
|
import { type Uninstall } from "./uninstall.js";
|
|
11
|
+
import { type AppScriptName } from "./app-scripts.js";
|
|
11
12
|
import { type Background } from "./background.js";
|
|
12
13
|
import { type SharedFolder } from "./shared-folder.js";
|
|
13
14
|
import { readApiPermissions, type ApiPermissions } from "./api-permissions.js";
|
|
14
15
|
import { type PortalDeclaration } from "../portal/alias.js";
|
|
16
|
+
import { type LoginStyle } from "../build-spk/login-style.js";
|
|
15
17
|
import { type SdkBackend } from "../runtime/sdk-source.js";
|
|
16
18
|
import { type WindowSize } from "./window-size.js";
|
|
17
19
|
import { type BridgeBackend } from "./bridge-backend.js";
|
|
@@ -81,8 +83,12 @@ export interface StandaloneManifest {
|
|
|
81
83
|
entry?: EntryMode;
|
|
82
84
|
/** `portal`: registers the DSM Application Portal entry; an administrator activates it. */
|
|
83
85
|
portal?: PortalDeclaration;
|
|
86
|
+
/** The project's dsm-scripts/<DSM script name> files, each run inside the generated script of that name. */
|
|
87
|
+
appScripts?: AppScriptName[];
|
|
84
88
|
/** `icon`: a square PNG of at least 256 px, or a directory of icon_<size>.png; absent uses the SDK default. */
|
|
85
89
|
icon?: string;
|
|
90
|
+
/** `login_style`: the portal login page's colour, wallpaper and title over the CLI defaults. */
|
|
91
|
+
loginStyle?: LoginStyle;
|
|
86
92
|
}
|
|
87
93
|
/**
|
|
88
94
|
* `admin-only`: DSM administrators only. `app-privilege`: administrators and
|
|
@@ -8,17 +8,20 @@ import { checkGlossary, loadCatalogs, readInfoTranslations } from "./i18n.js";
|
|
|
8
8
|
import { loadRootOps, readRootSettings, rootBinaryProblem, ROOT_OPS_DIR } from "./root-ops.js";
|
|
9
9
|
import { readDaemon } from "./daemon.js";
|
|
10
10
|
import { readUninstall } from "./uninstall.js";
|
|
11
|
+
import { readAppScripts } from "./app-scripts.js";
|
|
11
12
|
import { readBackground } from "./background.js";
|
|
12
13
|
import { DEFAULT_MIN_DSM, isDsmAtLeast, PORTAL_EXECUTOR_MIN_DSM, portalByExecutor, portalMinDsmMessage } from "../build-spk/dsm-version.js";
|
|
13
14
|
import { readSharedFolder } from "./shared-folder.js";
|
|
14
15
|
import { API_PERMISSIONS_FILE, floorAtAdministrators, permitsOrdinaryUsers, readApiPermissions } from "./api-permissions.js";
|
|
15
16
|
import { PORTAL_REQUIRES_EXECUTOR, portalEntryProblem, readPortalDeclaration } from "../portal/alias.js";
|
|
16
17
|
import { renderPackageIcons } from "../build-spk/dsm-icons.js";
|
|
18
|
+
import { LOGIN_COLOR, loginImageProblem } from "../build-spk/login-style.js";
|
|
17
19
|
import { validateReproducible } from "../reproducible-config.js";
|
|
18
20
|
import { appSdkVersion, SDK_MODULE } from "../runtime/sdk-source.js";
|
|
19
21
|
import { sdkProblems } from "./sdk-check.js";
|
|
20
22
|
import { readWindowSize } from "./window-size.js";
|
|
21
|
-
import {
|
|
23
|
+
import { appSdkTimeouts } from "../runtime/sdk-timeouts.js";
|
|
24
|
+
import { requestTimeoutSeconds, rootRunDeadlineProblem, TIMEOUT_USAGE, validTimeoutSeconds } from "./request-timeout.js";
|
|
22
25
|
import { BRIDGE_RUNTIMES, isBridgeBackend, overlaps, readBridgeBackend } from "./bridge-backend.js";
|
|
23
26
|
/**
|
|
24
27
|
* `dsm-package.yaml`: a Standalone Package, i.e. an unsigned native SPK that
|
|
@@ -27,7 +30,7 @@ import { BRIDGE_RUNTIMES, isBridgeBackend, overlaps, readBridgeBackend } from ".
|
|
|
27
30
|
export const STANDALONE_MANIFEST = "dsm-package.yaml";
|
|
28
31
|
export const KNOWN_FIELDS = new Set([
|
|
29
32
|
"schema", "package", "version", "build", "displayname", "description",
|
|
30
|
-
"metadata", "maintainer", "os_min_ver", "arches", "backend", "ui", "capabilities", "i18n", "daemon", "install", "uninstall", "entry", "portal", "icon", "reproducible", "root",
|
|
33
|
+
"metadata", "maintainer", "os_min_ver", "arches", "backend", "ui", "capabilities", "i18n", "daemon", "install", "uninstall", "entry", "portal", "icon", "reproducible", "root", "login_style",
|
|
31
34
|
]);
|
|
32
35
|
const I18N_FIELDS = new Set(["messages", "glossary", "info"]);
|
|
33
36
|
const PACKAGE_ID = /^[A-Za-z][A-Za-z0-9]{1,63}$/;
|
|
@@ -91,7 +94,7 @@ export function loadStandaloneManifest(root) {
|
|
|
91
94
|
function readManifest(raw, c) {
|
|
92
95
|
for (const key of Object.keys(raw)) {
|
|
93
96
|
if (!KNOWN_FIELDS.has(key)) {
|
|
94
|
-
c.fail("STANDALONE_FIELD_UNKNOWN", `Unsupported field "${key}". A Standalone Package does not accept
|
|
97
|
+
c.fail("STANDALONE_FIELD_UNKNOWN", `Unsupported field "${key}". A Standalone Package does not accept DSM conf; put lifecycle scripts in dsm-scripts/<DSM script name>.`);
|
|
95
98
|
}
|
|
96
99
|
}
|
|
97
100
|
if (raw.reproducible !== undefined) {
|
|
@@ -139,6 +142,16 @@ function readManifest(raw, c) {
|
|
|
139
142
|
if (binaryProblem)
|
|
140
143
|
c.fail("STANDALONE_ROOT_PROGRAMS_INVALID", binaryProblem);
|
|
141
144
|
const backend = arches && readBackend(raw.backend, arches, c);
|
|
145
|
+
if (backend && backend.kind !== "binary" && rootOps) {
|
|
146
|
+
// root.run may run a program without settings (its defaults) or with run.
|
|
147
|
+
// The SDK's spec/protocol/v1 Timeouts say how long the executor and its client wait.
|
|
148
|
+
const timeouts = appSdkTimeouts(backend);
|
|
149
|
+
const runnable = rootOps.map(op => ({ op, setting: rootPrograms?.programs?.[op.name] })).filter(({ setting }) => !setting || setting.run)
|
|
150
|
+
.map(({ op, setting }) => ({ name: op.name, timeoutSeconds: setting?.run?.timeout_s ?? timeouts.rootRunDefaultSeconds }));
|
|
151
|
+
const problem = rootRunDeadlineProblem(runnable, requestTimeoutSeconds(backend), timeouts.rootRunRequestMarginSeconds);
|
|
152
|
+
if (problem)
|
|
153
|
+
c.fail("STANDALONE_REQUEST_TIMEOUT_SHORT", problem);
|
|
154
|
+
}
|
|
142
155
|
let permissions;
|
|
143
156
|
if (backend && backend.kind !== "binary") {
|
|
144
157
|
try {
|
|
@@ -200,6 +213,7 @@ function readManifest(raw, c) {
|
|
|
200
213
|
const i18n = raw.i18n === undefined ? undefined : readI18n(raw.i18n, c);
|
|
201
214
|
const icon = raw.icon === undefined ? undefined : readIcon(raw.icon, c);
|
|
202
215
|
const uninstall = readUninstall(raw.uninstall, { rootOps: capabilities.rootOps === true, ops: rootOps, en: i18n?.catalogs?.en }, e => c.fail(e.code, e.message));
|
|
216
|
+
const appScripts = readAppScripts(c.root, e => c.fail(e.code, e.message));
|
|
203
217
|
const portal = raw.portal === undefined || !packageId ? undefined : readPortalDeclaration(raw.portal, packageId)
|
|
204
218
|
?? c.fail("STANDALONE_PORTAL_INVALID", "portal must be {} or { alias: <1-64 lowercase alphanumerics or -, not a DSM reserved path> }.");
|
|
205
219
|
if (portal && !capabilities.rootOps && !capabilities.delegate) {
|
|
@@ -212,6 +226,7 @@ function readManifest(raw, c) {
|
|
|
212
226
|
? portalEntryProblem(packageId, { application: displayname, alias: portal.alias }) : undefined;
|
|
213
227
|
if (entryProblem)
|
|
214
228
|
c.fail("STANDALONE_PORTAL_ENTRY_INVALID", entryProblem);
|
|
229
|
+
const loginStyle = raw.login_style === undefined ? undefined : readLoginStyle(raw.login_style, raw.portal !== undefined, c);
|
|
215
230
|
if (!packageId || !version || !build || !osMinVer || !displayname || !description || !maintainer
|
|
216
231
|
|| !arches || !backend || !ui || (capabilities.rootOps === true && !rootOps)) {
|
|
217
232
|
return undefined;
|
|
@@ -241,6 +256,8 @@ function readManifest(raw, c) {
|
|
|
241
256
|
...(entry ? { entry } : {}),
|
|
242
257
|
...(portal ? { portal } : {}),
|
|
243
258
|
...(icon ? { icon } : {}),
|
|
259
|
+
...(loginStyle ? { loginStyle } : {}),
|
|
260
|
+
...(appScripts ? { appScripts } : {}),
|
|
244
261
|
};
|
|
245
262
|
}
|
|
246
263
|
function checkBridgeMethods(backend, permissions, c) {
|
|
@@ -281,6 +298,37 @@ function readIcon(value, c) {
|
|
|
281
298
|
return c.fail("STANDALONE_ICON_INVALID", `icon: ${error.message}`);
|
|
282
299
|
}
|
|
283
300
|
}
|
|
301
|
+
const LOGIN_STYLE_FIELDS = ["color", "wallpaper", "wallpaper_thumbnail", "title"];
|
|
302
|
+
/** `login_style`: a portal's colour, wallpaper (with its thumbnail) and title image, each optional. */
|
|
303
|
+
function readLoginStyle(value, portal, c) {
|
|
304
|
+
const invalid = (message) => c.fail("STANDALONE_LOGIN_STYLE_INVALID", message);
|
|
305
|
+
if (!isRecord(value))
|
|
306
|
+
return invalid("login_style must be a mapping with color, wallpaper, wallpaper_thumbnail or title.");
|
|
307
|
+
if (!portal)
|
|
308
|
+
return invalid("login_style styles the Application Portal login page; declare portal as well, or remove login_style.");
|
|
309
|
+
const unknown = Object.keys(value).filter(key => !LOGIN_STYLE_FIELDS.includes(key));
|
|
310
|
+
if (unknown.length)
|
|
311
|
+
return invalid(`login_style does not accept ${unknown.join(", ")}; use ${LOGIN_STYLE_FIELDS.join(", ")}.`);
|
|
312
|
+
if (value.color !== undefined && (typeof value.color !== "string" || !LOGIN_COLOR.test(value.color))) {
|
|
313
|
+
return invalid('login_style.color must be a hex colour such as "#0F766E".');
|
|
314
|
+
}
|
|
315
|
+
if ((value.wallpaper === undefined) !== (value.wallpaper_thumbnail === undefined)) {
|
|
316
|
+
return invalid("login_style.wallpaper and login_style.wallpaper_thumbnail go together: DSM uses a package wallpaper only when both are set.");
|
|
317
|
+
}
|
|
318
|
+
const image = (field) => {
|
|
319
|
+
if (value[field] === undefined)
|
|
320
|
+
return undefined;
|
|
321
|
+
const path = c.path(value[field], `login_style.${field}`);
|
|
322
|
+
const problem = path && loginImageProblem(field, path);
|
|
323
|
+
return problem ? c.fail("STANDALONE_LOGIN_STYLE_IMAGE_INVALID", `login_style.${field}: ${problem}`) : path;
|
|
324
|
+
};
|
|
325
|
+
const [wallpaper, thumbnail, title] = [image("wallpaper"), image("wallpaper_thumbnail"), image("title")];
|
|
326
|
+
return {
|
|
327
|
+
...(value.color !== undefined ? { color: value.color } : {}),
|
|
328
|
+
...(wallpaper && thumbnail ? { wallpaper: { image: wallpaper, thumbnail } } : {}),
|
|
329
|
+
...(title ? { title } : {}),
|
|
330
|
+
};
|
|
331
|
+
}
|
|
284
332
|
/** Where each backend kind's CGI main wraps its authenticator in the SDK entry gate. */
|
|
285
333
|
const ENTRY_GATE = {
|
|
286
334
|
go: { main: b => b.kind === "go" ? join(b.dir, b.main, "main.go") : "", call: "dsm.NewEntryGate(", sdk: `backend/go.mod must require ${SDK_MODULE}` },
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
/** The panel text by locale, keyed without the SDK's `portal.` prefix. */
|
|
2
|
+
export type PortalCatalogs = Record<string, Record<string, string>>;
|
|
3
|
+
/** The SDK's portal panel text (`ts/i18n/portal/<locale>.json` of the App's SDK), which the SDK owns and its Vue panel shows too. */
|
|
4
|
+
export declare function portalCatalogs(sdkDir: string): PortalCatalogs;
|
|
4
5
|
/**
|
|
5
6
|
* `createPortalAccess(options)` → `ctx.portal`, plain ES5 for DSM's desktop.
|
|
6
7
|
*
|
|
@@ -16,8 +17,7 @@ export declare const PORTAL_MESSAGES: Record<string, Record<PortalLocale, string
|
|
|
16
17
|
* status or reject `{code}`. With `options.entry` (below DSM 7.2) the
|
|
17
18
|
* backend's `portal.status` also reports the executor's entry, and
|
|
18
19
|
* administrators get `register()` and `unregister()`; `openControlPanel()` opens Login Portal > Applications.
|
|
19
|
-
* `messages` is the panel text in the window's locale; `mountPanel(element)`
|
|
20
|
+
* `messages` is the SDK's panel text in the window's locale; `mountPanel(element)`
|
|
20
21
|
* renders a plain panel for UIs without their own.
|
|
21
22
|
*/
|
|
22
|
-
export declare const
|
|
23
|
-
export {};
|
|
23
|
+
export declare const portalRuntime: (catalogs: PortalCatalogs) => string;
|