@synopackageland/cli 0.41.0 → 0.41.1
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 +18 -0
- package/README.md +19 -2
- package/dist/error-registry.js +5 -0
- package/dist/standalone/dsm-window.js +14 -3
- package/dist/standalone/host-adapter.d.ts +1 -1
- package/dist/standalone/host-adapter.js +13 -0
- package/dist/standalone/host-types.d.ts +1 -0
- package/dist/standalone/manifest.d.ts +2 -0
- package/dist/standalone/manifest.js +5 -1
- package/package.json +2 -2
- package/skills/SKILL.md +13 -2
- package/skills/reference/create.md +15 -4
- package/skills/reference/ctl.md +7 -0
- package/skills/reference/ui-vue.md +17 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 0.41.1 (2026-10-29)
|
|
6
|
+
|
|
7
|
+
- `ui.banner: shown | hidden` lets an App choose whether DSM draws its banner
|
|
8
|
+
(App name, account menu) above the App's standalone page. `shown`, the
|
|
9
|
+
default, keeps today's page. `hidden` drops it for this App only, as DSM's
|
|
10
|
+
global "Hide banner" setting does, without touching that setting or a
|
|
11
|
+
desktop window's title bar. Meant for Apps whose own frame names the App and
|
|
12
|
+
shows the account; the SDK's `AppShell` then shows the account and a Sign
|
|
13
|
+
out button. The host adapter's new `ctx.host.signOut()` signs out of DSM the
|
|
14
|
+
way the banner's menu does. `STANDALONE_UI_BANNER_INVALID` refuses other values.
|
|
15
|
+
|
|
16
|
+
- Sync the bundled skill with SDK/App 0.38.0: 300-second requests, 302-second
|
|
17
|
+
browser waits and existing background admission, progress and status polling.
|
|
18
|
+
Preserve explicit budgets, original job IDs/keys and ctl's 62-second transport.
|
|
19
|
+
- Select templates 0.35.1 with SDK/App 0.38.0 for new projects.
|
|
20
|
+
|
|
3
21
|
## 0.41.0 (2026-10-29)
|
|
4
22
|
|
|
5
23
|
- Add generic `ctl external --protocol <id> --host <profile> --input - --json`
|
package/README.md
CHANGED
|
@@ -82,7 +82,7 @@ alongside npm lockfiles. Do not disable checksum verification for public SDK
|
|
|
82
82
|
packages. Private mirrors can use explicitly configured Go authentication and
|
|
83
83
|
checksum settings; a failed fetch reports `SDK_DOWNLOAD_FAILED`.
|
|
84
84
|
|
|
85
|
-
CLI 0.41.
|
|
85
|
+
CLI 0.41.1 uses templates ~0.35.1, whose default SDK pin is 0.38.0. Compose import and SDK defaults
|
|
86
86
|
read the SDK pin from the installed templates; explicit App SDK declarations take precedence.
|
|
87
87
|
Every App method must be declared in the permissions table. Dynamic names and
|
|
88
88
|
custom HTTP dispatchers are rejected. With SDK 0.33.0, use fixed
|
|
@@ -93,7 +93,7 @@ unimported development commands are excluded. Use
|
|
|
93
93
|
authentication. See [App entry and registration](docs/app-entry.md).
|
|
94
94
|
|
|
95
95
|
`init` and `add` read their templates from the locally installed
|
|
96
|
-
`@synopackageland/templates`. CLI 0.41.
|
|
96
|
+
`@synopackageland/templates`. CLI 0.41.1 depends on `~0.35.1`: npm installation or
|
|
97
97
|
update can select the latest templates patch in the 0.35 minor line. Templates
|
|
98
98
|
patches need only a templates release; update the npm installation to receive them.
|
|
99
99
|
The CLI never fetches templates at runtime, so offline use works after installation;
|
|
@@ -332,6 +332,23 @@ a one-line reload notice. The wrapper sets the standalone
|
|
|
332
332
|
page title, favicon and device-width viewport. It retains ExtJS; `pureEnv` is
|
|
333
333
|
not appropriate, and alternate Portal ports are not needed (`allowAltPort` is omitted).
|
|
334
334
|
|
|
335
|
+
`ui.banner: shown | hidden` chooses whether DSM draws its banner (the App's
|
|
336
|
+
name and the account menu) above the App's standalone page. The default,
|
|
337
|
+
`shown`, keeps DSM's behavior. `hidden` drops it for this App only, with the
|
|
338
|
+
class DSM's global "Hide banner" setting uses, and leaves DSM's setting and a
|
|
339
|
+
DSM desktop window's title bar alone. Hide it when the App's own frame already
|
|
340
|
+
names the App and shows the account, for example a full-page web App built on
|
|
341
|
+
the SDK's `AppShell`: the shell then shows the signed-in account and a Sign out
|
|
342
|
+
button (`ctx.host.signOut()`, DSM's banner sign-out). Keep the banner when the
|
|
343
|
+
App has no account area of its own.
|
|
344
|
+
|
|
345
|
+
```yaml
|
|
346
|
+
ui:
|
|
347
|
+
dir: ui/dist
|
|
348
|
+
launch: new-page
|
|
349
|
+
banner: hidden
|
|
350
|
+
```
|
|
351
|
+
|
|
335
352
|
A full page does not need Application Portal. Omit `portal` unless an alias is
|
|
336
353
|
explicitly wanted; only that opt-in adds `defaultAlias`, login styling, Portal
|
|
337
354
|
panels and capabilities. No Portal is a healthy default for inspect/info/doctor.
|
package/dist/error-registry.js
CHANGED
|
@@ -553,6 +553,11 @@ export const ERROR_REGISTRY = {
|
|
|
553
553
|
description: "ui.window does not give a valid DSM window size.",
|
|
554
554
|
suggestion: "Give width and height as whole numbers from 320 to 3840 and 240 to 2160, keep minWidth and minHeight (700 and 420 by default) no larger than them, and write only the fields you change.",
|
|
555
555
|
},
|
|
556
|
+
STANDALONE_UI_BANNER_INVALID: {
|
|
557
|
+
code: "STANDALONE_UI_BANNER_INVALID",
|
|
558
|
+
description: "ui.banner is neither shown nor hidden.",
|
|
559
|
+
suggestion: "Set ui.banner to hidden to drop DSM's banner from the App's standalone page, or omit it to keep the banner.",
|
|
560
|
+
},
|
|
556
561
|
STANDALONE_UI_BUILD_FAILED: {
|
|
557
562
|
code: "STANDALONE_UI_BUILD_FAILED",
|
|
558
563
|
description: "The UI build command failed.",
|
|
@@ -225,6 +225,7 @@ export function renderWindowScript(manifest, assetVersion, icons = packageIcons(
|
|
|
225
225
|
const p = JSON.stringify(pkg);
|
|
226
226
|
const root = manifest.executor ? renderRootParts(ns, manifest) : undefined;
|
|
227
227
|
const size = manifest.ui.window ?? DEFAULT_WINDOW;
|
|
228
|
+
const hideBanner = manifest.ui.banner === "hidden";
|
|
228
229
|
const requestTimeout = requestTimeoutSeconds(manifest.backend) * 1000;
|
|
229
230
|
const clientMargin = appSdkTimeouts(manifest.backend).browserMarginMs;
|
|
230
231
|
const sessionRuntime = appSdkSessionRuntime(manifest.backend);
|
|
@@ -473,7 +474,15 @@ ${ns}.prepareFullPage = function (title) {
|
|
|
473
474
|
favicon.href = ${ns}.appIcon;
|
|
474
475
|
};
|
|
475
476
|
|
|
476
|
-
${
|
|
477
|
+
${hideBanner ? `// ui.banner: hidden. DSM's own "Hide banner" class on a standalone page; on the
|
|
478
|
+
// desktop it would hide the window's title bar, so the window keeps it there.
|
|
479
|
+
${ns}.hideBanner = function (config) {
|
|
480
|
+
if (!${ns}.fullPage()) return config;
|
|
481
|
+
config = Ext.apply({}, config);
|
|
482
|
+
config.cls = (config.cls ? config.cls + " " : "") + "sds-standalone-main-window--no-banner";
|
|
483
|
+
return config;
|
|
484
|
+
};
|
|
485
|
+
` : ""}${windowResumesOIDC(manifest) ? `${ns}.resumeOIDC = ${oidcResumeRuntime(pkg, Object.values(manifest.identityGateway.listen).filter((p) => typeof p === "number"))};` : ""}
|
|
477
486
|
|
|
478
487
|
// DSM reopens a window at the size it was last left at, even one saved on a
|
|
479
488
|
// larger screen, and only moves a window that does not fit: keep it inside.
|
|
@@ -495,8 +504,10 @@ Ext.define(${JSON.stringify(`${ns}.MainWindow`)}, {
|
|
|
495
504
|
this.callParent([${ns}.fitDesktop(Ext.apply({
|
|
496
505
|
width: ${size.width}, height: ${size.height}, minWidth: ${ns}.fullPage() ? ${FULL_PAGE_MIN_WIDTH} : ${size.minWidth}, minHeight: ${size.minHeight}, layout: "fit",
|
|
497
506
|
${root ? root.html : `html: '<div class="dsmapp-root" style="height:100%"></div>'`}
|
|
498
|
-
}, config))]);
|
|
499
|
-
}
|
|
507
|
+
}, ${hideBanner ? `${ns}.hideBanner(config)` : "config"}))]);
|
|
508
|
+
},${hideBanner ? `
|
|
509
|
+
// DSM draws the banner unless its global "Hide banner" is on; skip it for this App alone.
|
|
510
|
+
genIndPortHeader: function () {},` : ""}
|
|
500
511
|
getRestoreSizePos: function (config) {
|
|
501
512
|
return ${ns}.fitDesktop(Ext.apply({ width: config.width, height: config.height }, this.callParent(arguments)));
|
|
502
513
|
},
|
|
@@ -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 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}";
|
|
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 // DSM's own banner sign-out, for an App that hides the banner (ui.banner: hidden).\n signOut: function () {\n if (disposed) return { status: \"unavailable\" };\n try {\n if (inspectHostPlatform(ctx).host !== \"dsm\") return { status: \"unsupported\" };\n var sds = SYNO.SDS, logout = sds.Utils && sds.Utils.Logout;\n if (!logout || typeof logout.action !== \"function\") return { status: \"unsupported\" };\n if (sds.StatusNotifier) sds.StatusNotifier.fireEvent(\"logout\");\n window.onbeforeunload = sds.onBasicBeforeUnload;\n logout.action();\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;
|
|
@@ -95,6 +95,19 @@ function createHostAdapter(ctx) {
|
|
|
95
95
|
return { status: "requested" };
|
|
96
96
|
} catch (error) { return { status: "failed" }; }
|
|
97
97
|
},
|
|
98
|
+
// DSM's own banner sign-out, for an App that hides the banner (ui.banner: hidden).
|
|
99
|
+
signOut: function () {
|
|
100
|
+
if (disposed) return { status: "unavailable" };
|
|
101
|
+
try {
|
|
102
|
+
if (inspectHostPlatform(ctx).host !== "dsm") return { status: "unsupported" };
|
|
103
|
+
var sds = SYNO.SDS, logout = sds.Utils && sds.Utils.Logout;
|
|
104
|
+
if (!logout || typeof logout.action !== "function") return { status: "unsupported" };
|
|
105
|
+
if (sds.StatusNotifier) sds.StatusNotifier.fireEvent("logout");
|
|
106
|
+
window.onbeforeunload = sds.onBasicBeforeUnload;
|
|
107
|
+
logout.action();
|
|
108
|
+
return { status: "requested" };
|
|
109
|
+
} catch (error) { return { status: "failed" }; }
|
|
110
|
+
},
|
|
98
111
|
openApp: function (appId) { return launchApp(appId, {}); },
|
|
99
112
|
openURL: function (raw) {
|
|
100
113
|
if (disposed || typeof window === "undefined") return { status: "unavailable" };
|
|
@@ -37,6 +37,7 @@ export interface HostAdapter {
|
|
|
37
37
|
setTitle(title: string): HostTitleResult;
|
|
38
38
|
activateWindow(): HostRequestResult;
|
|
39
39
|
requestClose(): HostRequestResult;
|
|
40
|
+
signOut(): HostRequestResult;
|
|
40
41
|
openApp(appId: string): HostRequestResult;
|
|
41
42
|
openURL(url: string): HostURLResult;
|
|
42
43
|
openFileManager(path: string): HostFileNavigationResult;
|
|
@@ -79,6 +79,8 @@ export interface StandaloneManifest {
|
|
|
79
79
|
command: string[];
|
|
80
80
|
};
|
|
81
81
|
window?: WindowSize;
|
|
82
|
+
/** `ui.banner: hidden`: a standalone page drops DSM's banner (name, account menu), as DSM's own "Hide banner" does for every App. */
|
|
83
|
+
banner?: "hidden";
|
|
82
84
|
};
|
|
83
85
|
i18n?: StandaloneI18n;
|
|
84
86
|
capabilities: Capabilities;
|
|
@@ -880,8 +880,11 @@ function readUi(value, c) {
|
|
|
880
880
|
if (read && "error" in read)
|
|
881
881
|
return c.fail("STANDALONE_UI_WINDOW_INVALID", read.error);
|
|
882
882
|
const window = read ? { window: read.size } : {};
|
|
883
|
+
if (value.banner !== undefined && value.banner !== "shown" && value.banner !== "hidden")
|
|
884
|
+
return c.fail("STANDALONE_UI_BANNER_INVALID", "ui.banner must be shown or hidden.");
|
|
885
|
+
const banner = value.banner === "hidden" ? { banner: "hidden" } : {};
|
|
883
886
|
if (value.build === undefined) {
|
|
884
|
-
return dir ? { dir, ...launch, ...window } : undefined;
|
|
887
|
+
return dir ? { dir, ...launch, ...window, ...banner } : undefined;
|
|
885
888
|
}
|
|
886
889
|
const build = value.build;
|
|
887
890
|
const command = isRecord(build) ? build.command : undefined;
|
|
@@ -897,6 +900,7 @@ function readUi(value, c) {
|
|
|
897
900
|
build: { cwd, command: command },
|
|
898
901
|
...launch,
|
|
899
902
|
...window,
|
|
903
|
+
...banner,
|
|
900
904
|
}
|
|
901
905
|
: undefined;
|
|
902
906
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@synopackageland/cli",
|
|
3
|
-
"version": "0.41.
|
|
3
|
+
"version": "0.41.1",
|
|
4
4
|
"description": "Syno Package Land Developer Kit CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@napi-rs/keyring": "^2.1.1",
|
|
31
|
-
"@synopackageland/templates": "~0.35.
|
|
31
|
+
"@synopackageland/templates": "~0.35.1",
|
|
32
32
|
"ajv": "^8.17.1",
|
|
33
33
|
"commander": "^13.1.0",
|
|
34
34
|
"json-canonicalize": "^3.0.1",
|
package/skills/SKILL.md
CHANGED
|
@@ -5,7 +5,7 @@ description: Initialize, extend, check, build, inspect, and deploy Synology appl
|
|
|
5
5
|
|
|
6
6
|
# Independent Synology SPKs
|
|
7
7
|
|
|
8
|
-
The CLI installs templates through `~0.35.
|
|
8
|
+
The CLI installs templates through `~0.35.1`. A templates patch needs only a
|
|
9
9
|
templates release; npm install/update selects a patch in that minor line. The CLI
|
|
10
10
|
uses the locally installed templates and reads their SDK pin for defaults; it
|
|
11
11
|
never downloads templates at runtime. Preserve the lockfile and use `npm ci` for
|
|
@@ -24,7 +24,7 @@ Use this guide to initialize, build and inspect own applications or upstream OSS
|
|
|
24
24
|
|
|
25
25
|
For an existing open-source App, first read [reference/upstream-fork.md](reference/upstream-fork.md): upstream fork/history + untouched upstream files + all packaging in `synology/`, official container images pinned by tag/index digest, native official release binaries pinned by SHA-256 or a source build hook, and a single tag-merge upgrade command. Use `init --upstream-fork`; never rewrite the App as a component. Integrated upstream Apps require direct DSM administrator entry without a separate App password or an OIDC/SSO server: supported trusted headers first, disabling native login second; the shipped OpenList pattern uses the existing SDK 0.17.3 token adapter (`mode: token`, `access: dsm-admins`, `entry: admin-only`) with server-side Authorization injection. All DSM admins share one upstream admin; token reset requires package restart; WebDAV retains its native credentials.
|
|
26
26
|
|
|
27
|
-
Use SDK/App 0.
|
|
27
|
+
Use SDK/App 0.38.0 and CLI 0.41.1 for App entry enforcement. Declare every App method
|
|
28
28
|
in the permissions table; dynamic names and unregistered HTTP dispatchers are rejected.
|
|
29
29
|
Use fixed SDK routes and `identity_gateway.upstream_auth_paths` for client API
|
|
30
30
|
paths that rely on upstream authentication. Read
|
|
@@ -41,6 +41,15 @@ cannot upgrade a connection; use an existing upgrade-capable deployment
|
|
|
41
41
|
transport. The audit follows each configured Go binary's Linux import graph,
|
|
42
42
|
including declared workers; unshipped dev commands are excluded.
|
|
43
43
|
|
|
44
|
+
SDK/App 0.38.0 allows 300 seconds for requests by default, with the browser
|
|
45
|
+
waiting 302 seconds. Explicit budgets and earlier parent deadlines still win.
|
|
46
|
+
For long work, use existing independent workers with prompt job admission,
|
|
47
|
+
progress and authorized status/result polling by the original ID. A timed-out
|
|
48
|
+
status read does not fail a queued/running job; reuse the same key/input after
|
|
49
|
+
a lost admission reply. Read [reference/create.md](reference/create.md) for
|
|
50
|
+
the deadline and background-job contract. The independent ctl transport keeps
|
|
51
|
+
its 62-second default; use an explicit timeout or an existing nonblocking job.
|
|
52
|
+
|
|
44
53
|
Use `ipc.StartUnix(ctx, path, handler)` for multiple private data-plane sockets.
|
|
45
54
|
Each direct `.sock` child of the package `var/run` gets an independent handle
|
|
46
55
|
with Close, Shutdown and Wait. Stop/revoke closes active streams and hijacked
|
|
@@ -268,6 +277,8 @@ Scaffold 明確寫入此選擇,plain/binary 使用 `new-page`,Vue UI SDK
|
|
|
268
277
|
`ctx.launchParams`/`onLaunch`、`entry` 權限沿用共用 wrapper;全頁的標題、
|
|
269
278
|
favicon 與手機 viewport 由 wrapper 設定。不需要 `pureEnv` 或 `allowAltPort`。
|
|
270
279
|
|
|
280
|
+
`ui.banner: hidden` 只替這個 App 的全頁拿掉 DSM 橫幅(預設 `shown` 保留);適合 App 外框已有名稱與帳號區時,帳號與登出改由 SDK `AppShell` 負責。細節見 reference/ui-vue.md。
|
|
281
|
+
|
|
271
282
|
Application Portal 是獨立 opt-in:不想要別名就省略 `portal`,不生成
|
|
272
283
|
`defaultAlias`、Portal 面板/capability 或 Web Station alias;沒有 Portal
|
|
273
284
|
不是 inspect/info/doctor 問題。明確啟用 `portal` 時才將其 alias 作為 `defaultAlias`。
|
|
@@ -62,19 +62,19 @@ backend: {runtime: rust, dir: app, entry: hello, methods: [hello.greet]}
|
|
|
62
62
|
|
|
63
63
|
## 請求期限與視窗尺寸
|
|
64
64
|
|
|
65
|
-
|
|
65
|
+
SDK/App 0.38.0 的 backend 請求期限預設 300 秒,DSM 視窗與 TS client 預設等候 302 秒。CLI 從 App 所選 SDK 的 timeout spec 讀取預設;`backend.timeout_seconds`(1–3600 的整數)可明確覆寫,go、python、node、rust 都適用,較早的 parent deadline 仍優先。到期時 App 回 `request_timeout`;DSM 視窗把工作期限放進 `ctx.requestTimeout`(毫秒),`ctx.call` 與 SDK 瀏覽器 client 多等 2 秒,讓 backend 的回覆先到。已安裝的 App、舊 SDK 或既有明確 timeout 不會自動改變;需使用匹配的 Go/TS SDK 重建。
|
|
66
66
|
|
|
67
67
|
- python、node、rust:build 把它寫進 `bridge.json` 的 `timeout_seconds`;runtime 以它為期限,傳給 App 的 `deadline_ms` 是送出呼叫時剩下的時間。
|
|
68
|
-
- go:build 以 `-X main.requestTimeout=<秒>` 傳入。要寫 `timeout_seconds`,`main.go` 必須讀這個變數,否則 check 回 `STANDALONE_TIMEOUT_UNSUPPORTED
|
|
68
|
+
- go:build 以 `-X main.requestTimeout=<秒>` 傳入。要寫 `timeout_seconds`,`main.go` 必須讀這個變數,否則 check 回 `STANDALONE_TIMEOUT_UNSUPPORTED`;未設定時使用所選 SDK 的 `api.DefaultRequestTimeout`:
|
|
69
69
|
|
|
70
70
|
```go
|
|
71
|
-
// Set by synopkgland build through -ldflags -X: backend.timeout_seconds
|
|
71
|
+
// Set by synopkgland build through -ldflags -X: backend.timeout_seconds.
|
|
72
72
|
var requestTimeout = ""
|
|
73
73
|
|
|
74
74
|
func deadline() api.DeadlineOption {
|
|
75
75
|
seconds, err := strconv.Atoi(requestTimeout)
|
|
76
76
|
if err != nil || seconds <= 0 {
|
|
77
|
-
|
|
77
|
+
return api.Timeout(api.DefaultRequestTimeout)
|
|
78
78
|
}
|
|
79
79
|
return api.Timeout(time.Duration(seconds) * time.Second)
|
|
80
80
|
}
|
|
@@ -84,6 +84,17 @@ func deadline() api.DeadlineOption {
|
|
|
84
84
|
|
|
85
85
|
- binary:預先建置的 CGI 自己決定期限,不接受 `timeout_seconds`;視窗仍等 62 秒。
|
|
86
86
|
|
|
87
|
+
長時間 NAS 工作沿用既有 jobs 與獨立 worker:
|
|
88
|
+
|
|
89
|
+
1. 驗證固定 operation 與可信 owner,以 `NewRequestStore` / `SubmitSingleton` 或 `Submit` admission,立即回覆既有 outcome 與原 job ID。
|
|
90
|
+
2. 在套件帳號的獨立 worker 或 resident daemon 執行 `Runner.Work`,用 `Task.Report` 回報 phase/progress;未知總量保持 unknown。SDK 預設 job 與 delegated download 期限為 30 分鐘,可由可信組裝設定有限期限。
|
|
91
|
+
3. 每次 status/result/cancel 都重新驗證權限,以原 ID 有界退避輪詢。`queued` / `running` 顯示仍在工作;慢 NAS 或 status read timeout 只代表狀態暫時無法取得,保留最後狀態,不判定 job 失敗。
|
|
92
|
+
4. admission 回覆遺失時,沿用相同 idempotency key 與 input,或查詢已知 ID;不可因逾時用新 key 重送已派送 mutation。只有 terminal state 能決定完成;取消無法撤回已發生的遠端效果。
|
|
93
|
+
|
|
94
|
+
直接 File Station 瀏覽器讀取預設也為 302 秒;identity gateway 等候 upstream headers 預設 310 秒,自訂期限需對齊 gateway 與外部 proxy。這些預設不保證所有 DSM/proxy 都能完成五分鐘請求。executor batch 的 60 秒上限、已核准 root policy/program budget 與 credential lifetime 維持原契約;長 root 工作使用既有 `root.start` / `root.jobs.*`。
|
|
95
|
+
|
|
96
|
+
獨立 `ctl` transport 預設仍是 62 秒。長同步呼叫須明確指定 `--timeout`,或使用既有 nonblocking job flow;參考 [ctl](ctl.md)。完整契約見 SDK 的 [request deadlines](https://github.com/synopackageland/syno-pkg-land-sdk/blob/v0.38.0/docs/request-deadlines.md)。
|
|
97
|
+
|
|
87
98
|
DSM 視窗預設 960×640、最小 700×420;`ui.window` 只寫要改的欄位:
|
|
88
99
|
|
|
89
100
|
```yaml
|
package/skills/reference/ctl.md
CHANGED
|
@@ -132,6 +132,13 @@ every App supports cancellation or singleton deduplication.
|
|
|
132
132
|
|
|
133
133
|
For AI consumers:
|
|
134
134
|
|
|
135
|
+
The independent ctl transport defaults to 62 seconds even with SDK/App 0.38.0.
|
|
136
|
+
For a long synchronous call, set `--timeout` explicitly; for an existing job
|
|
137
|
+
flow, use `--no-wait` and the original ID. A status read timeout leaves the last
|
|
138
|
+
known queued/running state intact. If the App's admission API supports an
|
|
139
|
+
idempotency key, reuse the same key and input after a lost reply; never invent
|
|
140
|
+
that support or issue a new mutation key to work around a timeout.
|
|
141
|
+
|
|
135
142
|
1. Discover the real command schema and current jobs if listing is supported.
|
|
136
143
|
Dry-run mutations, obtain authorization, then submit once with `--yes` and
|
|
137
144
|
`--no-wait` when using a job flow. Keep host, App and original operation ID.
|
|
@@ -177,6 +177,23 @@ Scaffold 明確寫入此選擇,plain/binary 使用 `new-page`,Vue UI SDK
|
|
|
177
177
|
`ctx.launchParams`/`onLaunch`、`entry` 權限沿用共用 wrapper;全頁的標題、
|
|
178
178
|
favicon 與手機 viewport 由 wrapper 設定。不需要 `pureEnv` 或 `allowAltPort`。
|
|
179
179
|
|
|
180
|
+
`ui.banner: shown | hidden` 決定 App 全頁(standalone)上方要不要有 DSM 橫幅
|
|
181
|
+
(App 名稱與帳號選單)。預設 `shown`,維持 DSM 原本的樣子;`hidden` 只替這個 App
|
|
182
|
+
拿掉橫幅,用的是 DSM 全域「隱藏橫幅」同一個 class,不改 DSM 設定,DSM 桌面視窗也照樣有標題列。
|
|
183
|
+
|
|
184
|
+
什麼時候藏:App 自己的外框已經有 App 名稱與帳號區,例如用 SDK `AppShell` 的全頁 web App,
|
|
185
|
+
再疊一條 DSM 橫幅只是重複。藏掉後帳號與登出由 SDK 外框負責:`AppShell` 偵測到 DSM 沒畫帳號,
|
|
186
|
+
就在側欄底部顯示登入者與「登出」按鈕,登出呼叫 `ctx.host.signOut()`(照 DSM 橫幅選單的登出流程,
|
|
187
|
+
DSM 以外回 `unsupported`)。自己寫 `sidebar-footer` 的 App 要自己放登出,呼叫同一個方法。
|
|
188
|
+
App 沒有自己的帳號區時就保留橫幅,否則使用者會找不到登出。其他值 check 回 `STANDALONE_UI_BANNER_INVALID`。
|
|
189
|
+
|
|
190
|
+
```yaml
|
|
191
|
+
ui:
|
|
192
|
+
dir: ui/dist
|
|
193
|
+
launch: new-page
|
|
194
|
+
banner: hidden
|
|
195
|
+
```
|
|
196
|
+
|
|
180
197
|
Application Portal 是獨立 opt-in:不想要別名就省略 `portal`,不生成
|
|
181
198
|
`defaultAlias`、Portal 面板/capability 或 Web Station alias;沒有 Portal
|
|
182
199
|
不是 inspect/info/doctor 問題。明確啟用 `portal` 時才將其 alias 作為 `defaultAlias`。
|