@synopackageland/cli 0.24.1 → 0.25.0

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,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.25.0 (2026-10-22)
4
+
5
+ - Add the opt-in `console` declaration for native Apps with `identity_gateway`: a native UI URL launcher and an administrator-only DSM console, selected by Package Center Open. Validate both entries and refuse incompatible launch declarations.
6
+ - Fix installation-settings inspection when native identity-gateway diagnostics precede the settings hook, retaining full lifecycle validation.
7
+ - Document punctuation rules for owned UI copy in the development skill.
8
+ - Support SDK/App 0.25.0 and templates 0.25.0, with UI 0.14.3 in Vue scaffolds. Reuse executor/v0.24.0 with unchanged protected executor/lifecycle payloads and Go 1.26.10. OIDC browser cold-login continuation requires SDK 0.25.0. No NAS acceptance is claimed.
9
+
3
10
  ## 0.24.1 (2026-10-22)
4
11
 
5
12
  - Build SDK/App 0.24.1 with the verified immutable executor/v0.24.0 release and its exact executor/lifecycle digests; retain SDK/App 0.24.0 support. SDK 0.24.1 adds AppShell brand navigation to the first page and supports UI 0.14.3.
package/README.md CHANGED
@@ -268,6 +268,16 @@ A full page does not need Application Portal. Omit `portal` unless an alias is
268
268
  explicitly wanted; only that opt-in adds `defaultAlias`, login styling, Portal
269
269
  panels and capabilities. No Portal is a healthy default for inspect/info/doctor.
270
270
 
271
+ A native App with `identity_gateway` can declare `console` to get two main-menu
272
+ icons: the native UI (`displayname`, a `type: "url"` launcher to `sso/open.cgi`
273
+ that opens a new tab) and an administrator-only DSM window running the SDK UI
274
+ from `ui.dir`, named "<displayname> for DSM" by default. Package Center's Open
275
+ (INFO `dsmapplaunchname`) starts the console, and Application Privileges lists
276
+ only the native icon. `console: {}` keeps the default name; `console: { title }`
277
+ sets it for every language. Omit `console` to keep one icon. check refuses
278
+ `console` on Compose, without `identity_gateway`, or with `ui.launch` or
279
+ `portal`. A cold DSM sign-in resumes through the url launcher.
280
+
271
281
  Compose uses schema-2 `dsm-package.yaml` and `ui.launch`; the default is new-page.
272
282
  Classless gateways use type url via the shared contract/app-launch resolver;
273
283
  AppWindow wrappers use standalone. Headless projects have no launcher.
@@ -18,6 +18,18 @@ export declare function uiTitleFields(app: AppTitle): {
18
18
  texts?: string;
19
19
  preloadTexts?: string[];
20
20
  };
21
+ /** The console's name: the declared title in every language, else "<displayname> for DSM". */
22
+ export declare function consoleTitle(displayname: string, console: {
23
+ title?: string;
24
+ }): string;
25
+ /** {@link uiTitleFields} for the console window, which shares the App's description. */
26
+ export declare function uiConsoleTitleFields(app: AppTitle, console: {
27
+ title?: string;
28
+ }): ReturnType<typeof uiTitleFields>;
21
29
  /** `ui/texts/<dsm lang>/strings` for English and every translated language; none without translations. */
22
- export declare function renderUiTexts(app: AppTitle): Map<string, string>;
23
- export declare function writeUiTexts(uiRoot: string, app: AppTitle): void;
30
+ export declare function renderUiTexts(app: AppTitle, console?: {
31
+ title?: string;
32
+ }): Map<string, string>;
33
+ export declare function writeUiTexts(uiRoot: string, app: AppTitle, console?: {
34
+ title?: string;
35
+ }): void;
@@ -4,6 +4,7 @@ import { DSM_LANGS, LOCALES } from "../standalone/i18n.js";
4
4
  const SECTION = "synopkgland";
5
5
  const TITLE = `${SECTION}:app_title`;
6
6
  const DESC = `${SECTION}:app_desc`;
7
+ const CONSOLE_TITLE = `${SECTION}:console_title`;
7
8
  /** `ui/` subdirectory with the strings; a `ui/config` entry's `texts` names it relative to `ui/`. */
8
9
  const TEXTS_DIR = "texts";
9
10
  function translated(app) {
@@ -21,8 +22,18 @@ export function uiTitleFields(app) {
21
22
  ? { title: TITLE, desc: DESC, texts: TEXTS_DIR, preloadTexts: [TITLE, DESC] }
22
23
  : { title: app.displayname, desc: app.description };
23
24
  }
25
+ /** The console's name: the declared title in every language, else "<displayname> for DSM". */
26
+ export function consoleTitle(displayname, console) {
27
+ return console.title ?? `${displayname} for DSM`;
28
+ }
29
+ /** {@link uiTitleFields} for the console window, which shares the App's description. */
30
+ export function uiConsoleTitleFields(app, console) {
31
+ return translated(app)
32
+ ? { title: CONSOLE_TITLE, desc: DESC, texts: TEXTS_DIR, preloadTexts: [CONSOLE_TITLE, DESC] }
33
+ : { title: consoleTitle(app.displayname, console), desc: app.description };
34
+ }
24
35
  /** `ui/texts/<dsm lang>/strings` for English and every translated language; none without translations. */
25
- export function renderUiTexts(app) {
36
+ export function renderUiTexts(app, console) {
26
37
  const files = new Map();
27
38
  if (!translated(app))
28
39
  return files;
@@ -32,12 +43,13 @@ export function renderUiTexts(app) {
32
43
  continue;
33
44
  const title = text?.displayname ?? app.displayname;
34
45
  const desc = text?.description ?? app.description;
35
- files.set(DSM_LANGS[locale], `[${SECTION}]\napp_title="${title}"\napp_desc="${desc}"\n`);
46
+ const consoleLine = console ? `console_title="${consoleTitle(title, console)}"\n` : "";
47
+ files.set(DSM_LANGS[locale], `[${SECTION}]\napp_title="${title}"\napp_desc="${desc}"\n${consoleLine}`);
36
48
  }
37
49
  return files;
38
50
  }
39
- export function writeUiTexts(uiRoot, app) {
40
- for (const [lang, strings] of renderUiTexts(app)) {
51
+ export function writeUiTexts(uiRoot, app, console) {
52
+ for (const [lang, strings] of renderUiTexts(app, console)) {
41
53
  mkdirSync(join(uiRoot, TEXTS_DIR, lang), { recursive: true });
42
54
  const path = join(uiRoot, TEXTS_DIR, lang, "strings");
43
55
  writeFileSync(path, strings, "utf8");
@@ -6,6 +6,9 @@ export declare const DEFAULT_WEB_LAUNCH: AppLaunchMode;
6
6
  export declare function isAppLaunchMode(value: unknown): value is AppLaunchMode;
7
7
  /** Both Compose routes use the same web default; a valid explicit choice wins. */
8
8
  export declare function resolveAppLaunch(value: unknown, webService: boolean): AppLaunchMode | undefined;
9
- export declare function resolvedSdkLaunch(manifest: Pick<StandaloneManifest, 'ui' | 'compose' | 'identityGateway'>): AppLaunchMode;
9
+ /** A console always opens the SDK UI in a DSM window. */
10
+ export declare function resolvedSdkLaunch(manifest: Pick<StandaloneManifest, 'ui' | 'compose' | 'identityGateway'> & {
11
+ console?: unknown;
12
+ }): AppLaunchMode;
10
13
  /** DSM rewrites standalone URLs to launchApp; only SDK wrappers provide that class. */
11
14
  export declare function dsmLauncherType(launch: AppLaunchMode, sdkWindow: boolean): 'standalone' | 'app' | 'url';
@@ -9,8 +9,9 @@ export function resolveAppLaunch(value, webService) {
9
9
  return value === undefined ? webService ? DEFAULT_WEB_LAUNCH : DEFAULT_NATIVE_LAUNCH
10
10
  : isAppLaunchMode(value) ? value : undefined;
11
11
  }
12
+ /** A console always opens the SDK UI in a DSM window. */
12
13
  export function resolvedSdkLaunch(manifest) {
13
- return resolveAppLaunch(manifest.ui.launch, Boolean(manifest.compose || manifest.identityGateway));
14
+ return resolveAppLaunch(manifest.console ? 'dsm-window' : manifest.ui.launch, Boolean(manifest.compose || manifest.identityGateway));
14
15
  }
15
16
  /** DSM rewrites standalone URLs to launchApp; only SDK wrappers provide that class. */
16
17
  export function dsmLauncherType(launch, sdkWindow) {
@@ -532,6 +532,26 @@ export const ERROR_REGISTRY = {
532
532
  description: "The portal format or alias in dsm-package.yaml is invalid.",
533
533
  suggestion: "Use portal: {} or portal: { alias: my-app }; alias is 1-64 lowercase alphanumerics or -, and not a DSM reserved path.",
534
534
  },
535
+ STANDALONE_CONSOLE_INVALID: {
536
+ code: "STANDALONE_CONSOLE_INVALID",
537
+ description: "The console format or title in dsm-package.yaml is invalid.",
538
+ suggestion: 'Use console: {} for "<displayname> for DSM", or console: { title: My App Admin } with 1-64 characters and no quotes, backslashes or control characters.',
539
+ },
540
+ STANDALONE_CONSOLE_REQUIRES_GATEWAY: {
541
+ code: "STANDALONE_CONSOLE_REQUIRES_GATEWAY",
542
+ description: "console is declared on a Compose App or an App without identity_gateway.",
543
+ suggestion: "Declare console only on a native App whose web UI the identity gateway serves; otherwise remove console and keep one icon.",
544
+ },
545
+ STANDALONE_CONSOLE_LAUNCH: {
546
+ code: "STANDALONE_CONSOLE_LAUNCH",
547
+ description: "console is declared together with ui.launch.",
548
+ suggestion: "Remove ui.launch: the console always opens in a DSM window and the native icon in a new tab.",
549
+ },
550
+ STANDALONE_CONSOLE_PORTAL: {
551
+ code: "STANDALONE_CONSOLE_PORTAL",
552
+ description: "console is declared together with portal.",
553
+ suggestion: "Declare either console or portal, not both.",
554
+ },
535
555
  STANDALONE_PORTAL_REQUIRES_EXECUTOR: {
536
556
  code: "STANDALONE_PORTAL_REQUIRES_EXECUTOR",
537
557
  description: "An App targeting DSM below 7.2-64570 declares portal without an executor.",
@@ -1,16 +1,5 @@
1
1
  {
2
- "0.24.0": {
3
- "x86_64": "de78684faac211c24a7cda8a4541ab1b138d37f6d580936fe354bb9bd0d78a0f",
4
- "aarch64": "8ef7c736203f6c111816b76f970b7204fa6f8d871ff3222fd7c0a27d0075b847",
5
- "armv7": "a38703119554b5f2a8bf4810ec2a6c8f1d793bfbb1ca96803eb1b39a26ffd96c",
6
- "lifecycle": {
7
- "x86_64": "c6fbb377ecd5ec7ee5b96439f22091d9fa5965dfd616d510ca9445722f436721",
8
- "aarch64": "a9ca1750d4d5cff4727dc29789e6eaf0a365cb0e674f1bb796971d82f2d4fa27",
9
- "armv7": "6d25fc972eba12e3ac68f8b6a95a45e9e5706ebe19ffc1ff774fc318acbeb90f"
10
- },
11
- "goToolchain": "go1.26.10"
12
- },
13
- "0.24.1": {
2
+ "0.25.0": {
14
3
  "x86_64": "de78684faac211c24a7cda8a4541ab1b138d37f6d580936fe354bb9bd0d78a0f",
15
4
  "aarch64": "8ef7c736203f6c111816b76f970b7204fa6f8d871ff3222fd7c0a27d0075b847",
16
5
  "armv7": "a38703119554b5f2a8bf4810ec2a6c8f1d793bfbb1ca96803eb1b39a26ffd96c",
@@ -76,7 +76,7 @@ function goMod(backendDir) {
76
76
  }
77
77
  }
78
78
  /** SDK releases that reuse an immutable executor release with identical payload bytes. */
79
- const EXECUTOR_RELEASES = { '0.21.0': '0.20.0', '0.21.1': '0.20.0', '0.22.0': '0.20.0', '0.23.0': '0.20.0', '0.23.1': '0.20.0', '0.23.2': '0.20.0', '0.24.1': '0.24.0' };
79
+ const EXECUTOR_RELEASES = { '0.21.0': '0.20.0', '0.21.1': '0.20.0', '0.22.0': '0.20.0', '0.23.0': '0.20.0', '0.23.1': '0.20.0', '0.23.2': '0.20.0', '0.24.1': '0.24.0', '0.25.0': '0.24.0' };
80
80
  /**
81
81
  * The module directories of one SDK version. SYNOPKGLAND_SDK_DIR names a
82
82
  * local sdk checkout (development and gates only); otherwise modules come from
@@ -25,8 +25,13 @@ export declare function windowScriptName(manifest: Pick<StandaloneManifest, "bui
25
25
  /** The launcher fields an entry mode fixes; inspect checks a built SPK against them. */
26
26
  export declare function entryLauncherAccess(entry: EntryMode): Record<string, unknown>;
27
27
  export declare function appTitle(manifest: StandaloneManifest): AppTitle;
28
+ /** The DSM app class whose launch opens the SDK window: the console's when the App declares one. */
29
+ export declare function windowAppId(manifest: Pick<StandaloneManifest, "package" | "console">): string;
28
30
  /**
29
- * `ui/config`: registers the DSM main-menu app and its window class. A portal
31
+ * `ui/config`: registers the DSM main-menu app and its window class. With a
32
+ * console, `.Instance` is the gateway's url launcher (new tab) and `.Console`
33
+ * the administrator-only window; DSM lists only `.Instance` under Application
34
+ * Privileges. A portal
30
35
  * App's launcher also carries the Login Portal style and its icon as the
31
36
  * login page icon (DSM shows `loginIcon`, never `icon`, there).
32
37
  */
@@ -9,7 +9,7 @@ import { HOST_ADAPTER_RUNTIME } from "./host-adapter.js";
9
9
  import { permitsOrdinaryUsers } from "./api-permissions.js";
10
10
  import { portalRuntime } from "./portal-window.js";
11
11
  import { portalByExecutor } from "../build-spk/dsm-version.js";
12
- import { uiTitleFields } from "../build-spk/ui-texts.js";
12
+ import { consoleTitle, uiConsoleTitleFields, uiTitleFields } from "../build-spk/ui-texts.js";
13
13
  import { packageIcons, windowIconFile, windowIconTemplate, } from "../build-spk/dsm-icons.js";
14
14
  import { DEFAULT_WINDOW, FULL_PAGE_MIN_WIDTH } from "./window-size.js";
15
15
  import { requestTimeoutSeconds } from "./request-timeout.js";
@@ -74,7 +74,7 @@ function launcherAccess(manifest) {
74
74
  return manifest.entry
75
75
  ? entryLauncherAccess(manifest.entry)
76
76
  : {
77
- allUsers: manifest.compose
77
+ allUsers: manifest.compose || manifest.console
78
78
  ? manifest.identityGateway?.access !== "dsm-admins"
79
79
  : permitsOrdinaryUsers(manifest.apiPermissions),
80
80
  };
@@ -92,19 +92,35 @@ export function appTitle(manifest) {
92
92
  info: manifest.i18n?.info,
93
93
  };
94
94
  }
95
- /** The app name by locale, as `ui/texts` names the window: `createI18n(lang, ...).t("app_title")`. */
95
+ /**
96
+ * The window's name by locale, as `ui/texts` names it: `createI18n(lang,
97
+ * ...).t("app_title")`. A console window carries the console's name.
98
+ */
96
99
  function displayNameCatalogs(manifest) {
100
+ const name = (displayname) => manifest.console ? consoleTitle(displayname, manifest.console) : displayname;
97
101
  const catalogs = {
98
- en: { app_title: manifest.displayname },
102
+ en: { app_title: name(manifest.displayname) },
99
103
  };
100
104
  for (const [locale, text] of Object.entries(manifest.i18n?.info ?? {})) {
101
105
  if (text?.displayname)
102
- catalogs[locale] = { app_title: text.displayname };
106
+ catalogs[locale] = { app_title: name(text.displayname) };
103
107
  }
104
108
  return catalogs;
105
109
  }
110
+ /** The DSM app class whose launch opens the SDK window: the console's when the App declares one. */
111
+ export function windowAppId(manifest) {
112
+ return `SYNO.SDS.${manifest.package}.${manifest.console ? "Console" : "Instance"}`;
113
+ }
114
+ /** Whether the window itself resumes a cold DSM sign-in; with a console the url launcher does. */
115
+ function windowResumesOIDC(manifest) {
116
+ const oidc = manifest.identityGateway?.oidc;
117
+ return Boolean((oidc?.native_login || oidc?.browser) && !manifest.console);
118
+ }
106
119
  /**
107
- * `ui/config`: registers the DSM main-menu app and its window class. A portal
120
+ * `ui/config`: registers the DSM main-menu app and its window class. With a
121
+ * console, `.Instance` is the gateway's url launcher (new tab) and `.Console`
122
+ * the administrator-only window; DSM lists only `.Instance` under Application
123
+ * Privileges. A portal
108
124
  * App's launcher also carries the Login Portal style and its icon as the
109
125
  * login page icon (DSM shows `loginIcon`, never `icon`, there).
110
126
  */
@@ -127,6 +143,38 @@ export function renderUiConfig(manifest, icons = packageIcons(manifest.icon), lo
127
143
  },
128
144
  }, null, 2) + "\n");
129
145
  }
146
+ if (manifest.console) {
147
+ const consoleFields = uiConsoleTitleFields(appTitle(manifest), manifest.console);
148
+ return `${JSON.stringify({
149
+ ".url": {
150
+ [`${ns}.Instance`]: {
151
+ type: "url",
152
+ ...titleFields,
153
+ icon,
154
+ ...launcherAccess(manifest),
155
+ url: gatewayEntryPath(manifest),
156
+ },
157
+ },
158
+ [windowScriptName(manifest)]: {
159
+ [`${ns}.Console`]: {
160
+ type: "app",
161
+ ...consoleFields,
162
+ icon,
163
+ appWindow: `${ns}.MainWindow`,
164
+ allowMultiInstance: false,
165
+ allUsers: false,
166
+ depend: [`${ns}.MainWindow`],
167
+ },
168
+ [`${ns}.MainWindow`]: {
169
+ type: "lib",
170
+ title: consoleFields.title,
171
+ texts: consoleFields.texts,
172
+ icon,
173
+ depend: [],
174
+ },
175
+ },
176
+ }, null, 2)}\n`;
177
+ }
130
178
  return `${JSON.stringify({
131
179
  [windowScriptName(manifest)]: {
132
180
  [`${ns}.Instance`]: {
@@ -379,7 +427,7 @@ ${ns}.prepareFullPage = function (title) {
379
427
  favicon.href = ${ns}.appIcon;
380
428
  };
381
429
 
382
- ${(manifest.identityGateway?.oidc?.native_login || manifest.identityGateway?.oidc?.browser) ? `${ns}.resumeOIDC = ${oidcResumeRuntime(pkg, Object.values(manifest.identityGateway.listen).filter((p) => typeof p === "number"))};` : ""}
430
+ ${windowResumesOIDC(manifest) ? `${ns}.resumeOIDC = ${oidcResumeRuntime(pkg, Object.values(manifest.identityGateway.listen).filter((p) => typeof p === "number"))};` : ""}
383
431
 
384
432
  // DSM reopens a window at the size it was last left at, even one saved on a
385
433
  // larger screen, and only moves a window that does not fit: keep it inside.
@@ -390,7 +438,7 @@ ${ns}.fitDesktop = function (size) {
390
438
  return size;
391
439
  };
392
440
 
393
- Ext.define(${JSON.stringify(`${ns}.Instance`)}, {
441
+ Ext.define(${JSON.stringify(windowAppId(manifest))}, {
394
442
  extend: "SYNO.SDS.AppInstance",
395
443
  appWindowName: ${JSON.stringify(`${ns}.MainWindow`)}
396
444
  });
@@ -408,7 +456,7 @@ Ext.define(${JSON.stringify(`${ns}.MainWindow`)}, {
408
456
  },
409
457
  onOpen: function (param) {
410
458
  this.callParent(arguments);
411
- ${(manifest.identityGateway?.oidc?.native_login || manifest.identityGateway?.oidc?.browser) ? `if (${ns}.resumeOIDC(param)) return;` : ""}
459
+ ${windowResumesOIDC(manifest) ? `if (${ns}.resumeOIDC(param)) return;` : ""}
412
460
  var me = this;
413
461
  if (me.mounted) {
414
462
  me.reopenEvents.emit();
@@ -471,7 +519,7 @@ ${root ? root.mount : ""} ${ns}.loadApp(function (app) {
471
519
  // with that launch's param. Before the App mounts, it mounts with the latest.
472
520
  onRequest: function (param) {
473
521
  this.callParent(arguments);
474
- ${(manifest.identityGateway?.oidc?.native_login || manifest.identityGateway?.oidc?.browser) ? `if (${ns}.resumeOIDC(param)) return;` : ""}
522
+ ${windowResumesOIDC(manifest) ? `if (${ns}.resumeOIDC(param)) return;` : ""}
475
523
  var params = ${ns}.launchParams(param);
476
524
  if (!this.mounted) {
477
525
  if (this.mountPending && Object.keys(params).length) this.launchParams = params;
@@ -36,7 +36,7 @@ import { renderNativePrivilege } from "../build-spk/native-lifecycle.js";
36
36
  import { renderUiConfig } from "./dsm-window.js";
37
37
  import { identityGatewayConfig, gatewayAdmissionReport, gatewayAdmissionPolicy, readIdentityGateway, } from "./identity-gateway.js";
38
38
  import { checkCgiBinary } from "./binary.js";
39
- import { matchesStandaloneScript, renderStandaloneScript } from "./lifecycle.js";
39
+ import { matchesStandaloneScript, renderStandaloneScript, matchesStandaloneScriptPrefix } from "./lifecycle.js";
40
40
  import { listPortExposures, loadCompose } from "../compose/analyze.js";
41
41
  import { uninstallCleanup, } from "../runtime/uninstall-cleanup.js";
42
42
  import { PORTAL_JSON_RELPATH, PORTAL_MUSTACHE_RELPATH, renderPortalJson, renderPortalLifecycle, renderPortalMustache, renderPortalWizardStep, } from "../portal/package.js";
@@ -112,7 +112,8 @@ export async function inspectSpk(spkPath, expectedKind) {
112
112
  ].includes(String(name))) ||
113
113
  new Set(raw.capabilities).size !== raw.capabilities.length ||
114
114
  (raw.entry !== undefined &&
115
- !["admin-only", "app-privilege"].includes(raw.entry)))
115
+ !["admin-only", "app-privilege"].includes(raw.entry)) ||
116
+ (raw.console !== undefined && raw.console !== true))
116
117
  throw new Error();
117
118
  if (raw.lifecycleTimeouts !== undefined && (!readLifecycleTimeouts(raw.lifecycleTimeouts, () => { }) || Object.keys(raw.lifecycleTimeouts).length !== 4))
118
119
  throw new Error();
@@ -272,7 +273,7 @@ export async function inspectSpk(spkPath, expectedKind) {
272
273
  }
273
274
  if (metadata.entry)
274
275
  await checkEntryLauncher(dir, info.package, metadata.entry, findings);
275
- const launch = await checkLaunchConfiguration(dir, info, standalone, metadata.launch, portal?.alias ?? executor?.policy.portal?.alias, findings);
276
+ const launch = await checkLaunchConfiguration(dir, info, standalone, metadata.launch, portal?.alias ?? executor?.policy.portal?.alias, findings, metadata.console === true);
276
277
  const loginStyle = standalone
277
278
  ? await inspectLoginStyle(dir, info.package, Boolean(portal || executor?.policy.portal), findings)
278
279
  : undefined;
@@ -336,7 +337,7 @@ export async function inspectSpk(spkPath, expectedKind) {
336
337
  const LAUNCHER_TYPE_MISMATCH = "LAUNCHER_TYPE_MISMATCH";
337
338
  const LAUNCHER_PORTAL_MISMATCH = "LAUNCHER_PORTAL_MISMATCH";
338
339
  /** Compare the actual launcher and Portal fields to the packaged declaration. */
339
- async function checkLaunchConfiguration(dir, info, native, declared, alias, findings) {
340
+ async function checkLaunchConfiguration(dir, info, native, declared, alias, findings, console = false) {
340
341
  const target = join(dir, "launch-inspection");
341
342
  mkdirSync(target);
342
343
  await tar.x({
@@ -387,6 +388,10 @@ async function checkLaunchConfiguration(dir, info, native, declared, alias, find
387
388
  catch {
388
389
  /* report the missing or malformed launcher below */
389
390
  }
391
+ if (console) {
392
+ checkConsoleLaunchers(target, info, native, findings);
393
+ return launch;
394
+ }
390
395
  const type = dsmLauncherType(launch, native);
391
396
  if (!launcher ||
392
397
  launcher.type !== type ||
@@ -406,6 +411,35 @@ async function checkLaunchConfiguration(dir, info, native, declared, alias, find
406
411
  }
407
412
  return launch;
408
413
  }
414
+ /**
415
+ * A console App's ui/config: `.Instance` is the url launcher to the gateway,
416
+ * `.Console` the administrator-only SDK window Package Center's Open starts.
417
+ */
418
+ function checkConsoleLaunchers(target, info, native, findings) {
419
+ const ns = `SYNO.SDS.${info.package}`;
420
+ let config = {};
421
+ try {
422
+ config = JSON.parse(readFileSync(join(target, "ui/config"), "utf8"));
423
+ }
424
+ catch {
425
+ /* reported below as a missing launcher */
426
+ }
427
+ const find = (id) => Object.values(config).map((entries) => entries?.[id]).find(Boolean);
428
+ const instance = find(`${ns}.Instance`);
429
+ const console = find(`${ns}.Console`);
430
+ if (!native ||
431
+ instance?.type !== "url" ||
432
+ instance.url !== `/webman/3rdparty/${info.package}/sso/open.cgi` ||
433
+ console?.type !== "app" ||
434
+ console.appWindow !== `${ns}.MainWindow` ||
435
+ console.allUsers !== false ||
436
+ console.grantPrivilege !== undefined ||
437
+ info.dsmuidir !== "ui" ||
438
+ info.dsmappname !== `${ns}.Instance ${ns}.Console` ||
439
+ info.dsmapplaunchname !== `${ns}.Console`) {
440
+ findings.push(`${LAUNCHER_TYPE_MISMATCH} console requires a url launcher at ${ns}.Instance to sso/open.cgi, an administrator-only app launcher at ${ns}.Console and INFO dsmapplaunchname=${ns}.Console; rebuild with synopkgland build`);
441
+ }
442
+ }
409
443
  /** The launcher in ui/config must carry exactly the access the declared entry mode fixes. */
410
444
  async function checkEntryLauncher(dir, pkg, entry, findings) {
411
445
  const target = join(dir, "entry-inspection");
@@ -1229,8 +1263,8 @@ async function inspectInstallSettings(dir, payload, metadata, findings) {
1229
1263
  mkdirSync(root);
1230
1264
  await tar.x({ file: join(dir, 'package.tgz'), cwd: root });
1231
1265
  const info = readInfo(join(dir, 'INFO'), findings, 'binary', Boolean(metadata.compose));
1232
- const gateway = metadata.compose?.gateway ?? (metadata.native_gateway ? { listen: metadata.native_gateway.listen } : undefined);
1233
- const m = { package: info.package, installSettings: settings, identityGateway: gateway };
1266
+ const gateway = metadata.compose?.gateway ?? metadata.native_gateway;
1267
+ const m = { package: info.package, os_min_ver: info.os_min_ver, capabilities: {}, installSettings: settings, identityGateway: gateway };
1234
1268
  try {
1235
1269
  if (readOptional(join(root, 'install/settings.json')) !== JSON.stringify(settingsConfig(m), null, 2) + '\n')
1236
1270
  throw new Error('settings inventory differs from its declaration or gateway port');
@@ -1243,7 +1277,8 @@ async function inspectInstallSettings(dir, payload, metadata, findings) {
1243
1277
  // App wrappers remain intact here; remove exactly the SDK prefix within them.
1244
1278
  for (const name of ['preinst', 'postinst', 'postupgrade', 'preuninst']) {
1245
1279
  const file = join(dir, 'scripts', name), text = readOptional(file), prefix = renderSettingsHook(name, m);
1246
- if (!text || !sdkScriptOf(name, text)?.sdk.startsWith('#!/bin/sh\n' + prefix) || text.split(prefix).length !== 2)
1280
+ const sdk = text && sdkScriptOf(name, text)?.sdk;
1281
+ if (!sdk || !matchesStandaloneScriptPrefix(name, m, sdk) || text.split(prefix).length !== 2)
1247
1282
  throw new Error(`scripts/${name} lacks the exact SDK settings hook`);
1248
1283
  writeFileSync(file, text.replace(prefix, ''));
1249
1284
  }
@@ -1,5 +1,8 @@
1
1
  import type { StandaloneManifest } from "./manifest.js";
2
2
  /** Package-account lifecycle hooks for CGI, daemon and background packages. */
3
3
  export declare function renderStandaloneScript(name: string, manifest: StandaloneManifest): string;
4
+ /** Settings inspection peels its hook only at the position build's renderer writes.
5
+ * The backend inspector then replays the entire remaining lifecycle script. */
6
+ export declare function matchesStandaloneScriptPrefix(name: string, manifest: StandaloneManifest, actual: string): boolean;
4
7
  /** Accept the exact current, diagnostic-only, and released lifecycle variants. */
5
8
  export declare function matchesStandaloneScript(name: string, manifest: StandaloneManifest, actual: string | undefined): boolean;
@@ -15,23 +15,34 @@ const UNINSTALL_ONLY = '[ "${SYNOPKG_PKG_STATUS:-}" = UPGRADE ] || synopkgland_u
15
15
  /** Package-account lifecycle hooks for CGI, daemon and background packages. */
16
16
  export function renderStandaloneScript(name, manifest) {
17
17
  const script = renderStandaloneScriptInternal(name, manifest);
18
- const prepared = ['postinst', 'postupgrade'].includes(name) ? renderNativeGatewayPrepare(manifest) : '';
19
- const prefix = renderNativeGatewayDiagnostic(manifest, name) + renderNativeGatewayPortWait(manifest, name) + renderSettingsHook(name, manifest) + prepared;
18
+ const prefix = renderStandaloneScriptPrefix(name, manifest);
20
19
  const sdk = prefix ? script.replace('#!/bin/sh\n', () => '#!/bin/sh\n' + prefix) : script;
21
20
  return manifest.appScripts?.some((app) => app === name)
22
21
  ? composeAppScript(name, sdk)
23
22
  : sdk;
24
23
  }
24
+ function renderStandaloneScriptPrefix(name, manifest) {
25
+ const prepared = ['postinst', 'postupgrade'].includes(name) ? renderNativeGatewayPrepare(manifest) : '';
26
+ return renderNativeGatewayDiagnostic(manifest, name) + renderNativeGatewayPortWait(manifest, name) + renderSettingsHook(name, manifest) + prepared;
27
+ }
28
+ /** Settings inspection peels its hook only at the position build's renderer writes.
29
+ * The backend inspector then replays the entire remaining lifecycle script. */
30
+ export function matchesStandaloneScriptPrefix(name, manifest, actual) {
31
+ const blocks = [renderNativeGatewayDiagnostic(manifest, name), renderNativeGatewayPortWait(manifest, name)].filter(Boolean);
32
+ return standaloneScriptVariants(name, manifest, renderStandaloneScriptPrefix(name, manifest)).some(prefix => actual.startsWith('#!/bin/sh\n' + prefix) &&
33
+ // A released prefix omits these blocks entirely; moving one after the
34
+ // settings hook must not masquerade as that released variant.
35
+ blocks.every(block => prefix.includes(block) || !actual.includes(block)));
36
+ }
25
37
  /** Accept the exact current, diagnostic-only, and released lifecycle variants. */
26
38
  export function matchesStandaloneScript(name, manifest, actual) {
27
- const expected = renderStandaloneScript(name, manifest);
28
- if (actual === expected)
29
- return true;
39
+ return actual !== undefined && standaloneScriptVariants(name, manifest, renderStandaloneScript(name, manifest)).includes(actual);
40
+ }
41
+ function standaloneScriptVariants(name, manifest, expected) {
30
42
  const diagnostic = renderNativeGatewayDiagnostic(manifest, name);
31
43
  const wait = renderNativeGatewayPortWait(manifest, name);
32
44
  const diagnosticOnly = wait ? expected.replace(wait, '') : expected;
33
- return (wait !== '' && actual === diagnosticOnly) ||
34
- (diagnostic !== '' && actual === diagnosticOnly.replace(diagnostic, ''));
45
+ return [expected, ...(wait ? [diagnosticOnly] : []), ...(diagnostic ? [diagnosticOnly.replace(diagnostic, '')] : [])];
35
46
  }
36
47
  function renderStandaloneScriptInternal(name, manifest) {
37
48
  if (manifest.compose) {
@@ -100,6 +100,12 @@ export interface StandaloneManifest {
100
100
  entry?: EntryMode;
101
101
  /** `portal`: registers the DSM Application Portal entry; an administrator activates it. */
102
102
  portal?: PortalDeclaration;
103
+ /**
104
+ * `console`: the main menu gets two icons, the gateway's native UI in a new
105
+ * tab and the SDK UI in an administrator-only DSM window titled `title`
106
+ * (default "<displayname> for DSM"). Absent keeps the single window icon.
107
+ */
108
+ console?: ConsoleDeclaration;
103
109
  /** The project's dsm-scripts/<DSM script name> files, each run inside the generated script of that name. */
104
110
  appScripts?: AppScriptName[];
105
111
  /** `icon`: a square PNG of at least 256 px, or a directory of icon_<size>.png; absent uses the SDK default. */
@@ -112,6 +118,9 @@ export interface StandaloneManifest {
112
118
  * accounts DSM's Control Panel > Application Privileges allows for the app.
113
119
  */
114
120
  export type EntryMode = "admin-only" | "app-privilege";
121
+ export interface ConsoleDeclaration {
122
+ title?: string;
123
+ }
115
124
  export interface Capabilities {
116
125
  composeMounts?: true;
117
126
  webapi?: WebAPIProfiles;
@@ -56,6 +56,7 @@ export const KNOWN_FIELDS = new Set([
56
56
  "uninstall",
57
57
  "entry",
58
58
  "portal",
59
+ "console",
59
60
  "icon",
60
61
  "reproducible",
61
62
  "root",
@@ -65,6 +66,7 @@ const I18N_FIELDS = new Set(["messages", "glossary", "info"]);
65
66
  const PACKAGE_ID = /^[A-Za-z][A-Za-z0-9]{1,63}$/;
66
67
  const VERSION = /^\d+(\.\d+){0,3}$/;
67
68
  const OS_MIN_VER = /^\d+\.\d+(\.\d+)?-\d+$/;
69
+ const CONSOLE_TITLE = /^[^"\\\x00-\x1f\x7f]{1,64}$/;
68
70
  const GO_MAIN = /^\.\/[A-Za-z0-9_./-]*$/;
69
71
  function isRecord(value) {
70
72
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -399,6 +401,7 @@ function readManifest(raw, c) {
399
401
  : undefined;
400
402
  if (entryProblem)
401
403
  c.fail("STANDALONE_PORTAL_ENTRY_INVALID", entryProblem);
404
+ const dsmConsole = raw.console === undefined ? undefined : readConsole(raw, ui?.launch, c);
402
405
  const loginStyle = raw.login_style === undefined
403
406
  ? undefined
404
407
  : readLoginStyle(raw.login_style, raw.portal !== undefined, c);
@@ -454,6 +457,7 @@ function readManifest(raw, c) {
454
457
  ...(installSettings ? { installSettings } : {}),
455
458
  ...(entry ? { entry } : {}),
456
459
  ...(portal ? { portal } : {}),
460
+ ...(dsmConsole ? { console: dsmConsole } : {}),
457
461
  ...(icon ? { icon } : {}),
458
462
  ...(loginStyle ? { loginStyle } : {}),
459
463
  ...(appScripts ? { appScripts } : {}),
@@ -503,6 +507,30 @@ const LOGIN_STYLE_FIELDS = [
503
507
  "wallpaper_thumbnail",
504
508
  "title",
505
509
  ];
510
+ /**
511
+ * `console`: {} or { title }. The native icon is the gateway's url launcher,
512
+ * so the App needs identity_gateway and its own SDK window; that window is the
513
+ * console, never a new page or a portal.
514
+ */
515
+ function readConsole(raw, launch, c) {
516
+ const value = raw.console;
517
+ if (!isRecord(value) ||
518
+ Object.keys(value).some((k) => k !== "title") ||
519
+ (value.title !== undefined &&
520
+ (typeof value.title !== "string" || !CONSOLE_TITLE.test(value.title)))) {
521
+ c.fail("STANDALONE_CONSOLE_INVALID", 'console must be {} or { title: <1-64 characters, no quotes, backslashes or control characters> }.');
522
+ return undefined;
523
+ }
524
+ if (raw.identity_gateway === undefined || raw.compose !== undefined) {
525
+ c.fail("STANDALONE_CONSOLE_REQUIRES_GATEWAY", "console needs identity_gateway on an App with its own SDK UI: the native icon opens the gateway, the console opens the SDK UI. Compose and Apps without a gateway keep one icon.");
526
+ return undefined;
527
+ }
528
+ if (launch !== undefined)
529
+ c.fail("STANDALONE_CONSOLE_LAUNCH", "console always opens in a DSM window and the native icon in a new tab; remove ui.launch.");
530
+ if (raw.portal !== undefined)
531
+ c.fail("STANDALONE_CONSOLE_PORTAL", "console and portal both claim the App's SDK UI; declare one of them.");
532
+ return value.title === undefined ? {} : { title: value.title };
533
+ }
506
534
  /** `login_style`: a portal's colour, wallpaper (with its thumbnail) and title image, each optional. */
507
535
  function readLoginStyle(value, portal, c) {
508
536
  const invalid = (message) => c.fail("STANDALONE_LOGIN_STYLE_INVALID", message);
@@ -36,4 +36,9 @@ export interface AssembleOptions {
36
36
  */
37
37
  export declare function assembleStandaloneSpk(options: AssembleOptions): Promise<void>;
38
38
  /** Manifest validation already refused quotes, backslashes and newlines. */
39
+ /**
40
+ * INFO `dsmappname` lists the App's launchable classes. With a console,
41
+ * Package Center's Open (`dsmapplaunchname`) opens the console window.
42
+ */
43
+ export declare function consoleInfo(manifest: Pick<StandaloneManifest, "package" | "console">): Array<[string, string]>;
39
44
  export declare function renderInfo(manifest: StandaloneManifest, arch: NativeArchFamily, checksum: string, executorDigest?: string): string;
@@ -91,7 +91,7 @@ export async function assembleStandaloneSpk(options) {
91
91
  writeFile(join(uiRoot, windowScriptName(manifest)), renderWindowScript(manifest, assetVersion, icons, options.portalText));
92
92
  writeFile(join(uiRoot, hostScriptName(manifest.build)), renderHostAdapterScript(manifest.package));
93
93
  writeDsmWindowIcons(uiRoot, icons);
94
- writeUiTexts(uiRoot, appTitle(manifest));
94
+ writeUiTexts(uiRoot, appTitle(manifest), manifest.console);
95
95
  if (options.apiDescription)
96
96
  writeFile(join(staging, "package", "synopkgland-api.json"), JSON.stringify(options.apiDescription) + "\n");
97
97
  const cgiTarget = join(staging, "package", CGI_PAYLOAD_PATH);
@@ -242,6 +242,7 @@ export async function assembleStandaloneSpk(options) {
242
242
  }
243
243
  : {}),
244
244
  ...(manifest.entry ? { entry: manifest.entry } : {}),
245
+ ...(manifest.console ? { console: true } : {}),
245
246
  ...(manifest.uninstall
246
247
  ? {
247
248
  uninstall: uninstallRecord(manifest.uninstall, manifest.i18n?.catalogs),
@@ -319,6 +320,16 @@ function quote(value) {
319
320
  return `"${value.replace(/([\\"$`])/g, "\\$1")}"`;
320
321
  }
321
322
  /** Manifest validation already refused quotes, backslashes and newlines. */
323
+ /**
324
+ * INFO `dsmappname` lists the App's launchable classes. With a console,
325
+ * Package Center's Open (`dsmapplaunchname`) opens the console window.
326
+ */
327
+ export function consoleInfo(manifest) {
328
+ const ns = `SYNO.SDS.${manifest.package}`;
329
+ return manifest.console
330
+ ? [["dsmappname", `${ns}.Instance ${ns}.Console`], ["dsmapplaunchname", `${ns}.Console`]]
331
+ : [["dsmappname", `${ns}.Instance`]];
332
+ }
322
333
  export function renderInfo(manifest, arch, checksum, executorDigest) {
323
334
  const fields = [
324
335
  ["package", manifest.package],
@@ -329,7 +340,7 @@ export function renderInfo(manifest, arch, checksum, executorDigest) {
329
340
  ["arch", infoArchValue(arch)],
330
341
  ["os_min_ver", manifest.os_min_ver],
331
342
  ["dsmuidir", "ui"],
332
- ["dsmappname", `SYNO.SDS.${manifest.package}.Instance`],
343
+ ...consoleInfo(manifest),
333
344
  ["thirdparty", "yes"],
334
345
  ["startable", "yes"],
335
346
  ["checksum", checksum],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synopackageland/cli",
3
- "version": "0.24.1",
3
+ "version": "0.25.0",
4
4
  "description": "Syno Package Land Developer Kit CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,7 +27,7 @@
27
27
  },
28
28
  "dependencies": {
29
29
  "@napi-rs/keyring": "^2.1.1",
30
- "@synopackageland/templates": "0.24.0",
30
+ "@synopackageland/templates": "0.25.0",
31
31
  "ajv": "^8.17.1",
32
32
  "commander": "^13.1.0",
33
33
  "json-canonicalize": "^3.0.1",
package/skills/SKILL.md CHANGED
@@ -27,6 +27,18 @@ Read [reference/ui-vue.md](reference/ui-vue.md) when editing Vue components in t
27
27
 
28
28
  Read [reference/ui-json.md](reference/ui-json.md) when generating or editing JSON UI; discover the installed catalog, validate the spec, then render and fix layout issues.
29
29
 
30
+ ## UI copywriting
31
+
32
+ For UI/locale strings we author in the SDK, UI package, templates and our own
33
+ Apps, remove the final sentence-ending period in every language, even from
34
+ multi-sentence text; keep periods between sentences. Preserve question marks,
35
+ exclamation marks, ellipses, abbreviations, version numbers, URLs and trailing
36
+ `{key}` placeholders. In upstream forks, apply this only to UI copy we add under
37
+ `synology/` (such as DSM install wizards and gateway pages); leave upstream
38
+ UI/locale strings and release notes unchanged. See
39
+ [Vue copy and i18n](reference/ui-vue.md#ui-copy-and-i18n) and
40
+ [JSON copy and i18n](reference/ui-json.md#ui-copy-and-i18n) for examples.
41
+
30
42
  For UI discovery, run `npx --no syno-pkg-land-ui catalog --json` and
31
43
  `npx --no syno-pkg-land-ui describe Button --json` from the installed frontend.
32
44
  Read its prompt and exported Vue docs/types before choosing component APIs.
@@ -155,6 +167,15 @@ Application Portal 是獨立 opt-in:不想要別名就省略 `portal`,不生
155
167
  `defaultAlias`、Portal 面板/capability 或 Web Station alias;沒有 Portal
156
168
  不是 inspect/info/doctor 問題。明確啟用 `portal` 時才將其 alias 作為 `defaultAlias`。
157
169
 
170
+ 宣告 `console` 的 App 在主選單有兩個圖示,適用於包裝開源專案、又有額外整合操作的 App。
171
+ 原生圖示叫 `displayname`,是 `type: "url"`,經 `sso/open.cgi` 在新分頁開原生 UI。
172
+ 主控台預設叫「<displayname> for DSM」,是只給管理員的 `type: "app"` DSM 視窗,掛載 `ui.dir` 的 SDK UI。
173
+ 套件中心「開啟」(INFO `dsmapplaunchname`)開主控台;Application Privileges 只列出原生圖示。
174
+ `console: {}` 用預設名稱,`console: { title: "<名稱>" }` 覆寫所有語系。
175
+ 省略 `console` 維持單一圖示。`console` 必須搭配 `identity_gateway` 與 SDK UI;
176
+ compose App、沒有 gateway 的 App、`ui.launch` 或 `portal` 都會被 check 拒絕。
177
+ 冷登入由 url 圖示經 `open.cgi` 續接,`oidc.browser` 需要支援此續接的 SDK。
178
+
158
179
  Schema 2 Compose 使用 `dsm-package.yaml` 的 `compose.files`、`compose.web` 與
159
180
  `identity_gateway`;不使用 file/targets/address_env 或手寫 upstream。
160
181
  省略 `ui.launch` 時預設 `new-page`,使用 SDK standalone launcher,不帶 Application Portal。
@@ -57,6 +57,15 @@ Application Portal 是獨立 opt-in:不想要別名就省略 `portal`,不生
57
57
  `defaultAlias`、Portal 面板/capability 或 Web Station alias;沒有 Portal
58
58
  不是 inspect/info/doctor 問題。明確啟用 `portal` 時才將其 alias 作為 `defaultAlias`。
59
59
 
60
+ 宣告 `console` 的 App 在主選單有兩個圖示,適用於包裝開源專案、又有額外整合操作的 App。
61
+ 原生圖示叫 `displayname`,是 `type: "url"`,經 `sso/open.cgi` 在新分頁開原生 UI。
62
+ 主控台預設叫「<displayname> for DSM」,是只給管理員的 `type: "app"` DSM 視窗,掛載 `ui.dir` 的 SDK UI。
63
+ 套件中心「開啟」(INFO `dsmapplaunchname`)開主控台;Application Privileges 只列出原生圖示。
64
+ `console: {}` 用預設名稱,`console: { title: "<名稱>" }` 覆寫所有語系。
65
+ 省略 `console` 維持單一圖示。`console` 必須搭配 `identity_gateway` 與 SDK UI;
66
+ compose App、沒有 gateway 的 App、`ui.launch` 或 `portal` 都會被 check 拒絕。
67
+ 冷登入由 url 圖示經 `open.cgi` 續接,`oidc.browser` 需要支援此續接的 SDK。
68
+
60
69
  Docker/web service App 設定 `integration.web.launch`,內建預設是 `new-page`;
61
70
  省略開啟方式時直接開新分頁,不宣告 Application Portal。SDK 原生 App
62
71
  省略時使用 `dsm-window`;非 UI SDK 原生 App 必須明確寫 `ui.launch: new-page`。
@@ -25,6 +25,35 @@ every state path you bind. Use only catalog components and real app-owned action
25
25
  computed functions and message keys. Keep NAS pickers, native DSM bridges,
26
26
  authorization, polling and domain-specific views in Vue/TypeScript extensions.
27
27
 
28
+ ## UI copy and i18n
29
+
30
+ Use the same [UI copy rule as Vue](ui-vue.md#ui-copy-and-i18n) for literal
31
+ display text in JSON specs and the locale catalogs behind message keys. It
32
+ applies to copy we author in the SDK, UI package, templates and our own Apps.
33
+ In upstream forks, only our added UI copy under `synology/` (for example DSM
34
+ install wizards and gateway pages) is in scope. Leave upstream UI/locale
35
+ strings and release notes unchanged; any future checker must exclude upstream
36
+ source and scan only our owned files.
37
+
38
+ In every language, remove the final sentence-ending period (`.`, `。`, `.`),
39
+ even when the string contains multiple sentences. Preserve periods between
40
+ sentences. Keep question marks, exclamation marks, ellipses (`…`, `...`),
41
+ abbreviations such as `etc.`, version numbers, URLs and trailing `{key}`
42
+ placeholders intact. Apply the rule to each translation and plural form, not
43
+ to message keys, action names, URLs or other non-display spec data.
44
+
45
+ | Avoid (message value) | Use (message value) |
46
+ | --- | --- |
47
+ | `"Saved."` | `"Saved"` |
48
+ | `"Try again. Contact support if it still fails."` | `"Try again. Contact support if it still fails"` |
49
+ | `"請稍後再開。若一直打不開,請聯絡送審的人。"` | `"請稍後再開。若一直打不開,請聯絡送審的人"` |
50
+ | `"保存しました。"` | `"保存しました"` |
51
+
52
+ Keep `"Continue?"`, `"Done!"`, `"Loading…"`, `"Loading..."`, `"Images, etc."`,
53
+ `"Version {version}"`, `"https://example.com"` and `"Saved to {folder}"` unchanged.
54
+ Use a script for bulk corrections rather than editing entries one by one.
55
+ This is authoring guidance; it does not add validation to the commands below.
56
+
28
57
  ## Validate, fix, render
29
58
 
30
59
  After saving the generated spec as `ui.json`, run:
@@ -73,6 +73,38 @@ the first screen waits for the startup chain, not the bundle:
73
73
  - Start polling on its schedule; do not repeat at mount a read startup just
74
74
  made. Delay background sampling that the first screen does not need.
75
75
 
76
+ ## UI copy and i18n
77
+
78
+ Apply this rule to UI strings we author in the SDK, UI package, templates and
79
+ our own Apps, whether in locale catalogs or inline Vue/TypeScript copy: buttons,
80
+ titles, labels, field descriptions, hints, toasts and error messages. In an
81
+ upstream fork, apply it only to our added UI copy under `synology/`, including
82
+ DSM install wizards and gateway pages. Never rewrite upstream UI/locale strings
83
+ to match this style; preserve them for future upstream merges. Release notes
84
+ are outside this UI-copy rule.
85
+
86
+ Remove only the final sentence-ending period, including `.` and fullwidth
87
+ periods such as `。` and `.`, in every language. This applies to both single-
88
+ and multi-sentence strings. Keep sentence-separating periods inside the string.
89
+ Preserve question marks (`?`, `?`), exclamation marks (`!`, `!`), ellipses
90
+ (`…`, `...`), abbreviations such as `etc.`, version numbers, URLs and trailing
91
+ `{key}` placeholders; do not trim dots indiscriminately or alter substituted
92
+ values.
93
+
94
+ | Avoid | Use |
95
+ | --- | --- |
96
+ | `Save.` | `Save` |
97
+ | `Try again later. If it still fails, contact support.` | `Try again later. If it still fails, contact support` |
98
+ | `請稍後再開。若一直打不開,請聯絡送審的人。` | `請稍後再開。若一直打不開,請聯絡送審的人` |
99
+ | `設定を保存しました。` | `設定を保存しました` |
100
+
101
+ Keep these unchanged: `Continue?`, `Done!`, `Loading…`, `Loading...`,
102
+ `Images, videos, etc.`, `Version {version}`, `https://example.com` and
103
+ `Saved to {folder}`. Preserve internal punctuation in every translation and
104
+ plural form. For bulk copy corrections, use a script rather than editing each
105
+ entry by hand. Any future punctuation checker must scan only our owned files,
106
+ excluding upstream source; this guidance does not add a CLI check or gate.
107
+
76
108
  ## Verify the actual Vue app
77
109
 
78
110
  From `frontend`, run:
@@ -124,6 +156,15 @@ Application Portal 是獨立 opt-in:不想要別名就省略 `portal`,不生
124
156
  `defaultAlias`、Portal 面板/capability 或 Web Station alias;沒有 Portal
125
157
  不是 inspect/info/doctor 問題。明確啟用 `portal` 時才將其 alias 作為 `defaultAlias`。
126
158
 
159
+ 宣告 `console` 的 App 在主選單有兩個圖示,適用於包裝開源專案、又有額外整合操作的 App。
160
+ 原生圖示叫 `displayname`,是 `type: "url"`,經 `sso/open.cgi` 在新分頁開原生 UI。
161
+ 主控台預設叫「<displayname> for DSM」,是只給管理員的 `type: "app"` DSM 視窗,掛載 `ui.dir` 的 SDK UI。
162
+ 套件中心「開啟」(INFO `dsmapplaunchname`)開主控台;Application Privileges 只列出原生圖示。
163
+ `console: {}` 用預設名稱,`console: { title: "<名稱>" }` 覆寫所有語系。
164
+ 省略 `console` 維持單一圖示。`console` 必須搭配 `identity_gateway` 與 SDK UI;
165
+ compose App、沒有 gateway 的 App、`ui.launch` 或 `portal` 都會被 check 拒絕。
166
+ 冷登入由 url 圖示經 `open.cgi` 續接,`oidc.browser` 需要支援此續接的 SDK。
167
+
127
168
  Docker/web service App 設定 `integration.web.launch`,內建預設是 `new-page`;
128
169
  省略開啟方式時直接開新分頁,不宣告 Application Portal。SDK 原生 App
129
170
  省略時使用 `dsm-window`;非 UI SDK 原生 App 必須明確寫 `ui.launch: new-page`。
@@ -6,6 +6,11 @@ Never edit upstream README, Dockerfile, source, config, workflows or ignore file
6
6
  Do not rewrite the App or treat it as an SDK component. Use `.git/info/exclude`
7
7
  for local output when needed.
8
8
 
9
+ Apply the [UI copy rule](ui-vue.md#ui-copy-and-i18n) only to UI strings we add
10
+ under `synology/`, such as DSM install wizards and gateway pages; leave upstream
11
+ UI/locale strings and release notes unchanged, and exclude upstream source from
12
+ any future punctuation checker.
13
+
9
14
  ## Decide whether to expose AI control
10
15
 
11
16
  Ask whether the user needs AI control unless they have already explicitly requested it.