@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 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.0 uses templates ~0.35.0, whose default SDK pin is 0.37.0. Compose import and SDK defaults
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.0 depends on `~0.35.0`: npm installation or
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.
@@ -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
- ${windowResumesOIDC(manifest) ? `${ns}.resumeOIDC = ${oidcResumeRuntime(pkg, Object.values(manifest.identityGateway.listen).filter((p) => typeof p === "number"))};` : ""}
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.0",
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.0",
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.0`. A templates patch needs only a
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.37.0 and CLI 0.41.0 for App entry enforcement. Declare every App method
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
- 所有 App 的 backend 請求期限預設 60 秒;`backend.timeout_seconds`(1–3600 的整數)改成別的值,go、python、node、rust 都適用。到期時 App 回 `request_timeout`;DSM 視窗把同一值放進 `ctx.requestTimeout`(毫秒),`ctx.call` 與 SDK 的瀏覽器 client 多等 2 秒,讓 backend 的回覆先到。超過期限的工作改用 jobs 或 root job,不要拉長同步請求。
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`;main 沒有讀它時,CGI 維持 SDK 的 20 秒:
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, 60 unless set.
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
- seconds = 60
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
@@ -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`。