@synopackageland/cli 0.13.1 → 0.14.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,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.14.1
4
+
5
+ - DSM 會改寫 standalone URL,因此沒有 SDK class 的 schema 1 web launcher 使用 url;schema 2 identity_gateway + compose 與原生 new-page 使用 standalone。兩條 Compose 路線共用預設解析。
6
+
7
+ ## 0.14.0
8
+
9
+ - 原生 App 支援 ui.launch: dsm-window | new-page;new-page 使用 DSM standalone launcher。SDK 原生 App 的內建預設維持 dsm-window;Docker/Web App(含 schema 2 identity_gateway + compose)共用解析邏輯,內建預設改為 new-page,主選單與套件中心皆可開啟既有 gateway/public alias。inspect 驗證 launcher 與 Portal 宣告,完整頁統一設定標題、favicon 與手機 viewport。Web App 省略開啟方式的行為變更,因此採用 0.14.0。
10
+
3
11
  ## 0.13.1 (2026-10-17)
4
12
 
5
13
  - Use templates 0.13.1, SDK 0.13.1 fixtures and the unchanged executor/lifecycle digests on the 0.13 minor line.
package/README.md CHANGED
@@ -216,3 +216,36 @@ Call `webapi.call` with `response_type: "raw"`; Go Apps use
216
216
  package's private executor-staging folder with size/SHA-256 and immediate cleanup.
217
217
  Do not use Attachment.download or follow X-Accel-Redirect. See the SDK raw response
218
218
  contract for the complete transport and CGI validation rules.
219
+
220
+ ## App launch mode
221
+
222
+ Native Apps declare `ui.launch: dsm-window | new-page` in `dsm-package.yaml`.
223
+ Use `dsm-window` for frontends built with `@synopackageland/ui`; use `new-page`
224
+ for every other frontend. Plain/binary scaffolds explicitly choose new-page; Vue uses the native default. An omitted
225
+ native `ui.launch` resolves to `dsm-window`; the CLI does not guess a frontend's
226
+ framework from its build command. `new-page` registers DSM `type: "standalone"`,
227
+ so the main menu and Package Center open a full page on the DSM origin.
228
+ `ctx.fullPage`, `ctx.appIcon`, session/SynoToken, launch parameters and entry
229
+ permissions use the same wrapper in both modes. The wrapper sets the standalone
230
+ page title, favicon and device-width viewport. It retains ExtJS; `pureEnv` is
231
+ not appropriate, and alternate Portal ports are not needed (`allowAltPort` is omitted).
232
+
233
+ A full page does not need Application Portal. Omit `portal` unless an alias is
234
+ explicitly wanted; only that opt-in adds `defaultAlias`, login styling, Portal
235
+ panels and capabilities. No Portal is a healthy default for inspect/info/doctor.
236
+
237
+ Web service Apps declare `integration.web.launch`; its built-in default is `new-page`.
238
+ Native SDK Apps default to `dsm-window`; non-UI-SDK native frontends must
239
+ explicitly set `ui.launch: new-page`. Headless Compose Apps have no launcher.
240
+ The new-page main menu uses `type: "url"` with an explicit `url` at the existing trusted entry: the authenticated
241
+ gateway, or a Web Station alias only with explicit `access: public`. DSM rewrites standalone URLs to a launchApp shell, which needs an AppInstance
242
+ and AppWindow JS class. A schema-1 web service has no such class, so it uses url.
243
+ The schema-2 identity_gateway + compose SDK wrapper has the class and uses standalone.
244
+ Authenticated web Apps do not create a Web Station alias or Application Portal.
245
+ `dsm-window` remains an explicit web launch option with its URL launcher.
246
+
247
+ Schema 2 `dsm-package.yaml` with `identity_gateway` and `compose` is also a web
248
+ service: omitted `ui.launch` resolves to `new-page`, without Application Portal.
249
+ Both Compose routes share `contract/app-launch.ts` for defaults and launcher types.
250
+ An explicit `ui.launch` overrides this default. Native SDK Apps without a web
251
+ service keep `dsm-window` when `ui.launch` is absent.
@@ -184,7 +184,7 @@ export async function buildSpk(options) {
184
184
  cpSync(htmlDir, join(staging, "package", "compose", "html"), { recursive: true });
185
185
  }
186
186
  }
187
- if (contract.adminport !== undefined && resolvedWebLaunch(contract) === "dsm-window") {
187
+ if (contract.adminport !== undefined && resolvedWebLaunch(contract) !== undefined) {
188
188
  const uiDir = join(staging, "package", "ui");
189
189
  mkdirSync(uiDir, { recursive: true });
190
190
  writeFileSync(join(uiDir, "config"), renderDsmWindowConfig(contract, icons), "utf8");
@@ -198,7 +198,7 @@ export async function buildSpk(options) {
198
198
  chmodSync(join(staging, "privilege"), FILE_MODE);
199
199
  chmodSync(join(staging, "package", "compose.yaml"), FILE_MODE);
200
200
  chmodSync(join(staging, "package", "synology-app.yaml"), FILE_MODE);
201
- if (contract.adminport !== undefined && resolvedWebLaunch(contract) === "dsm-window") {
201
+ if (contract.adminport !== undefined && resolvedWebLaunch(contract) !== undefined) {
202
202
  chmodSync(join(staging, "package", "ui", "config"), FILE_MODE);
203
203
  }
204
204
  const outputPath = options.outputPath ?? join(options.project.root, `${String(contract.package)}.spk`);
@@ -274,14 +274,10 @@ function renderInfo(contract, archFamily, isResourceWorker = false) {
274
274
  add("adminurl", String(contract.adminurl));
275
275
  }
276
276
  }
277
- if (launch === "dsm-window") {
277
+ if (launch === "dsm-window" || launch === "new-page") {
278
278
  add("dsmuidir", "ui");
279
279
  add("dsmappname", String(contract.package));
280
280
  }
281
- else if (viaGateway) {
282
- // The gateway CGI is served from ui/ even when the App opens in a new page.
283
- add("dsmuidir", "ui");
284
- }
285
281
  return `${lines.join("\n")}\n`;
286
282
  }
287
283
  function renderPrivilege() {
@@ -0,0 +1,11 @@
1
+ import type { StandaloneManifest } from '../standalone/manifest.js';
2
+ export declare const APP_LAUNCH_MODES: readonly ["dsm-window", "new-page"];
3
+ export type AppLaunchMode = (typeof APP_LAUNCH_MODES)[number];
4
+ export declare const DEFAULT_NATIVE_LAUNCH: AppLaunchMode;
5
+ export declare const DEFAULT_WEB_LAUNCH: AppLaunchMode;
6
+ export declare function isAppLaunchMode(value: unknown): value is AppLaunchMode;
7
+ /** Both Compose routes use the same web default; a valid explicit choice wins. */
8
+ export declare function resolveAppLaunch(value: unknown, webService: boolean): AppLaunchMode | undefined;
9
+ export declare function resolvedSdkLaunch(manifest: Pick<StandaloneManifest, 'ui' | 'compose' | 'identityGateway'>): AppLaunchMode;
10
+ /** DSM rewrites standalone URLs to launchApp; only SDK wrappers provide that class. */
11
+ export declare function dsmLauncherType(launch: AppLaunchMode, sdkWindow: boolean): 'standalone' | 'app' | 'url';
@@ -0,0 +1,18 @@
1
+ export const APP_LAUNCH_MODES = ['dsm-window', 'new-page'];
2
+ export const DEFAULT_NATIVE_LAUNCH = 'dsm-window';
3
+ export const DEFAULT_WEB_LAUNCH = 'new-page';
4
+ export function isAppLaunchMode(value) {
5
+ return value === 'dsm-window' || value === 'new-page';
6
+ }
7
+ /** Both Compose routes use the same web default; a valid explicit choice wins. */
8
+ export function resolveAppLaunch(value, webService) {
9
+ return value === undefined ? webService ? DEFAULT_WEB_LAUNCH : DEFAULT_NATIVE_LAUNCH
10
+ : isAppLaunchMode(value) ? value : undefined;
11
+ }
12
+ export function resolvedSdkLaunch(manifest) {
13
+ return resolveAppLaunch(manifest.ui.launch, Boolean(manifest.compose || manifest.identityGateway));
14
+ }
15
+ /** DSM rewrites standalone URLs to launchApp; only SDK wrappers provide that class. */
16
+ export function dsmLauncherType(launch, sdkWindow) {
17
+ return sdkWindow ? launch === 'new-page' ? 'standalone' : 'app' : 'url';
18
+ }
@@ -1,10 +1,11 @@
1
+ import { type AppLaunchMode } from './app-launch.js';
1
2
  import { type PackageIcons } from "../build-spk/dsm-icons.js";
2
3
  import type { AppContract } from "../types.js";
3
4
  /** DSM Web Station portal alias max length (error 1311 above this). */
4
5
  export declare const WEB_STATION_ALIAS_MAX = 20;
5
6
  export declare const WEB_LAUNCH_MODES: readonly ["dsm-window", "new-page"];
6
- export type WebLaunchMode = (typeof WEB_LAUNCH_MODES)[number];
7
- export declare const DEFAULT_WEB_LAUNCH_MODE: WebLaunchMode;
7
+ export type WebLaunchMode = AppLaunchMode;
8
+ export declare const DEFAULT_WEB_LAUNCH_MODE: "dsm-window" | "new-page";
8
9
  export interface WebIntegration {
9
10
  launch?: unknown;
10
11
  cookies?: unknown;
@@ -1,11 +1,12 @@
1
+ import { APP_LAUNCH_MODES, DEFAULT_WEB_LAUNCH, isAppLaunchMode, resolveAppLaunch, dsmLauncherType } from './app-launch.js';
1
2
  import { createHash } from "node:crypto";
2
3
  import { uiTitleFields } from "../build-spk/ui-texts.js";
3
4
  import { windowIconTemplate } from "../build-spk/dsm-icons.js";
4
5
  import { contractAppTitle } from "./i18n.js";
5
6
  /** DSM Web Station portal alias max length (error 1311 above this). */
6
7
  export const WEB_STATION_ALIAS_MAX = 20;
7
- export const WEB_LAUNCH_MODES = ["dsm-window", "new-page"];
8
- export const DEFAULT_WEB_LAUNCH_MODE = "dsm-window";
8
+ export const WEB_LAUNCH_MODES = APP_LAUNCH_MODES;
9
+ export const DEFAULT_WEB_LAUNCH_MODE = DEFAULT_WEB_LAUNCH;
9
10
  export function readWebIntegration(contract) {
10
11
  const integration = contract.integration;
11
12
  if (!integration || typeof integration !== "object") {
@@ -18,17 +19,13 @@ export function readWebIntegration(contract) {
18
19
  return web;
19
20
  }
20
21
  export function isWebLaunchMode(value) {
21
- return value === "dsm-window" || value === "new-page";
22
+ return isAppLaunchMode(value);
22
23
  }
23
24
  export function resolvedWebLaunch(contract) {
24
25
  if (contract.adminport === undefined) {
25
26
  return undefined;
26
27
  }
27
- const launch = readWebIntegration(contract)?.launch;
28
- if (launch === undefined) {
29
- return DEFAULT_WEB_LAUNCH_MODE;
30
- }
31
- return isWebLaunchMode(launch) ? launch : undefined;
28
+ return resolveAppLaunch(readWebIntegration(contract)?.launch, true);
32
29
  }
33
30
  export const WEB_ACCESS_MODES = ["dsm-users", "dsm-admins", "dsm-groups", "public"];
34
31
  export const DEFAULT_WEB_ACCESS_MODE = "dsm-users";
@@ -145,7 +142,7 @@ export function renderDsmWindowConfig(contract, icons) {
145
142
  return `${JSON.stringify({
146
143
  ".url": {
147
144
  [packageId]: {
148
- type: "url",
145
+ type: dsmLauncherType(resolvedWebLaunch(contract), false),
149
146
  allUsers: true,
150
147
  ...uiTitleFields(contractAppTitle(contract)),
151
148
  icon: windowIconTemplate(icons),
@@ -1,4 +1,7 @@
1
1
  export const ERROR_REGISTRY = {
2
+ 'LAUNCHER_PORTAL_MISMATCH': { code: 'LAUNCHER_PORTAL_MISMATCH', description: 'The launcher has Portal fields without a matching explicit Portal declaration.', suggestion: 'Remove defaultAlias and login fields, or explicitly enable portal and rebuild.' },
3
+ 'LAUNCHER_TYPE_MISMATCH': { code: 'LAUNCHER_TYPE_MISMATCH', description: 'The built launcher or INFO entry differs from the declared launch mode.', suggestion: 'Rebuild with synopkgland build; native new-page uses standalone, and schema-1 web uses url at the trusted entry; schema-2 SDK wrappers use standalone.' },
4
+ 'STANDALONE_UI_LAUNCH_INVALID': { code: 'STANDALONE_UI_LAUNCH_INVALID', description: 'The native ui.launch value is invalid.', suggestion: 'Set ui.launch to dsm-window (UI SDK) or new-page (other frontends). Portal is a separate opt-in.' },
2
5
  "NATIVE_LAYOUT_INVALID": {
3
6
  code: "NATIVE_LAYOUT_INVALID",
4
7
  description: "The native layout's manifest, hooks, toolkit or artifacts violate the package-only contract.",
@@ -52,7 +55,7 @@ export const ERROR_REGISTRY = {
52
55
  "CONTRACT_LAUNCH_INVALID": {
53
56
  "code": "CONTRACT_LAUNCH_INVALID",
54
57
  "description": "The integration.web.launch mode is invalid.",
55
- "suggestion": "Use dsm-window (DSM window, the default) or new-page (new tab); omit this field for a headless App."
58
+ "suggestion": "Use dsm-window (DSM window) or new-page (new tab, the default); omit this field for a headless App."
56
59
  },
57
60
  "CONTRACT_LAUNCH_REQUIRES_WEB": {
58
61
  "code": "CONTRACT_LAUNCH_REQUIRES_WEB",
package/dist/init.js CHANGED
@@ -191,7 +191,7 @@ export function initFromTemplate(template, targetDir, options = {}) {
191
191
  }
192
192
  if (ui === "vue") {
193
193
  const manifest = join(targetDir, "dsm-package.yaml");
194
- writeFileSync(manifest, readFileSync(manifest, "utf8").replace(" dir: ui", " dir: frontend/dist\n build: {cwd: frontend, command: [npm, run, build]}"));
194
+ writeFileSync(manifest, readFileSync(manifest, "utf8").replace(" dir: ui", " dir: frontend/dist\n build: {cwd: frontend, command: [npm, run, build]}").replace(" launch: new-page\n", "").replace("Non-UI-SDK frontends open a full page", "UI SDK frontends open a DSM window"));
195
195
  rmSync(join(targetDir, "ui"), { recursive: true });
196
196
  }
197
197
  if (ui !== "vue")
package/dist/inspect.js CHANGED
@@ -31,7 +31,7 @@ export function inspectProject(project) {
31
31
  const primary = publishedPorts[0];
32
32
  webHints.push(`Detected published port ${primary.port}; consider setting adminport: ${primary.port} in synology-app.yaml.`);
33
33
  webHints.push("A Web App usually also needs adminprotocol and adminurl.");
34
- webHints.push("Launch Mode: integration.web.launch is dsm-window (default, DSM window) or new-page (new tab).");
34
+ webHints.push("Launch Mode: integration.web.launch is dsm-window (DSM window) or new-page (default, new tab).");
35
35
  }
36
36
  const exposedPorts = listPortExposures(compose).filter((exposure) => !exposure.loopbackOnly);
37
37
  return { services, publishedPorts, dataMounts, risks, webHints, exposedPorts };
@@ -1,5 +1,5 @@
1
1
  {
2
- "0.13.0": {
2
+ "0.14.1": {
3
3
  "x86_64": "efe409287770f6b70e7f8465e8767eeb2413b9ab8775020cf4dffb1555d7bf1f",
4
4
  "aarch64": "d68c9889ed2391e918b9c278679ea0a01cd04425528c11090700a3b6341fc543",
5
5
  "armv7": "9149a05efb9f2d7d86a400707099b3d657382b1c0dbad6588be8c500e90445cc",
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "goToolchain": "go1.26.9"
12
12
  },
13
- "0.13.1": {
13
+ "0.14.0": {
14
14
  "x86_64": "efe409287770f6b70e7f8465e8767eeb2413b9ab8775020cf4dffb1555d7bf1f",
15
15
  "aarch64": "d68c9889ed2391e918b9c278679ea0a01cd04425528c11090700a3b6341fc543",
16
16
  "armv7": "9149a05efb9f2d7d86a400707099b3d657382b1c0dbad6588be8c500e90445cc",
@@ -1,3 +1,4 @@
1
+ import { resolvedSdkLaunch, dsmLauncherType } from "../contract/app-launch.js";
1
2
  import { REOPEN_EVENTS_RUNTIME } from "./reopen-events.js";
2
3
  import { LAUNCH_PARAMS_RUNTIME } from "./launch-params.js";
3
4
  import { DSM_LANGS } from "./i18n.js";
@@ -99,7 +100,8 @@ export function renderUiConfig(manifest, icons = packageIcons(manifest.icon), lo
99
100
  return `${JSON.stringify({
100
101
  [windowScriptName(manifest)]: {
101
102
  [`${ns}.Instance`]: {
102
- type: "app",
103
+ type: dsmLauncherType(resolvedSdkLaunch(manifest), true),
104
+ ...(manifest.portal ? { defaultAlias: manifest.portal.alias } : {}),
103
105
  ...titleFields,
104
106
  icon,
105
107
  appWindow: `${ns}.MainWindow`,
@@ -316,6 +318,18 @@ ${ns}.fullPage = function () {
316
318
  };
317
319
  ${ns}.appIcon = ${JSON.stringify(`webman/3rdparty/${pkg}/${windowIconFile(icons, 64)}`)};
318
320
 
321
+ // Standalone pages own their title, favicon and device viewport, for any UI.
322
+ ${ns}.prepareFullPage = function (title) {
323
+ if (!${ns}.fullPage()) return;
324
+ document.title = title;
325
+ var viewport = document.querySelector && document.querySelector('meta[name="viewport"]');
326
+ if (!viewport) { viewport = document.createElement("meta"); viewport.name = "viewport"; document.head.appendChild(viewport); }
327
+ viewport.content = "width=device-width, initial-scale=1, viewport-fit=cover";
328
+ var favicon = document.querySelector && document.querySelector('link[rel="icon"]');
329
+ if (!favicon) { favicon = document.createElement("link"); favicon.rel = "icon"; document.head.appendChild(favicon); }
330
+ favicon.href = ${ns}.appIcon;
331
+ };
332
+
319
333
  // DSM reopens a window at the size it was last left at, even one saved on a
320
334
  // larger screen, and only moves a window that does not fit: keep it inside.
321
335
  ${ns}.fitDesktop = function (size) {
@@ -367,6 +381,7 @@ ${root ? root.mount : ""} ${ns}.loadApp(function (app) {
367
381
  var i18n = ${ns}.createI18n(lang, ${ns}.messages);
368
382
  me.hostAccess = ${ns}.createHost({ appWindow: me, locale: i18n.locale });
369
383
  root.lang = i18n.locale;
384
+ ${ns}.prepareFullPage(${ns}.createI18n(lang, ${JSON.stringify(displayNameCatalogs(manifest))}).t("app_title"));
370
385
  try {
371
386
  me.mounted = app.mount(root, Ext.apply(ctx, {
372
387
  displayName: ${ns}.createI18n(lang, ${JSON.stringify(displayNameCatalogs(manifest))}).t("app_title"),
@@ -1,3 +1,4 @@
1
+ import { type WebLaunchMode } from "../contract/web-launch.js";
1
2
  import { type ExecutorInspection } from "../runtime/executor-inspect.js";
2
3
  import { type AppScriptName } from "./app-scripts.js";
3
4
  import type { EntryMode } from "./manifest.js";
@@ -31,6 +32,8 @@ export interface SpkInspection {
31
32
  };
32
33
  /** Declared `entry` mode; `admin-only` is the administrator floor of every route. */
33
34
  entry?: EntryMode;
35
+ /** The declared launch mode verified against the built launcher. */
36
+ launch?: WebLaunchMode;
34
37
  /** The App's own scripts/app/<DSM script name>, each called from the generated script of that name. */
35
38
  appScripts?: AppScriptName[];
36
39
  /** PACKAGE_ICON_256.PNG: the SDK default, a custom icon (by digest) or none. */
@@ -1,3 +1,6 @@
1
+ import { dsmLauncherType } from "../contract/app-launch.js";
2
+ import { isWebLaunchMode, resolvedWebLaunch, trustedAppEntryPath, usesAuthGateway } from "../contract/web-launch.js";
3
+ import { loadContract } from "../contract/load.js";
1
4
  import { readComposeApp } from "./compose.js";
2
5
  import { createHash } from "node:crypto";
3
6
  import { inspectNativeLayout } from "./native-layout.js";
@@ -63,7 +66,7 @@ export async function inspectSpk(spkPath, expectedKind) {
63
66
  if (metadataText !== undefined) {
64
67
  try {
65
68
  const raw = JSON.parse(metadataText);
66
- if (raw.schema !== 2 || !["go", "python", "node", "rust", "binary"].includes(raw.backend) || !Array.isArray(raw.capabilities) || raw.capabilities.some((name) => !["files", "jobs", "identity", "rootOps", "delegate", "webapi"].includes(String(name))) || new Set(raw.capabilities).size !== raw.capabilities.length || (raw.entry !== undefined && !["admin-only", "app-privilege"].includes(raw.entry)))
69
+ if ((raw.launch !== undefined && !isWebLaunchMode(raw.launch)) || raw.schema !== 2 || !["go", "python", "node", "rust", "binary"].includes(raw.backend) || !Array.isArray(raw.capabilities) || raw.capabilities.some((name) => !["files", "jobs", "identity", "rootOps", "delegate", "webapi"].includes(String(name))) || new Set(raw.capabilities).size !== raw.capabilities.length || (raw.entry !== undefined && !["admin-only", "app-privilege"].includes(raw.entry)))
67
70
  throw new Error();
68
71
  metadata = raw;
69
72
  }
@@ -144,6 +147,7 @@ export async function inspectSpk(spkPath, expectedKind) {
144
147
  }
145
148
  if (metadata.entry)
146
149
  await checkEntryLauncher(dir, info.package, metadata.entry, findings);
150
+ const launch = await checkLaunchConfiguration(dir, info, standalone, metadata.launch, portal?.alias ?? executor?.policy.portal?.alias, findings);
147
151
  const loginStyle = standalone ? await inspectLoginStyle(dir, info.package, Boolean(portal || executor?.policy.portal), findings) : undefined;
148
152
  if (standalone)
149
153
  checkSharedFolder(dir, findings, info.package, sharedFolder, executor?.policy.scope === 'install-selected', portal, hasExecutor);
@@ -157,6 +161,7 @@ export async function inspectSpk(spkPath, expectedKind) {
157
161
  }
158
162
  return {
159
163
  kind,
164
+ ...(launch ? { launch } : {}),
160
165
  icon,
161
166
  ...(executor ? { executor } : {}),
162
167
  ...(portal ? { portal: { appId: portal.appId, alias: portal.alias } } : {}),
@@ -179,6 +184,57 @@ export async function inspectSpk(spkPath, expectedKind) {
179
184
  rmSync(dir, { recursive: true, force: true });
180
185
  }
181
186
  }
187
+ const LAUNCHER_TYPE_MISMATCH = "LAUNCHER_TYPE_MISMATCH";
188
+ const LAUNCHER_PORTAL_MISMATCH = "LAUNCHER_PORTAL_MISMATCH";
189
+ /** Compare the actual launcher and Portal fields to the packaged declaration. */
190
+ async function checkLaunchConfiguration(dir, info, native, declared, alias, findings) {
191
+ const target = join(dir, "launch-inspection");
192
+ mkdirSync(target);
193
+ await tar.x({ file: join(dir, "package.tgz"), cwd: target, filter: path => ["ui/config", "synology-app.yaml"].includes(path.replace(/^\.\//, "")) });
194
+ let launch = declared ?? "dsm-window";
195
+ let url;
196
+ if (!native) {
197
+ let contract;
198
+ try {
199
+ contract = loadContract(join(target, "synology-app.yaml")).contract;
200
+ }
201
+ catch {
202
+ findings.push(`${LAUNCHER_TYPE_MISMATCH} cannot read the packaged web launch declaration; rebuild with synopkgland build`);
203
+ return undefined;
204
+ }
205
+ const resolved = resolvedWebLaunch(contract);
206
+ if (!resolved) {
207
+ if (contract.adminport !== undefined)
208
+ findings.push(`${LAUNCHER_TYPE_MISMATCH} invalid packaged web launch mode; use dsm-window or new-page and rebuild`);
209
+ return undefined; // A headless service has no launcher.
210
+ }
211
+ launch = resolved;
212
+ url = trustedAppEntryPath(contract);
213
+ try {
214
+ const resource = JSON.parse(readFileSync(join(dir, "conf/resource"), "utf8"));
215
+ const portals = resource.webservice?.portals ?? [];
216
+ if (usesAuthGateway(contract) ? portals.length > 0 : portals.some((p) => p.app !== (launch === "dsm-window" ? info.package : undefined))) {
217
+ findings.push(`${LAUNCHER_PORTAL_MISMATCH} Web Station entries differ from the declared access and launch mode; rebuild with synopkgland build`);
218
+ }
219
+ }
220
+ catch { /* checkConf reports invalid resources */ }
221
+ }
222
+ const appId = native ? `SYNO.SDS.${info.package}.Instance` : info.package;
223
+ let launcher;
224
+ try {
225
+ const config = JSON.parse(readFileSync(join(target, "ui/config"), "utf8"));
226
+ launcher = Object.values(config).map((entries) => entries[appId]).find(Boolean);
227
+ }
228
+ catch { /* report the missing or malformed launcher below */ }
229
+ const type = dsmLauncherType(launch, native);
230
+ if (!launcher || launcher.type !== type || info.dsmuidir !== "ui" || info.dsmappname !== appId || (native ? launcher.appWindow !== `SYNO.SDS.${info.package}.MainWindow` : launcher.url !== url || info.adminurl !== url)) {
231
+ findings.push(`${LAUNCHER_TYPE_MISMATCH} ${launch} requires a ${type} launcher at ${appId} and matching INFO entry; rebuild with synopkgland build`);
232
+ }
233
+ if (launcher && (launcher.defaultAlias !== alias || (!alias && (launcher.loginStyle !== undefined || launcher.loginIcon !== undefined)))) {
234
+ findings.push(`${LAUNCHER_PORTAL_MISMATCH} defaultAlias and login fields require an explicit matching portal declaration; remove them or opt in to portal and rebuild`);
235
+ }
236
+ return launch;
237
+ }
182
238
  /** The launcher in ui/config must carry exactly the access the declared entry mode fixes. */
183
239
  async function checkEntryLauncher(dir, pkg, entry, findings) {
184
240
  const target = join(dir, "entry-inspection");
@@ -1,3 +1,4 @@
1
+ import { type WebLaunchMode } from "../contract/web-launch.js";
1
2
  import { type WebAPIProfiles } from '../runtime/webapi-profile.js';
2
3
  import { type PackageMetadata } from "../package-metadata.js";
3
4
  import { type DelegateCapability } from "../runtime/executor-policy.js";
@@ -61,6 +62,7 @@ export interface StandaloneManifest {
61
62
  /** `ui.window`: the DSM window's size over the defaults; absent keeps 960×640, at least 700×420. */
62
63
  ui: {
63
64
  dir: string;
65
+ launch?: WebLaunchMode;
64
66
  build?: {
65
67
  cwd: string;
66
68
  command: string[];
@@ -1,3 +1,4 @@
1
+ import { isWebLaunchMode } from "../contract/web-launch.js";
1
2
  import { validWebAPIProfiles } from '../runtime/webapi-profile.js';
2
3
  import { validateMetadata } from "../package-metadata.js";
3
4
  import { validDelegate } from "../runtime/executor-policy.js";
@@ -487,13 +488,16 @@ function readUi(value, c) {
487
488
  if (!isRecord(value)) {
488
489
  return c.fail("STANDALONE_UI_INVALID", "ui must contain dir.");
489
490
  }
491
+ if (value.launch !== undefined && !isWebLaunchMode(value.launch))
492
+ return c.fail("STANDALONE_UI_LAUNCH_INVALID", "ui.launch must be dsm-window or new-page. Application Portal is a separate opt-in.");
493
+ const launch = value.launch === undefined ? {} : { launch: value.launch };
490
494
  const dir = c.path(value.dir, "ui.dir");
491
495
  const read = value.window === undefined ? undefined : readWindowSize(value.window);
492
496
  if (read && "error" in read)
493
497
  return c.fail("STANDALONE_UI_WINDOW_INVALID", read.error);
494
498
  const window = read ? { window: read.size } : {};
495
499
  if (value.build === undefined) {
496
- return dir ? { dir, ...window } : undefined;
500
+ return dir ? { dir, ...launch, ...window } : undefined;
497
501
  }
498
502
  const build = value.build;
499
503
  const command = isRecord(build) ? build.command : undefined;
@@ -501,7 +505,7 @@ function readUi(value, c) {
501
505
  return c.fail("STANDALONE_UI_BUILD_INVALID", "ui.build.command must be an argv string array (not going through a shell), for example [npm, run, build].");
502
506
  }
503
507
  const cwd = c.path(build.cwd, "ui.build.cwd");
504
- return dir && cwd ? { dir, build: { cwd, command: command }, ...window } : undefined;
508
+ return dir && cwd ? { dir, build: { cwd, command: command }, ...launch, ...window } : undefined;
505
509
  }
506
510
  function readI18n(value, c) {
507
511
  const invalid = (why) => c.fail("STANDALONE_I18N_INVALID", `i18n ${why}`);
@@ -1,3 +1,4 @@
1
+ import { resolvedSdkLaunch } from "../contract/app-launch.js";
1
2
  import { metadataInfoFields } from "../package-metadata.js";
2
3
  import { createHash } from "node:crypto";
3
4
  import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync, } from "node:fs";
@@ -146,7 +147,7 @@ export async function assembleStandaloneSpk(options) {
146
147
  await createReproducibleTar(join(staging, "package"), packageTgz, { gzip: true });
147
148
  rmSync(join(staging, "package"), { recursive: true, force: true });
148
149
  const checksum = createHash("md5").update(readFileSync(packageTgz)).digest("hex");
149
- writeFile(join(staging, "sdk-package.json"), JSON.stringify({ schema: 2, backend: manifest.backend.kind, capabilities: Object.keys(manifest.capabilities).sort(), ...(manifest.compose ? { compose: { services: manifest.compose.services } } : {}), ...(manifest.entry ? { entry: manifest.entry } : {}), ...(manifest.uninstall ? { uninstall: uninstallRecord(manifest.uninstall, manifest.i18n?.catalogs) } : {}) }));
150
+ writeFile(join(staging, "sdk-package.json"), JSON.stringify({ schema: 2, backend: manifest.backend.kind, launch: resolvedSdkLaunch(manifest), capabilities: Object.keys(manifest.capabilities).sort(), ...(manifest.compose ? { compose: { services: manifest.compose.services } } : {}), ...(manifest.entry ? { entry: manifest.entry } : {}), ...(manifest.uninstall ? { uninstall: uninstallRecord(manifest.uninstall, manifest.i18n?.catalogs) } : {}) }));
150
151
  writeFile(join(staging, "INFO"), renderInfo(manifest, options.arch, checksum, options.executor?.executorDigest));
151
152
  mkdirSync(join(staging, "conf"));
152
153
  writeFile(join(staging, "conf", "privilege"), renderNativePrivilege());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synopackageland/cli",
3
- "version": "0.13.1",
3
+ "version": "0.14.1",
4
4
  "description": "Syno Package Land Developer Kit CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -25,7 +25,7 @@
25
25
  "prepublishOnly": "npm run build"
26
26
  },
27
27
  "dependencies": {
28
- "@synopackageland/templates": "0.13.1",
28
+ "@synopackageland/templates": "0.14.1",
29
29
  "ajv": "^8.17.1",
30
30
  "commander": "^13.1.0",
31
31
  "tar": "^7.4.3",
@@ -122,7 +122,7 @@
122
122
  "dsm-window",
123
123
  "new-page"
124
124
  ],
125
- "description": "Web App Launch Mode. dsm-window opens a DSM Application Window at the Trusted App Entry; new-page uses Package Center Open. Omit for dsm-window. Headless apps omit this field."
125
+ "description": "Web App Launch Mode. dsm-window opens a DSM Application Window at the Trusted App Entry; new-page opens a new tab from the main menu and Package Center. Omit for new-page. Headless apps omit this field."
126
126
  },
127
127
  "cookies": {
128
128
  "type": "array",
package/skills/SKILL.md CHANGED
@@ -131,3 +131,34 @@ sweep forward the cancel instead of admitting it.
131
131
  adapt a real producer; show Saved only from a protected committed receipt.
132
132
  - Wizard support is measured on vt-nas DSM 7.4 via a session-only SSH local
133
133
  forward. This is not proof of other DSM releases or full writer/reboot behavior.
134
+
135
+ ## 開啟方式
136
+
137
+ 原生 App 在 `dsm-package.yaml` 設定 `ui.launch: dsm-window | new-page`。
138
+ SDK 原生 App 的內建預設為 `dsm-window`。使用 `@synopackageland/ui` 的前端維持視窗;
139
+ 其他前端必須明確設定 `new-page`,不宣告 Application Portal。
140
+ Scaffold 明確寫入此選擇,plain/binary 使用 `new-page`,Vue UI SDK 使用
141
+ 預設的 `dsm-window`(省略 `ui.launch`)。原生 manifest 省略 `ui.launch` 時解析為 `dsm-window`,CLI 不猜測前端依賴。
142
+
143
+ `new-page` 產生 DSM `type: "standalone"` launcher,主選單及套件中心皆在 DSM
144
+ 本身的 origin 開啟全頁。`ctx.fullPage`、`ctx.appIcon`、SynoToken/session、
145
+ `ctx.launchParams`/`onLaunch`、`entry` 權限沿用共用 wrapper;全頁的標題、
146
+ favicon 與手機 viewport 由 wrapper 設定。不需要 `pureEnv` 或 `allowAltPort`。
147
+
148
+ Application Portal 是獨立 opt-in:不想要別名就省略 `portal`,不生成
149
+ `defaultAlias`、Portal 面板/capability 或 Web Station alias;沒有 Portal
150
+ 不是 inspect/info/doctor 問題。明確啟用 `portal` 時才將其 alias 作為 `defaultAlias`。
151
+
152
+ Docker/web service App 設定 `integration.web.launch`,內建預設是 `new-page`;
153
+ 省略開啟方式時直接開新分頁,不宣告 Application Portal。SDK 原生 App
154
+ 省略時使用 `dsm-window`;非 UI SDK 原生 App 必須明確寫 `ui.launch: new-page`。
155
+ Web service 主選單使用
156
+ `type: "url"` 與明確的 `url` 指向既有 DSM gateway。明確 `access: public` 才保留 Web Station
157
+ alias;這是服務入口,不是 SDK Application Portal。DSM 會將 standalone URL 改寫成 launchApp 頁面,
158
+ 需要 AppInstance/AppWindow JS class;schema 1 web service 沒有該 class,因此使用 url。需要 DSM 視窗時明寫 `dsm-window`。
159
+
160
+ Schema 2 的 `dsm-package.yaml` + `identity_gateway` + `compose` 同樣視為
161
+ web service:省略 `ui.launch` 時預設 `new-page`,使用 standalone launcher,
162
+ 不帶 Application Portal。兩條 Compose 路線共用 `contract/app-launch.ts`
163
+ 解析開啟方式與選擇 launcher 型別;明確的 `ui.launch` 可覆寫預設。
164
+ 只有沒有 web service 的 SDK 原生 App 省略時才預設 `dsm-window`。
@@ -39,8 +39,8 @@ rootOps 與 delegate 已共用管理員 PasswordConfirm 核准的 protected exec
39
39
  backend 寫 `runtime: python|node|rust`(`runtime: go` 等同 `kind: go`)。CGI 是 SDK 的 Go runtime(`bridge/cmd/runtime`),它驗證 DSM session、entry 與 `permissions.json` 後,以套件帳號(不是 root)每個請求啟動一次 App。App 放在 ui 以外的目錄,打包成 `target/app/`,build 寫入 `bridge.json`(spec/bridge/v1)並把它與 `permissions.json` 的 sha256 編進 runtime。
40
40
 
41
41
  ```yaml
42
- backend: {runtime: python, dir: app, entry: main.py, sdk: 0.13.1}
43
- backend: {runtime: node, dir: app, entry: main.mjs, sdk: 0.13.1, node: {package: Node.js_v20}}
42
+ backend: {runtime: python, dir: app, entry: main.py, sdk: 0.14.1}
43
+ backend: {runtime: node, dir: app, entry: main.mjs, sdk: 0.14.1, node: {package: Node.js_v20}}
44
44
  backend: {runtime: rust, dir: app, entry: hello, methods: [hello.greet]}
45
45
  ```
46
46
 
@@ -214,3 +214,34 @@ sweep forward the cancel instead of admitting it.
214
214
  adapt a real producer; show Saved only from a protected committed receipt.
215
215
  - Wizard support is measured on vt-nas DSM 7.4 via a session-only SSH local
216
216
  forward. This is not proof of other DSM releases or full writer/reboot behavior.
217
+
218
+ ## 開啟方式
219
+
220
+ 原生 App 在 `dsm-package.yaml` 設定 `ui.launch: dsm-window | new-page`。
221
+ SDK 原生內建預設為 `dsm-window`;使用 `@synopackageland/ui` 維持視窗,
222
+ 其他原生前端必須明確寫 `ui.launch: new-page`。
223
+ Scaffold 明確寫入此選擇,plain/binary 使用 `new-page`,Vue UI SDK 使用
224
+ 預設的 `dsm-window`(省略 `ui.launch`)。原生 manifest 省略 `ui.launch` 時解析為 `dsm-window`,CLI 不猜測前端依賴。
225
+
226
+ `new-page` 產生 DSM `type: "standalone"` launcher,主選單及套件中心皆在 DSM
227
+ 本身的 origin 開啟全頁。`ctx.fullPage`、`ctx.appIcon`、SynoToken/session、
228
+ `ctx.launchParams`/`onLaunch`、`entry` 權限沿用共用 wrapper;全頁的標題、
229
+ favicon 與手機 viewport 由 wrapper 設定。不需要 `pureEnv` 或 `allowAltPort`。
230
+
231
+ Application Portal 是獨立 opt-in:不想要別名就省略 `portal`,不生成
232
+ `defaultAlias`、Portal 面板/capability 或 Web Station alias;沒有 Portal
233
+ 不是 inspect/info/doctor 問題。明確啟用 `portal` 時才將其 alias 作為 `defaultAlias`。
234
+
235
+ Docker/web service App 設定 `integration.web.launch`,內建預設是 `new-page`;
236
+ 省略開啟方式時直接開新分頁,不宣告 Application Portal。SDK 原生 App
237
+ 省略時使用 `dsm-window`;非 UI SDK 原生 App 必須明確寫 `ui.launch: new-page`。
238
+ Web service 主選單使用
239
+ `type: "url"` 與明確的 `url` 指向既有 DSM gateway。明確 `access: public` 才保留 Web Station
240
+ alias;這是服務入口,不是 SDK Application Portal。DSM 會將 standalone URL 改寫成 launchApp 頁面,
241
+ 需要 AppInstance/AppWindow JS class;schema 1 web service 沒有該 class,因此使用 url。需要 DSM 視窗時明寫 `dsm-window`。
242
+
243
+ Schema 2 的 `dsm-package.yaml` + `identity_gateway` + `compose` 同樣視為
244
+ web service:省略 `ui.launch` 時預設 `new-page`,使用 standalone launcher,
245
+ 不帶 Application Portal。兩條 Compose 路線共用 `contract/app-launch.ts`
246
+ 解析開啟方式與選擇 launcher 型別;明確的 `ui.launch` 可覆寫預設。
247
+ 只有沒有 web service 的 SDK 原生 App 省略時才預設 `dsm-window`。
@@ -37,7 +37,7 @@ Start with a schema-2 native-go or binary project for the native CGI and package
37
37
  UI. Add `compose` to **dsm-package.yaml**; do not combine the legacy
38
38
  `synology-app.yaml` Compose contract with it. Omit `daemon`. Set `os_min_ver` to
39
39
  at least `7.2.1-69057`, `capabilities: {}`, and supply one helper payload directory
40
- per architecture. The examples below pin released SDK 0.13.1, which contains the gateway/account APIs.
40
+ per architecture. The examples below pin released SDK 0.14.1, which contains the gateway/account APIs.
41
41
 
42
42
  ```yaml
43
43
  compose:
@@ -46,7 +46,7 @@ compose:
46
46
  services:
47
47
  openlist: {address_env: ADDR, port: 25244}
48
48
  identity_gateway:
49
- sdk: 0.13.1
49
+ sdk: 0.14.1
50
50
  mode: oidc
51
51
  upstream: 25244
52
52
  listen: {http: 25240, https: 25241}
@@ -114,7 +114,7 @@ as a firewall: correct bindings must be established before deployment.
114
114
  Use the existing native `daemon` route. A project-owned `absglue run` launches
115
115
  the unmodified ABS binary, binds it to loopback with the supported host/base
116
116
  path settings, configures OIDC through its API, and supervises it. The same
117
- helper implements idempotent deletion. The example pins released SDK 0.13.1.
117
+ helper implements idempotent deletion. The example pins released SDK 0.14.1.
118
118
 
119
119
  ```yaml
120
120
  daemon:
@@ -122,7 +122,7 @@ daemon:
122
122
  targets: {x86_64: payload}
123
123
  args: [run]
124
124
  identity_gateway:
125
- sdk: 0.13.1
125
+ sdk: 0.14.1
126
126
  mode: oidc
127
127
  upstream: 13378
128
128
  listen: {http: 13380, https: 13443}
@@ -172,3 +172,9 @@ deleting audio files. A minimal fixture directory is not proof of full purge.
172
172
  - Clean up only task-owned users, package/project/containers/images, private
173
173
  fixtures and test resources. Read back absence. Keep DSM-retained package
174
174
  identities unless their deletion was authorized, and report their names.
175
+
176
+ Schema 2 的 `dsm-package.yaml` + `identity_gateway` + `compose` 同樣視為
177
+ web service:省略 `ui.launch` 時預設 `new-page`,使用 standalone launcher,
178
+ 不帶 Application Portal。兩條 Compose 路線共用 `contract/app-launch.ts`
179
+ 解析開啟方式與選擇 launcher 型別;明確的 `ui.launch` 可覆寫預設。
180
+ 只有沒有 web service 的 SDK 原生 App 省略時才預設 `dsm-window`。
@@ -35,3 +35,34 @@ Never: write `AppPortal.json`, `@appconf/<package>` or `/usr/syno/etc/www/app.d`
35
35
  Uninstall: the root executor's purge deletes the rules of `SYNO.SDS.<package>.Instance` through the Application Privileges API and, once DSM has removed the alias files, reloads nginx (`synosystemctl reload nginx`), so the alias stops at once. Failures are only logged to the system log entry `synopkgland-executor`; then an administrator saves an alias in Control Panel > Login Portal > Applications (which reloads nginx) or restarts the NAS, and removes the rules in Control Panel > Application Privileges. The uninstall wizard and the `status`/`inspect` cleanup list say this in that order: automatic first, manual only as the fallback.
36
36
 
37
37
  Executor mode uninstall: the purge reads the retained policy and unregisters the entry. If the executor is not running or was never approved, uninstall does not remove the entry; the wizard and cleanup list say so and give the root commands (`rm -f /usr/syno/etc/www/app.d/<package>.json /usr/syno/etc/www/app.d/<package>.mustache /usr/syno/etc/packages/<package>/AppPortal.json`, `synow3tool --disable-nginx-confd/--unreg-nginx-confd` for `dsm www alias .alias`, `--disable-nginx-sites/--unreg-nginx-sites` for `server .server` on `<c>.syno-app-portal.<package>.conf`, then `nginx -t && synow3tool --nginx=reload`).
38
+
39
+ ## 開啟方式
40
+
41
+ 原生 App 在 `dsm-package.yaml` 設定 `ui.launch: dsm-window | new-page`。
42
+ SDK 原生內建預設為 `dsm-window`;使用 `@synopackageland/ui` 維持視窗,
43
+ 其他原生前端必須明確寫 `ui.launch: new-page`。
44
+ Scaffold 明確寫入此選擇,plain/binary 使用 `new-page`,Vue UI SDK 使用
45
+ 預設的 `dsm-window`(省略 `ui.launch`)。原生 manifest 省略 `ui.launch` 時解析為 `dsm-window`,CLI 不猜測前端依賴。
46
+
47
+ `new-page` 產生 DSM `type: "standalone"` launcher,主選單及套件中心皆在 DSM
48
+ 本身的 origin 開啟全頁。`ctx.fullPage`、`ctx.appIcon`、SynoToken/session、
49
+ `ctx.launchParams`/`onLaunch`、`entry` 權限沿用共用 wrapper;全頁的標題、
50
+ favicon 與手機 viewport 由 wrapper 設定。不需要 `pureEnv` 或 `allowAltPort`。
51
+
52
+ Application Portal 是獨立 opt-in:不想要別名就省略 `portal`,不生成
53
+ `defaultAlias`、Portal 面板/capability 或 Web Station alias;沒有 Portal
54
+ 不是 inspect/info/doctor 問題。明確啟用 `portal` 時才將其 alias 作為 `defaultAlias`。
55
+
56
+ Docker/web service App 設定 `integration.web.launch`,內建預設是 `new-page`;
57
+ 省略開啟方式時直接開新分頁,不宣告 Application Portal。SDK 原生 App
58
+ 省略時使用 `dsm-window`;非 UI SDK 原生 App 必須明確寫 `ui.launch: new-page`。
59
+ Web service 主選單使用
60
+ `type: "url"` 與明確的 `url` 指向既有 DSM gateway。明確 `access: public` 才保留 Web Station
61
+ alias;這是服務入口,不是 SDK Application Portal。DSM 會將 standalone URL 改寫成 launchApp 頁面,
62
+ 需要 AppInstance/AppWindow JS class;schema 1 web service 沒有該 class,因此使用 url。需要 DSM 視窗時明寫 `dsm-window`。
63
+
64
+ Schema 2 的 `dsm-package.yaml` + `identity_gateway` + `compose` 同樣視為
65
+ web service:省略 `ui.launch` 時預設 `new-page`,使用 standalone launcher,
66
+ 不帶 Application Portal。兩條 Compose 路線共用 `contract/app-launch.ts`
67
+ 解析開啟方式與選擇 launcher 型別;明確的 `ui.launch` 可覆寫預設。
68
+ 只有沒有 web service 的 SDK 原生 App 省略時才預設 `dsm-window`。
@@ -94,3 +94,34 @@ with the same PasswordConfirm form and no extra dialog. Mismatch must be explici
94
94
  Restrict the existing NasPicker to `selection.root`; do not grant via cached paths.
95
95
  The default facade includes typed `destination(key)` and `exportExample({key,job_id})`;
96
96
  use original operation references and display Saved only for protected receipts.
97
+
98
+ ## 開啟方式
99
+
100
+ 原生 App 在 `dsm-package.yaml` 設定 `ui.launch: dsm-window | new-page`。
101
+ SDK 原生內建預設為 `dsm-window`;使用 `@synopackageland/ui` 維持視窗,
102
+ 其他原生前端必須明確寫 `ui.launch: new-page`。
103
+ Scaffold 明確寫入此選擇,plain/binary 使用 `new-page`,Vue UI SDK 使用
104
+ 預設的 `dsm-window`(省略 `ui.launch`)。原生 manifest 省略 `ui.launch` 時解析為 `dsm-window`,CLI 不猜測前端依賴。
105
+
106
+ `new-page` 產生 DSM `type: "standalone"` launcher,主選單及套件中心皆在 DSM
107
+ 本身的 origin 開啟全頁。`ctx.fullPage`、`ctx.appIcon`、SynoToken/session、
108
+ `ctx.launchParams`/`onLaunch`、`entry` 權限沿用共用 wrapper;全頁的標題、
109
+ favicon 與手機 viewport 由 wrapper 設定。不需要 `pureEnv` 或 `allowAltPort`。
110
+
111
+ Application Portal 是獨立 opt-in:不想要別名就省略 `portal`,不生成
112
+ `defaultAlias`、Portal 面板/capability 或 Web Station alias;沒有 Portal
113
+ 不是 inspect/info/doctor 問題。明確啟用 `portal` 時才將其 alias 作為 `defaultAlias`。
114
+
115
+ Docker/web service App 設定 `integration.web.launch`,內建預設是 `new-page`;
116
+ 省略開啟方式時直接開新分頁,不宣告 Application Portal。SDK 原生 App
117
+ 省略時使用 `dsm-window`;非 UI SDK 原生 App 必須明確寫 `ui.launch: new-page`。
118
+ Web service 主選單使用
119
+ `type: "url"` 與明確的 `url` 指向既有 DSM gateway。明確 `access: public` 才保留 Web Station
120
+ alias;這是服務入口,不是 SDK Application Portal。DSM 會將 standalone URL 改寫成 launchApp 頁面,
121
+ 需要 AppInstance/AppWindow JS class;schema 1 web service 沒有該 class,因此使用 url。需要 DSM 視窗時明寫 `dsm-window`。
122
+
123
+ Schema 2 的 `dsm-package.yaml` + `identity_gateway` + `compose` 同樣視為
124
+ web service:省略 `ui.launch` 時預設 `new-page`,使用 standalone launcher,
125
+ 不帶 Application Portal。兩條 Compose 路線共用 `contract/app-launch.ts`
126
+ 解析開啟方式與選擇 launcher 型別;明確的 `ui.launch` 可覆寫預設。
127
+ 只有沒有 web service 的 SDK 原生 App 省略時才預設 `dsm-window`。
@@ -34,3 +34,12 @@ the legacy Compose route below retains its restrictions.
34
34
 
35
35
  - 要身分/檔案整合 → `identity.md`/`files.md`
36
36
  - 要上 NAS → `deploy.md`
37
+
38
+ ## 開啟方式
39
+
40
+ Docker/web service App 的 `integration.web.launch` 內建預設為 `new-page`。
41
+ 主選單與套件中心開啟既有 gateway,不產生 Application Portal。
42
+ schema 1 新分頁 launcher 使用 `type: "url"` 與明確 `url`;DSM 的 standalone
43
+ 會改寫成需要 AppWindow JS class 的 launchApp 頁面。schema 2 SDK wrapper
44
+ 有此 class,因此使用 standalone。只有 `access: public` 需要 Web Station alias。
45
+ SDK 原生 App 的 `ui.launch` 內建預設是 `dsm-window`;非 UI SDK 前端明寫 `new-page`。