@synopackageland/cli 0.10.1 → 0.10.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +219 -1669
  2. package/README.md +21 -2
  3. package/assets/login-style/mask.png +0 -0
  4. package/assets/login-style/wallpaper-thumbnail.jpg +0 -0
  5. package/assets/login-style/wallpaper.jpg +0 -0
  6. package/dist/build-spk/generator.js +10 -1
  7. package/dist/build-spk/login-style.d.ts +55 -0
  8. package/dist/build-spk/login-style.js +115 -0
  9. package/dist/cli.js +22 -1
  10. package/dist/error-registry.js +37 -0
  11. package/dist/host/dsm-client.js +6 -9
  12. package/dist/init.js +9 -5
  13. package/dist/runtime/delegate-contract.d.ts +2 -92
  14. package/dist/runtime/delegate-contract.js +2 -24
  15. package/dist/runtime/executor-build.js +10 -0
  16. package/dist/runtime/executor-digests.json +22 -0
  17. package/dist/runtime/executor-inspect.js +3 -0
  18. package/dist/runtime/executor-policy-check.d.ts +14 -0
  19. package/dist/runtime/executor-policy-check.js +70 -0
  20. package/dist/runtime/executor-policy.d.ts +2 -0
  21. package/dist/runtime/executor-policy.js +5 -1
  22. package/dist/runtime/root-programs.d.ts +2 -0
  23. package/dist/runtime/sdk-source.d.ts +4 -0
  24. package/dist/runtime/sdk-source.js +7 -0
  25. package/dist/runtime/sdk-timeouts.d.ts +23 -0
  26. package/dist/runtime/sdk-timeouts.js +44 -0
  27. package/dist/skill.js +1 -0
  28. package/dist/standalone/app-scripts.d.ts +26 -0
  29. package/dist/standalone/app-scripts.js +141 -0
  30. package/dist/standalone/bridge-app.d.ts +2 -0
  31. package/dist/standalone/bridge-app.js +9 -1
  32. package/dist/standalone/bridge-backend.d.ts +1 -1
  33. package/dist/standalone/build.js +5 -2
  34. package/dist/standalone/dsm-window.d.ts +14 -3
  35. package/dist/standalone/dsm-window.js +34 -12
  36. package/dist/standalone/host-adapter.d.ts +1 -1
  37. package/dist/standalone/host-adapter.js +1 -1
  38. package/dist/standalone/host-file-navigation.d.ts +6 -2
  39. package/dist/standalone/host-file-navigation.js +8 -2
  40. package/dist/standalone/inspect-spk.d.ts +9 -0
  41. package/dist/standalone/inspect-spk.js +60 -6
  42. package/dist/standalone/lifecycle.js +3 -1
  43. package/dist/standalone/manifest.d.ts +6 -0
  44. package/dist/standalone/manifest.js +51 -3
  45. package/dist/standalone/portal-window.d.ts +6 -6
  46. package/dist/standalone/portal-window.js +12 -32
  47. package/dist/standalone/request-timeout.d.ts +6 -3
  48. package/dist/standalone/request-timeout.js +10 -4
  49. package/dist/standalone/root-ops.d.ts +3 -2
  50. package/dist/standalone/root-ops.js +9 -3
  51. package/dist/standalone/root-window.d.ts +2 -2
  52. package/dist/standalone/root-window.js +173 -49
  53. package/dist/standalone/sdk-check.js +4 -1
  54. package/dist/standalone/spk.d.ts +3 -0
  55. package/dist/standalone/spk.js +10 -3
  56. package/dist/validate.js +6 -1
  57. package/package.json +2 -3
  58. package/skills/SKILL.md +3 -1
  59. package/skills/reference/create.md +5 -1
  60. package/skills/reference/identity.md +1 -1
  61. package/skills/reference/lifecycle.md +110 -0
  62. package/skills/reference/portal.md +12 -0
  63. package/dist/authorization-sdk.d.ts +0 -6
  64. package/dist/authorization-sdk.js +0 -6
  65. package/dist/standalone/native-authorization-window.d.ts +0 -2
  66. package/dist/standalone/native-authorization-window.js +0 -36
  67. package/dist/standalone/native-root-window.d.ts +0 -29
  68. package/dist/standalone/native-root-window.js +0 -307
@@ -1,33 +1,13 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
1
3
  import { PORTAL_ALIAS_PATTERN, RESERVED_PORTAL_ALIASES } from "../portal/alias.js";
2
- /** SDK-owned portal panel text, shared by the plain panel and the Vue template. */
3
- export const PORTAL_MESSAGES = {
4
- title: { en: "Application Portal", "zh-TW": "應用程式入口", "zh-CN": "应用程序门户", ja: "アプリケーションポータル", ko: "응용 프로그램 포털", de: "Anwendungsportal", fr: "Portail d'application", es: "Portal de aplicaciones", it: "Portale applicazioni" },
5
- link: { en: "Direct link after DSM sign-in:", "zh-TW": "DSM 登入後的直接連結:", "zh-CN": "DSM 登录后的直接链接:", ja: "DSM サインイン後の直接リンク:", ko: "DSM 로그인 후 바로 가기 링크:", de: "Direkter Link nach der DSM-Anmeldung:", fr: "Lien direct après connexion à DSM :", es: "Enlace directo tras iniciar sesión en DSM:", it: "Link diretto dopo l'accesso a DSM:" },
6
- inactive: { en: "Not active. Apply an alias to let users open this app directly after DSM sign-in.", "zh-TW": "尚未啟用。套用別名後,使用者可在 DSM 登入後直接開啟這個 App。", "zh-CN": "尚未启用。应用别名后,用户可在 DSM 登录后直接打开此应用。", ja: "無効です。エイリアスを適用すると、DSM サインイン後にこのアプリを直接開けます。", ko: "비활성 상태입니다. 별칭을 적용하면 DSM 로그인 후 이 앱을 바로 열 수 있습니다.", de: "Nicht aktiv. Wenden Sie einen Alias an, damit Benutzer diese App nach der DSM-Anmeldung direkt öffnen können.", fr: "Inactif. Appliquez un alias pour ouvrir cette application directement après connexion à DSM.", es: "Inactivo. Aplique un alias para abrir esta aplicación directamente tras iniciar sesión en DSM.", it: "Non attivo. Applica un alias per aprire questa app direttamente dopo l'accesso a DSM." },
7
- askAdmin: { en: "Ask an administrator to set this up", "zh-TW": "請管理員設定", "zh-CN": "请管理员设置", ja: "管理者に設定を依頼してください", ko: "관리자에게 설정을 요청하십시오", de: "Bitten Sie einen Administrator, dies einzurichten", fr: "Demandez à un administrateur de le configurer", es: "Pida a un administrador que lo configure", it: "Chiedi a un amministratore di configurarlo" },
8
- alias: { en: "Alias", "zh-TW": "別名", "zh-CN": "别名", ja: "エイリアス", ko: "별칭", de: "Alias", fr: "Alias", es: "Alias", it: "Alias" },
9
- apply: { en: "Apply", "zh-TW": "套用", "zh-CN": "应用", ja: "適用", ko: "적용", de: "Übernehmen", fr: "Appliquer", es: "Aplicar", it: "Applica" },
10
- disable: { en: "Turn off", "zh-TW": "停用", "zh-CN": "停用", ja: "無効にする", ko: "끄기", de: "Ausschalten", fr: "Désactiver", es: "Desactivar", it: "Disattiva" },
11
- advanced: { en: "Advanced settings in Control Panel", "zh-TW": "在控制台進行進階設定", "zh-CN": "在控制面板中进行高级设置", ja: "コントロールパネルの詳細設定", ko: "제어판의 고급 설정", de: "Erweiterte Einstellungen in der Systemsteuerung", fr: "Paramètres avancés dans le Panneau de configuration", es: "Ajustes avanzados en el Panel de control", it: "Impostazioni avanzate nel Pannello di controllo" },
12
- controlPanel: { en: "Open Control Panel Application Portal", "zh-TW": "開啟控制台 Application Portal", "zh-CN": "打开控制面板 Application Portal", ja: "コントロールパネルの Application Portal を開く", ko: "제어판 Application Portal 열기", de: "Application Portal in der Systemsteuerung öffnen", fr: "Ouvrir Application Portal dans le Panneau de configuration", es: "Abrir Application Portal en el Panel de control", it: "Apri Application Portal nel Pannello di controllo" },
13
- entryOff: { en: "This app has no Application Portal entry yet. Enable the entry so that an alias can open it after DSM sign-in.", "zh-TW": "這個 App 尚未加入應用程式入口。啟用入口後,即可用別名在 DSM 登入後直接開啟。", "zh-CN": "此应用尚未加入应用程序门户。启用入口后,即可用别名在 DSM 登录后直接打开。", ja: "このアプリはまだアプリケーションポータルに登録されていません。エントリを有効にすると、DSM サインイン後にエイリアスで直接開けます。", ko: "이 앱은 아직 응용 프로그램 포털에 등록되지 않았습니다. 항목을 활성화하면 DSM 로그인 후 별칭으로 바로 열 수 있습니다.", de: "Diese App hat noch keinen Eintrag im Anwendungsportal. Aktivieren Sie den Eintrag, damit ein Alias sie nach der DSM-Anmeldung öffnet.", fr: "Cette application n'a pas encore d'entrée dans le Portail d'application. Activez l'entrée pour qu'un alias l'ouvre après connexion à DSM.", es: "Esta aplicación aún no tiene una entrada en el Portal de aplicaciones. Active la entrada para que un alias la abra tras iniciar sesión en DSM.", it: "Questa app non ha ancora una voce nel Portale applicazioni. Attivare la voce per aprirla con un alias dopo l'accesso a DSM." },
14
- cleanupRequired: { en: "The Application Portal entry was not fully removed. Remove the entry again.", "zh-TW": "應用程式入口未完全移除。請再移除一次入口。", "zh-CN": "应用程序门户入口未完全移除。请再移除一次入口。", ja: "アプリケーションポータルのエントリが完全に削除されていません。もう一度エントリを削除してください。", ko: "응용 프로그램 포털 항목이 완전히 제거되지 않았습니다. 항목을 다시 제거하십시오.", de: "Der Eintrag im Anwendungsportal wurde nicht vollständig entfernt. Entfernen Sie den Eintrag erneut.", fr: "L'entrée du Portail d'application n'a pas été entièrement supprimée. Supprimez de nouveau l'entrée.", es: "La entrada del Portal de aplicaciones no se eliminó por completo. Elimine la entrada de nuevo.", it: "La voce del Portale applicazioni non è stata rimossa completamente. Rimuovere di nuovo la voce." },
15
- register: { en: "Enable entry", "zh-TW": "啟用入口", "zh-CN": "启用入口", ja: "エントリを有効にする", ko: "항목 활성화", de: "Eintrag aktivieren", fr: "Activer l'entrée", es: "Activar entrada", it: "Attiva voce" },
16
- unregister: { en: "Remove entry", "zh-TW": "移除入口", "zh-CN": "移除入口", ja: "エントリを削除", ko: "항목 제거", de: "Eintrag entfernen", fr: "Supprimer l'entrée", es: "Eliminar entrada", it: "Rimuovi voce" },
17
- portal_alias_invalid: { en: "Use 1-64 lowercase letters, digits or hyphens, not starting or ending with a hyphen.", "zh-TW": "請使用 1–64 個小寫英文字母、數字或連字號,且不可以連字號開頭或結尾。", "zh-CN": "请使用 1–64 个小写英文字母、数字或连字符,且不能以连字符开头或结尾。", ja: "1~64 文字の小文字英字・数字・ハイフンを使用し、先頭と末尾にハイフンは使えません。", ko: "1~64자의 영문 소문자, 숫자 또는 하이픈을 사용하고 하이픈으로 시작하거나 끝나지 않게 하십시오.", de: "Verwenden Sie 1–64 Kleinbuchstaben, Ziffern oder Bindestriche, ohne Bindestrich am Anfang oder Ende.", fr: "Utilisez 1 à 64 lettres minuscules, chiffres ou tirets, sans tiret au début ni à la fin.", es: "Use de 1 a 64 letras minúsculas, dígitos o guiones, sin guion al principio ni al final.", it: "Usare da 1 a 64 lettere minuscole, cifre o trattini, senza trattino all'inizio o alla fine." },
18
- portal_alias_reserved: { en: "DSM already uses this alias. Choose another one.", "zh-TW": "DSM 已使用這個別名,請改用其他別名。", "zh-CN": "DSM 已使用此别名,请改用其他别名。", ja: "このエイリアスは DSM で使用されています。別のものを選んでください。", ko: "DSM에서 이미 사용하는 별칭입니다. 다른 별칭을 선택하십시오.", de: "DSM verwendet diesen Alias bereits. Wählen Sie einen anderen.", fr: "DSM utilise déjà cet alias. Choisissez-en un autre.", es: "DSM ya usa este alias. Elija otro.", it: "DSM usa già questo alias. Sceglierne un altro." },
19
- portal_alias_in_use: { en: "Another app already uses this alias. Choose another one.", "zh-TW": "另一個 App 已使用這個別名,請改用其他別名。", "zh-CN": "另一个应用已使用此别名,请改用其他别名。", ja: "別のアプリがこのエイリアスを使用しています。別のものを選んでください。", ko: "다른 앱이 이미 이 별칭을 사용합니다. 다른 별칭을 선택하십시오.", de: "Eine andere App verwendet diesen Alias bereits. Wählen Sie einen anderen.", fr: "Une autre application utilise déjà cet alias. Choisissez-en un autre.", es: "Otra aplicación ya usa este alias. Elija otro.", it: "Un'altra app usa già questo alias. Sceglierne un altro." },
20
- admin_required: { en: "Only a DSM administrator can change this.", "zh-TW": "只有 DSM 管理員可以變更這項設定。", "zh-CN": "只有 DSM 管理员可以更改此设置。", ja: "これを変更できるのは DSM 管理者のみです。", ko: "DSM 관리자만 변경할 수 있습니다.", de: "Nur ein DSM-Administrator kann dies ändern.", fr: "Seul un administrateur DSM peut modifier ce paramètre.", es: "Solo un administrador de DSM puede cambiar esto.", it: "Solo un amministratore DSM può modificarlo." },
21
- portal_unavailable: { en: "DSM did not accept the change. Try again or use Control Panel.", "zh-TW": "DSM 未接受變更。請再試一次或改用控制台。", "zh-CN": "DSM 未接受更改。请重试或改用控制面板。", ja: "DSM は変更を受け付けませんでした。再試行するか、コントロールパネルを使用してください。", ko: "DSM이 변경을 받아들이지 않았습니다. 다시 시도하거나 제어판을 사용하십시오.", de: "DSM hat die Änderung nicht übernommen. Versuchen Sie es erneut oder verwenden Sie die Systemsteuerung.", fr: "DSM n'a pas accepté la modification. Réessayez ou utilisez le Panneau de configuration.", es: "DSM no aceptó el cambio. Inténtelo de nuevo o use el Panel de control.", it: "DSM non ha accettato la modifica. Riprovare o usare il Pannello di controllo." },
22
- PORTAL_FAILED: { en: "DSM did not accept the Application Portal change. Try again.", "zh-TW": "DSM 未接受應用程式入口的變更。請再試一次。", "zh-CN": "DSM 未接受应用程序门户的更改。请重试。", ja: "DSM はアプリケーションポータルの変更を受け付けませんでした。再試行してください。", ko: "DSM이 응용 프로그램 포털 변경을 받아들이지 않았습니다. 다시 시도하십시오.", de: "DSM hat die Änderung am Anwendungsportal nicht übernommen. Versuchen Sie es erneut.", fr: "DSM n'a pas accepté la modification du Portail d'application. Réessayez.", es: "DSM no aceptó el cambio en el Portal de aplicaciones. Inténtelo de nuevo.", it: "DSM non ha accettato la modifica al Portale applicazioni. Riprovare." },
23
- };
24
- function localizedMessages() {
25
- const out = {};
26
- for (const [key, texts] of Object.entries(PORTAL_MESSAGES)) {
27
- for (const [locale, text] of Object.entries(texts))
28
- (out[locale] ??= {})[key] = text;
29
- }
30
- return out;
4
+ import { LOCALES } from "./i18n.js";
5
+ /** The SDK's portal panel text (`ts/i18n/portal/<locale>.json` of the App's SDK), which the SDK owns and its Vue panel shows too. */
6
+ export function portalCatalogs(sdkDir) {
7
+ return Object.fromEntries(LOCALES.map((locale) => {
8
+ const catalog = JSON.parse(readFileSync(join(sdkDir, "ts/i18n/portal", `${locale}.json`), "utf8"));
9
+ return [locale, Object.fromEntries(Object.entries(catalog).map(([key, text]) => [key.slice("portal.".length), text]))];
10
+ }));
31
11
  }
32
12
  /**
33
13
  * `createPortalAccess(options)` → `ctx.portal`, plain ES5 for DSM's desktop.
@@ -44,14 +24,14 @@ function localizedMessages() {
44
24
  * status or reject `{code}`. With `options.entry` (below DSM 7.2) the
45
25
  * backend's `portal.status` also reports the executor's entry, and
46
26
  * administrators get `register()` and `unregister()`; `openControlPanel()` opens Login Portal > Applications.
47
- * `messages` is the panel text in the window's locale; `mountPanel(element)`
27
+ * `messages` is the SDK's panel text in the window's locale; `mountPanel(element)`
48
28
  * renders a plain panel for UIs without their own.
49
29
  */
50
- export const PORTAL_RUNTIME = `function createPortalAccess(options) {
30
+ export const portalRuntime = (catalogs) => `function createPortalAccess(options) {
51
31
  var ALIAS = new RegExp(${JSON.stringify(PORTAL_ALIAS_PATTERN.source)});
52
32
  var RESERVED = ${JSON.stringify(RESERVED_PORTAL_ALIASES)};
53
33
  var LINKABLE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
54
- var CATALOGS = ${JSON.stringify(localizedMessages())};
34
+ var CATALOGS = ${JSON.stringify(catalogs)};
55
35
  var messages = CATALOGS[options.locale] || CATALOGS.en;
56
36
  var ENTRIES = ["registered", "unregistered", "cleanup-required"];
57
37
  var ERRORS = { dsm_4100: "portal_alias_invalid", dsm_4102: "portal_alias_reserved", dsm_4103: "portal_alias_in_use", dsm_105: "admin_required" };
@@ -1,9 +1,12 @@
1
- /** The backend's request deadline in seconds unless the App sets backend.timeout_seconds. */
2
- export declare const DEFAULT_REQUEST_TIMEOUT_SECONDS = 60;
3
1
  /** spec/bridge/v1 timeout_seconds: the runtime refuses more. */
4
2
  export declare const MAX_REQUEST_TIMEOUT_SECONDS = 3600;
5
- export declare const TIMEOUT_USAGE = "timeout_seconds is the request deadline in seconds, a whole number from 1 to 3600; omitted, 60.";
3
+ export declare const TIMEOUT_USAGE = "timeout_seconds is the request deadline in seconds, a whole number from 1 to 3600; omitted, the request default of the App's SDK (60 seconds through SDK 0.10).";
6
4
  /** Whether backend.timeout_seconds is a deadline the runtime accepts. */
7
5
  export declare function validTimeoutSeconds(value: unknown): value is number;
8
6
  /** The request deadline the CGI applies and the window waits past. */
9
7
  export declare function requestTimeoutSeconds(backend: object): number;
8
+ /** Why a request deadline ends before a program root.run may run would answer, marginSeconds after its timeout_s; undefined when it does not. */
9
+ export declare function rootRunDeadlineProblem(programs: ReadonlyArray<{
10
+ name: string;
11
+ timeoutSeconds: number;
12
+ }>, requestSeconds: number, marginSeconds: number): string | undefined;
@@ -1,13 +1,19 @@
1
- /** The backend's request deadline in seconds unless the App sets backend.timeout_seconds. */
2
- export const DEFAULT_REQUEST_TIMEOUT_SECONDS = 60;
1
+ import { appSdkTimeouts } from "../runtime/sdk-timeouts.js";
3
2
  /** spec/bridge/v1 timeout_seconds: the runtime refuses more. */
4
3
  export const MAX_REQUEST_TIMEOUT_SECONDS = 3600;
5
- export const TIMEOUT_USAGE = `timeout_seconds is the request deadline in seconds, a whole number from 1 to ${MAX_REQUEST_TIMEOUT_SECONDS}; omitted, ${DEFAULT_REQUEST_TIMEOUT_SECONDS}.`;
4
+ export const TIMEOUT_USAGE = `timeout_seconds is the request deadline in seconds, a whole number from 1 to ${MAX_REQUEST_TIMEOUT_SECONDS}; omitted, the request default of the App's SDK (60 seconds through SDK 0.10).`;
6
5
  /** Whether backend.timeout_seconds is a deadline the runtime accepts. */
7
6
  export function validTimeoutSeconds(value) {
8
7
  return Number.isInteger(value) && value >= 1 && value <= MAX_REQUEST_TIMEOUT_SECONDS;
9
8
  }
10
9
  /** The request deadline the CGI applies and the window waits past. */
11
10
  export function requestTimeoutSeconds(backend) {
12
- return backend.timeoutSeconds ?? DEFAULT_REQUEST_TIMEOUT_SECONDS;
11
+ return backend.timeoutSeconds ?? appSdkTimeouts(backend).requestDefaultSeconds;
12
+ }
13
+ /** Why a request deadline ends before a program root.run may run would answer, marginSeconds after its timeout_s; undefined when it does not. */
14
+ export function rootRunDeadlineProblem(programs, requestSeconds, marginSeconds) {
15
+ const short = programs.find(p => p.timeoutSeconds + marginSeconds > requestSeconds);
16
+ if (!short)
17
+ return undefined;
18
+ return `backend.timeout_seconds is ${requestSeconds}, but root.run waits for root program ${short.name} up to its run.timeout_s ${short.timeoutSeconds} + ${marginSeconds} seconds; set timeout_seconds to at least ${short.timeoutSeconds + marginSeconds}, or lower run.timeout_s.`;
13
19
  }
@@ -27,8 +27,9 @@ export declare function loadRootOps(dir: string): RootOpsResult;
27
27
  export declare function rootBinaryProblem(ops: readonly RootOp[], settings: RootSettings | undefined, arches: readonly NativeArchFamily[]): string | undefined;
28
28
  /**
29
29
  * dsm-package.yaml `root`: `programs.<name>` gives a root/ program its kind,
30
- * params schema and run or job limits; `stop` names the program the executor
31
- * runs at package stop and upgrade; `state: true` gives root programs a
30
+ * params schema and run or job limits; `start` names the program the executor
31
+ * runs once after each install or upgrade, `stop` the one it runs at package
32
+ * stop and upgrade; `state: true` gives root programs a
32
33
  * directory only root can read. Returns why the settings are refused.
33
34
  */
34
35
  export declare function readRootSettings(value: unknown, ops: readonly RootOp[] | undefined): {
@@ -91,12 +91,13 @@ function readRootOp(path, file) {
91
91
  }
92
92
  return { name, body };
93
93
  }
94
- const ROOT_SETTINGS_FIELDS = ["programs", "stop", "state"];
94
+ const ROOT_SETTINGS_FIELDS = ["programs", "start", "stop", "state"];
95
95
  const PROGRAM_FIELDS = ["kind", "schema", "run", "job"];
96
96
  /**
97
97
  * dsm-package.yaml `root`: `programs.<name>` gives a root/ program its kind,
98
- * params schema and run or job limits; `stop` names the program the executor
99
- * runs at package stop and upgrade; `state: true` gives root programs a
98
+ * params schema and run or job limits; `start` names the program the executor
99
+ * runs once after each install or upgrade, `stop` the one it runs at package
100
+ * stop and upgrade; `state: true` gives root programs a
100
101
  * directory only root can read. Returns why the settings are refused.
101
102
  */
102
103
  export function readRootSettings(value, ops) {
@@ -118,6 +119,11 @@ export function readRootSettings(value, ops) {
118
119
  if (problem)
119
120
  return fail(`programs.${name}: ${problem}`);
120
121
  }
122
+ if (raw.start !== undefined && (typeof raw.start !== "string" || !names.has(raw.start)))
123
+ return fail(`start must be a program name in root/, not ${JSON.stringify(raw.start)}.`);
124
+ const start = typeof raw.start === "string" ? programs[raw.start] : undefined;
125
+ if (start && (start.run !== undefined || start.job !== undefined))
126
+ return fail(`start: root program ${raw.start} runs once after each install or upgrade and cannot also declare run or job; give the session work another program name.`);
121
127
  if (raw.stop !== undefined && (typeof raw.stop !== "string" || !names.has(raw.stop)))
122
128
  return fail(`stop must be a program name in root/, not ${JSON.stringify(raw.stop)}.`);
123
129
  if (raw.state !== undefined && raw.state !== true)
@@ -3,13 +3,13 @@ export { type ExecutorTask as RootTask } from '../runtime/executor-tasks.js';
3
3
  * across both Root APIs, and never persisted (DSM validity is about 300 seconds). */
4
4
  export declare const ROOT_RUNTIME: string;
5
5
  type PanelLocale = "en" | "zh-TW" | "zh-CN" | "ja" | "ko" | "de" | "fr" | "es" | "it";
6
- /** The plain-UI window panel's text; a state's reason stays the runtime's English detail. */
6
+ /** The plain-UI window panel's text: a state, and a result's code and outcome; reason stays the runtime's English detail. */
7
7
  export declare const ROOT_PANEL_MESSAGES: Record<string, Record<PanelLocale, string>>;
8
8
  /** ROOT_PANEL_MESSAGES as createI18n catalogs, one per locale. */
9
9
  export declare function rootPanelCatalogs(): Record<string, Record<string, string>>;
10
10
  /** The plain-UI approval panel: it asks for the password itself and passes it
11
11
  * to `access.authorize`, the same PasswordConfirm path as ExecutorSettings. */
12
- export declare const ROOT_PANEL = "function mountRootPanel(host, access, t, admin) {\n if (!admin) return;\n function el(tag, className) { var e = document.createElement(tag); if (className) e.className = className; return e; }\n var panel = el(\"div\", \"dsmapp-rootpanel\"), title = el(\"h2\"), status = el(\"p\"), reason = el(\"p\", \"dsmapp-rootpanel-hint\");\n var form = el(\"form\"), label = el(\"label\"), password = el(\"input\"), actions = el(\"div\", \"dsmapp-rootpanel-actions\");\n var approve = el(\"button\", \"dsmapp-rootpanel-primary\"), check = el(\"button\");\n title.textContent = t(\"title\"); label.textContent = t(\"password\");\n password.type = \"password\"; password.autocomplete = \"current-password\";\n approve.type = \"submit\"; approve.textContent = t(\"approve\");\n check.type = \"button\"; check.textContent = t(\"check\");\n label.appendChild(password); actions.appendChild(approve); actions.appendChild(check);\n form.appendChild(label); form.appendChild(actions);\n panel.appendChild(title); panel.appendChild(status); panel.appendChild(reason); panel.appendChild(form);\n host.appendChild(panel);\n function show(result) {\n status.textContent = t(\"state.\" + result.state);\n reason.textContent = result.reason || \"\"; reason.hidden = !result.reason;\n label.hidden = approve.hidden = result.state !== \"needs-admin-approval\" && result.retry !== true;\n }\n function run(fn) { approve.disabled = check.disabled = true; return fn().then(show, function(e) { show({state: \"blocked\", reason: e.message || e.code}); }).then(function() { approve.disabled = check.disabled = false; }); }\n access.subscribe(show);\n form.onsubmit = function(event) {\n if (event) event.preventDefault();\n var entered = password.value; password.value = \"\";\n if (!entered) return Promise.resolve();\n return run(function() { return access.authorize(entered); });\n };\n check.onclick = function() { return run(function() { return access.status(); }); };\n run(function() { return access.status().then(function(result) { return result.tasks && result.tasks.boot && result.tasks.control && result.tasks.boot.state === \"ready\" && result.tasks.control.state === \"ready\" ? access.control(\"start\") : result; }); });\n}";
12
+ export declare const ROOT_PANEL = "function mountRootPanel(host, access, t, admin) {\n if (!admin) return;\n function el(tag, className) { var e = document.createElement(tag); if (className) e.className = className; return e; }\n var panel = el(\"div\", \"dsmapp-rootpanel\"), title = el(\"h2\"), status = el(\"p\"), reason = el(\"p\", \"dsmapp-rootpanel-hint\");\n var form = el(\"form\"), label = el(\"label\"), password = el(\"input\"), actions = el(\"div\", \"dsmapp-rootpanel-actions\");\n var approve = el(\"button\", \"dsmapp-rootpanel-primary\"), check = el(\"button\");\n title.textContent = t(\"title\"); label.textContent = t(\"password\");\n password.type = \"password\"; password.autocomplete = \"current-password\";\n approve.type = \"submit\"; approve.textContent = t(\"approve\");\n check.type = \"button\"; check.textContent = t(\"check\");\n label.appendChild(password); actions.appendChild(approve); actions.appendChild(check);\n form.appendChild(label); form.appendChild(actions);\n panel.appendChild(title); panel.appendChild(status); panel.appendChild(reason); panel.appendChild(form);\n host.appendChild(panel);\n // The code and outcome in the DSM language, then the DSM code the English reason starts with.\n function explain(result) {\n if (!result.reason && !result.code) return \"\";\n var parts = [t(\"reason.\" + (result.code || \"failed\"))];\n if (result.outcome) parts.push(t(\"outcome.\" + result.outcome));\n var detail = /^([A-Za-z0-9_]+):/.exec(result.reason || \"\");\n if (detail) parts.push(\"(\" + detail[1] + \")\");\n return parts.reduce(function (text, part) { return text + (/[\u3000-\u30FF\u4E00-\u9FFF\uAC00-\uD7AF\uFF00-\uFFEF]$/.test(text) ? \"\" : \" \") + part; });\n }\n function show(result) {\n status.textContent = t(\"state.\" + result.state);\n reason.textContent = explain(result); reason.hidden = !reason.textContent;\n label.hidden = approve.hidden = result.state !== \"needs-admin-approval\" && result.retry !== true;\n }\n // A start step can hold the background service for up to a minute.\n function run(fn) { approve.disabled = check.disabled = true; reason.textContent = t(\"working\"); reason.hidden = false; return fn().then(show, function(e) { show({state: \"blocked\", reason: e && e.code ? e.code + \":\" : \"\", code: \"failed\"}); }).then(function() { approve.disabled = check.disabled = false; }); }\n access.subscribe(show);\n form.onsubmit = function(event) {\n if (event) event.preventDefault();\n var entered = password.value; password.value = \"\";\n if (!entered) return Promise.resolve();\n return run(function() { return access.authorize(entered); });\n };\n check.onclick = function() { return run(function() { return access.status(); }); };\n run(function() { return access.status().then(function(result) { return result.tasks && result.tasks.boot && result.tasks.control && result.tasks.boot.state === \"ready\" && result.tasks.control.state === \"ready\" ? access.control(\"start\") : result; }); });\n}";
13
13
  /** Administrator password confirmation in the window's own session, for the
14
14
  * password an approval panel collected. */
15
15
  export declare const PASSWORD_CONFIRM: string;
@@ -12,8 +12,29 @@ export const ROOT_RUNTIME = `function createRootAccess(options) {
12
12
  var code = String(e && e.code || "executor_bad_response");
13
13
  if (e && e.passwordConfirm) return code + (code === "dsm_3121" ? ": DSM rejected the administrator password." : ": administrator password confirmation failed.") + " Nothing was created.";
14
14
  if (e && e.detail) return code + ": " + e.detail;
15
+ if (["dsm_106","dsm_107","dsm_119"].indexOf(code) >= 0) return code + ": the DSM session ended. Sign in to DSM again, then approve again.";
15
16
  return code + (code === "105" || code === "dsm_105" ? ": DSM denied administrator scheduler access. Check the DSM session and scheduler; a NAS restart resolved this on the measured DSM. No automatic retry was made." : ": scheduler readback or operation failed.");
16
17
  }
18
+ // What a result's code says, for the window panel and the SDK to translate;
19
+ // reason keeps the English detail. fallback names a step's own failure.
20
+ function codeOf(e, fallback) {
21
+ var code = String(e && e.code || "");
22
+ if (e && e.passwordConfirm) return code === "dsm_3121" ? "password_rejected" : code === "password_required" ? "password_required" : ["dsm_106","dsm_107","dsm_119"].indexOf(code) >= 0 ? "session_ended" : "password_confirm_failed";
23
+ if (e && e.task) return e.task;
24
+ if (["dsm_106","dsm_107","dsm_119"].indexOf(code) >= 0) return "session_ended";
25
+ if (code === "admin_required") return code;
26
+ if (code === "executor_confirmation_missing") return "password_confirm_failed";
27
+ if (code === "executor_starting") return code;
28
+ if (e && e.detail) return /other digests/.test(e.detail) ? "executor_mismatch" : "executor_unreachable";
29
+ if (code.indexOf("executor_selection") === 0) return "selection_failed";
30
+ if (fallback) return fallback;
31
+ if (code === "105" || code === "dsm_105") return "scheduler_denied";
32
+ if (["102","103","104","dsm_102","dsm_103","dsm_104"].indexOf(code) >= 0) return "scheduler_unsupported";
33
+ return "scheduler_failed";
34
+ }
35
+ function block(result, e, fallback) { result.state = "blocked"; result.reason = reason(e); result.code = codeOf(e, fallback); return result; }
36
+ // A find result that stops an approval, with its code.
37
+ function refused(current) { var e = fail(current.reason); e.task = current.code; return e; }
17
38
  // AppWindow.sendWebAPI encodes JSON parameters. Preserve their native types;
18
39
  // pre-encoding turns objects/booleans into strings and DSM rejects create.
19
40
  function send(api, method, version, params) { return options.send(api, method, version, params || {}); }
@@ -38,36 +59,60 @@ export const ROOT_RUNTIME = `function createRootAccess(options) {
38
59
  return send(boot ? eventAPI : taskAPI, "delete", boot ? 1 : 2, boot ? ref : {tasks: [ref]});
39
60
  }
40
61
  function admin() { if (!options.user() || options.user().admin !== true) throw fail("admin_required"); }
41
- function find(task) {
62
+ function missing() { return {state: "needs-admin-approval", reason: "Task is missing.", code: "task_missing"}; }
63
+ // EventScheduler keys a task by its name (DSM refuses a second one with 117
64
+ // DUPLICATE_PRIMARY_KEY) and get answers no data for a missing name, so get
65
+ // alone finds it. Task Scheduler names repeat: a list finds the id and
66
+ // refuses duplicates, and later cached reads get that id until it fails.
67
+ var listed = {};
68
+ function locate(task, cached) {
69
+ if (task.kind === "boot") return Promise.resolve({task_name: task.name});
70
+ if (cached && listed[task.name] !== undefined) return Promise.resolve({id: listed[task.name], real_owner: "root", cached: true});
71
+ return taskRead("list", 3, {start: 0, limit: 500}, 2, {offset: 0, limit: 500}).then(function (data) {
72
+ var records = data && data.tasks;
73
+ if (!Array.isArray(records) || records.length > 500 || !Number.isInteger(data.total) || data.total !== records.length) throw fail("executor_bad_response");
74
+ var named = records.filter(function (r) { return r && r.name === task.name; });
75
+ delete listed[task.name];
76
+ if (!named.length) return null;
77
+ if (named.length !== 1) return {duplicate: true};
78
+ if (!/^[0-9]+$/.test(String(named[0].id))) throw fail("executor_bad_response");
79
+ listed[task.name] = named[0].id;
80
+ return {id: named[0].id, real_owner: "root"};
81
+ });
82
+ }
83
+ // cached: get the control task by the id an earlier list in this window showed.
84
+ function find(task, cached) {
42
85
  var boot = task.kind === "boot";
43
- return (boot ? send(eventAPI, "list", 1, {}) : taskRead("list", 3, {start: 0, limit: 500}, 2, {offset: 0, limit: 500})).then(function (data) {
44
- var records = boot ? data : data && data.tasks;
45
- if (!Array.isArray(records) || records.length > 500 || (!boot && (!Number.isInteger(data.total) || data.total !== records.length))) throw fail("executor_bad_response");
46
- var named = records.filter(function (r) { return r && (boot ? r.task_name : r.name) === task.name; });
47
- if (!named.length) return {state: "needs-admin-approval", reason: "Task is missing."};
48
- if (named.length !== 1) return {state: "blocked", reason: "Duplicate task names."};
49
- var summary = named[0];
50
- if (!boot && !/^[0-9]+$/.test(String(summary.id))) throw fail("executor_bad_response");
51
- var ref = boot ? {task_name: task.name} : {id: summary.id, real_owner: "root"};
86
+ return locate(task, cached).then(function (located) {
87
+ if (!located) return missing();
88
+ if (located.duplicate) return {state: "blocked", reason: "Duplicate task names.", code: "task_duplicate"};
89
+ var ref = boot ? located : {id: located.id, real_owner: "root"};
52
90
  var detail = boot ? send(eventAPI, "get", 1, ref) : taskRead("get", 4, ref, 3, ref);
91
+ // A cached id that is gone or now names another task is listed again.
92
+ if (located.cached) detail = detail.then(function (r) { if (!r || r.name !== task.name || String(r.id) !== String(ref.id)) throw {relist: true}; return r; });
53
93
  return detail.then(function (r) {
94
+ if (boot && (r === undefined || r === null || (typeof r === "object" && !Object.keys(r).length))) return missing();
54
95
  var owner = r && r.owner;
55
96
  var root = owner === "root" || (boot && owner && owner["0"] === "root");
56
97
  var script = boot ? r && r.operation : r && r.extra && r.extra.script;
57
- if (!r || (boot ? r.task_name !== task.name : String(r.id) !== String(ref.id) || r.name !== task.name) || !root || (!boot && (r.real_owner !== "root" || r.enable !== false || r.type !== "script")) || (boot && (r.enable !== true || r.event !== "bootup" || r.operation_type !== "script")) || typeof script !== "string") return {state: "blocked", reason: "Task ownership or definition cannot be verified."};
98
+ if (!r || (boot ? r.task_name !== task.name : String(r.id) !== String(ref.id) || r.name !== task.name) || !root || (!boot && (r.real_owner !== "root" || r.enable !== false || r.type !== "script")) || (boot && (r.enable !== true || r.event !== "bootup" || r.operation_type !== "script")) || typeof script !== "string") return {state: "blocked", reason: "Task ownership or definition cannot be verified.", code: "task_unverified"};
58
99
  if (script === task.script) return {state: "ready", ref: ref};
59
- if (script.indexOf(task.header) === 0) return {state: "needs-admin-approval", ref: ref, reason: "Approved digests differ."};
60
- return {state: "blocked", reason: "Same-named task has an unrelated script."};
100
+ if (script.indexOf(task.header) === 0) return {state: "needs-admin-approval", ref: ref, script: script, reason: "Approved digests differ.", code: "task_outdated"};
101
+ return {state: "blocked", reason: "Same-named task has an unrelated script.", code: "task_conflict"};
102
+ }, function (e) {
103
+ if (!located.cached || !(e && (e.relist || e.code === "dsm_4801"))) throw e;
104
+ delete listed[task.name];
105
+ return find(task, false);
61
106
  });
62
- }).catch(function (e) { return {state: ["102","103","104","dsm_102","dsm_103","dsm_104"].indexOf(String(e && e.code)) >= 0 ? "unsupported" : "blocked", reason: reason(e)}; });
107
+ }).catch(function (e) { return {state: ["102","103","104","dsm_102","dsm_103","dsm_104"].indexOf(String(e && e.code)) >= 0 ? "unsupported" : "blocked", reason: reason(e), code: codeOf(e)}; });
63
108
  }
64
- function read() {
65
- try { admin(); } catch (e) { return Promise.resolve({state: "blocked", reason: reason(e), tasks: {}}); }
66
- return Promise.all(options.tasks.map(find)).then(function (found) {
109
+ function read(cached) {
110
+ try { admin(); } catch (e) { return Promise.resolve(block({tasks: {}}, e)); }
111
+ return Promise.all(options.tasks.map(function (task) { return find(task, cached === true); })).then(function (found) {
67
112
  var result = {state: "ready", tasks: {}};
68
113
  options.tasks.forEach(function (t, i) {
69
114
  result.tasks[t.kind] = found[i];
70
- if (found[i].state === "blocked" || (found[i].state !== "ready" && result.state === "ready")) { result.state = found[i].state; result.reason = found[i].reason; }
115
+ if (found[i].state === "blocked" || (found[i].state !== "ready" && result.state === "ready")) { result.state = found[i].state; result.reason = found[i].reason; result.code = found[i].code; }
71
116
  });
72
117
  return result;
73
118
  });
@@ -82,6 +127,18 @@ export const ROOT_RUNTIME = `function createRootAccess(options) {
82
127
  }).catch(function (e) { failed = reason(e); });
83
128
  }, Promise.resolve()).then(function () { return failed; });
84
129
  }
130
+ // Undoes one approval: withdraws what it created, then recreates, newest
131
+ // first, the earlier approval's tasks it removed. Resolves like withdraw.
132
+ function rollback(created, removed, confirmation) {
133
+ return withdraw(created).then(function (failed) {
134
+ return removed.reduceRight(function (restored, earlier) {
135
+ return restored.then(function () { return find(earlier); }).then(function (current) {
136
+ if (current.state === "blocked") throw refused(current);
137
+ if (!current.ref) return create(earlier.kind === "boot", definition(earlier), confirmation);
138
+ }).catch(function (e) { failed = reason(e); });
139
+ }, Promise.resolve()).then(function () { return failed; });
140
+ });
141
+ }
85
142
  function definition(task) { return task.kind === "boot" ? (${bootDefinition.toString()})(task) : (${controlDefinition.toString()})(task); }
86
143
  function approve(password, selectedScope) {
87
144
  var selectionTask;
@@ -95,29 +152,41 @@ export const ROOT_RUNTIME = `function createRootAccess(options) {
95
152
  if (!confirmation || confirmation.confirmed !== true) return {state: "cancelled", tasks: before.tasks};
96
153
  // DSM 7.1 confirms the password server-side but issues no create token.
97
154
  if (!legacy && (typeof confirmation.token !== "string" || !confirmation.token)) throw fail("executor_confirmation_missing");
98
- var errors = {}, created = [], withdrawFailed;
155
+ var errors = {}, created = [], removed = [], withdrawFailed;
99
156
  // Delete only a verified, same-header outdated task, then create the pinned
100
- // replacement. set has no measured wire contract. Re-read before each write.
157
+ // replacement. set has no measured wire contract, and a same-named task
158
+ // cannot be created first. A task to delete is read again by get just
159
+ // before; a missing one keeps the first read: DSM refuses a second boot
160
+ // task name, and the readback after the writes lists control tasks for
161
+ // duplicates. Stop at the first error.
101
162
  var chain = Promise.resolve();
102
163
  options.tasks.forEach(function (task) {
103
164
  chain = chain.then(function () {
104
- return find(task).then(function (current) {
165
+ if (Object.keys(errors).length) return;
166
+ var first = before.tasks[task.kind];
167
+ return (first.ref ? find(task, true) : Promise.resolve(first)).then(function (current) {
105
168
  if (current.state === "ready") return;
106
- if (current.state === "blocked") throw fail(current.reason);
107
- var removed = current.ref ? remove(task, current.ref) : Promise.resolve();
108
- return removed.then(function () { return create(task.kind === "boot", definition(task), confirmation); }).then(function () { created.push(task); });
109
- }).catch(function (e) { errors[task.kind] = reason(e); });
169
+ if (current.state === "blocked") throw refused(current);
170
+ var deleted = current.ref ? remove(task, current.ref).then(function () { removed.push({kind: task.kind, name: task.name, header: task.header, script: current.script}); }) : Promise.resolve();
171
+ return deleted.then(function () { return create(task.kind === "boot", definition(task), confirmation); }).then(function () { created.push(task); });
172
+ }).catch(function (e) { errors[task.kind] = e; });
110
173
  });
111
174
  });
112
175
  // A partial approval must not leave an enabled root bootup task that no
113
- // control task or uninstall inventory owns: withdraw what it created.
176
+ // control task or uninstall inventory owns, nor lose the earlier approval:
177
+ // roll back to it. A retry converges from whatever the rollback left.
114
178
  return chain.then(function () {
115
- if (Object.keys(errors).length) return withdraw(created).then(function (failed) { withdrawFailed = failed; });
116
- }).then(read).then(function (after) {
179
+ if (Object.keys(errors).length) return rollback(created, removed, confirmation).then(function (failed) { withdrawFailed = failed; });
180
+ }).then(function () { return read(true); }).then(function (after) {
117
181
  Object.keys(errors).forEach(function (kind) {
118
- if (after.tasks[kind].state !== "ready") { after.tasks[kind].state = "blocked"; after.tasks[kind].reason = errors[kind]; after.state = "blocked"; after.reason = errors[kind]; }
182
+ if (after.tasks[kind].state !== "ready") { block(after.tasks[kind], errors[kind]); block(after, errors[kind]); }
119
183
  });
120
- if (withdrawFailed) { after.state = "blocked"; after.reason = (after.reason ? after.reason + " " : "") + "Withdrawing the tasks this approval created failed: " + withdrawFailed; }
184
+ if (withdrawFailed) { after.state = "blocked"; after.reason = (after.reason ? after.reason + " " : "") + (removed.length ? "Restoring the earlier approval failed: " : "Withdrawing the tasks this approval created failed: ") + withdrawFailed; after.outcome = removed.length ? "restore_failed" : "withdraw_failed"; }
185
+ else if (Object.keys(errors).length && after.state !== "ready") {
186
+ var restored = Object.keys(before.tasks).some(function (kind) { return before.tasks[kind].ref; });
187
+ after.reason += restored ? " The earlier approval was restored." : " The tasks this approval created were withdrawn.";
188
+ after.outcome = restored ? "restored" : "withdrawn"; after.retry = true;
189
+ }
121
190
  // A scope change must not restart a running executor: the boot task
122
191
  // stops it, which interrupts in-flight copies instead of canceling them.
123
192
  return after.state === "ready" ? (selectionTask ? running(after) : Promise.resolve(null)).then(function (live) { return live || start(after); }).then(function (started) {
@@ -128,6 +197,7 @@ export const ROOT_RUNTIME = `function createRootAccess(options) {
128
197
  return read().then(function (current) {
129
198
  current.state = "blocked";
130
199
  current.reason = started.reason + (failed ? " Withdrawing the tasks this approval created failed: " + failed : " The tasks this approval created were withdrawn.");
200
+ current.code = started.code; current.outcome = failed ? "withdraw_failed" : "withdrawn";
131
201
  current.retry = !failed;
132
202
  return current;
133
203
  });
@@ -141,7 +211,7 @@ export const ROOT_RUNTIME = `function createRootAccess(options) {
141
211
  // app converge by readback: applied once status().executor.selection
142
212
  // shows this scope above this generation. A resubmit is pinned and safe.
143
213
  return selectScope(selectionTask, confirmation, started, selectedScope).catch(function (e) {
144
- return {state: "blocked", reason: reason(e), tasks: started.tasks, requested: {scope: selectedScope, generation: generation}};
214
+ return block({tasks: started.tasks, requested: {scope: selectedScope, generation: generation}}, e, "selection_failed");
145
215
  });
146
216
  }) : after;
147
217
  });
@@ -203,32 +273,52 @@ export const ROOT_RUNTIME = `function createRootAccess(options) {
203
273
  // A started executor answered only after its own DSM lookups, which took
204
274
  // 16-20 s on a measured NAS; giving up withdraws a fresh approval.
205
275
  var startBudget = 30000;
206
- // budget: how long to keep asking after a start (ms). The wait doubles from
207
- // 500 ms to 2 s so an open window does not poll the executor twice a second.
208
- function health(verified, budget, delay) {
209
- if (verified.state !== "ready") return Promise.resolve(verified);
210
- delay = Math.min(delay || 500, 2000);
276
+ // The pinned executor listens before its package start step, which runs as
277
+ // root for at most 60 s and answers state "starting" until it ends.
278
+ var stepBudget = 75000;
279
+ function starting(status) { return !!status && status.state === "starting" && status.executor_digest === (options.pins && options.pins.executorDigest) && status.policy_digest === options.pins.policyDigest; }
280
+ // The approved executor's status. budget: how long to keep asking after a
281
+ // start (ms); a start step's own wait is step. An executor without a start
282
+ // step answered 1.8-3 s after run on a measured NAS: ask every 500 ms for
283
+ // the first 4 s, then double to 2 s so an open window does not poll the
284
+ // executor twice a second.
285
+ function alive(budget, delay, step, waited) {
286
+ waited = waited || 0;
287
+ delay = waited < 4000 ? 500 : Math.min(delay * 2, 2000);
288
+ step = step === undefined ? stepBudget : step;
211
289
  return options.call("executor.status", {}).then(function (status) {
290
+ if (starting(status)) throw fail("executor_starting", "the executor's start step has not ended.");
212
291
  var why = mismatch(status);
213
292
  if (why) throw fail("executor_status_mismatch", why);
214
- verified.executor = status; return verified;
293
+ return status;
215
294
  }, function (e) {
216
295
  throw fail(e && e.code, "the executor status request failed.");
217
296
  }).catch(function (e) {
218
- if (budget >= delay && options.wait) return options.wait(delay).then(function () { return health(verified, budget - delay, delay * 2); });
297
+ var steps = e.code === "executor_starting";
298
+ if ((steps ? step : budget) >= delay && options.wait) return options.wait(delay).then(function () { return steps ? alive(budget, delay, step - delay, waited + delay) : alive(budget - delay, delay, step, waited + delay); });
299
+ throw e;
300
+ });
301
+ }
302
+ function health(verified, budget) {
303
+ if (verified.state !== "ready") return Promise.resolve(verified);
304
+ return settle(verified, alive(budget));
305
+ }
306
+ function settle(verified, status) {
307
+ return status.then(function (current) { verified.executor = current; return verified; }, function (e) {
219
308
  // The tasks are approved: approving again runs start, which restarts the
220
309
  // executor without a password, so the panel offers it.
221
- verified.state = "blocked"; verified.reason = reason(e); verified.retry = true; return verified;
310
+ block(verified, e); verified.retry = true; return verified;
222
311
  });
223
312
  }
224
313
  function start(verified) {
225
314
  return options.call("executor.control", {action: "start"}).then(function () {
226
315
  return send(eventAPI, "run", 1, verified.tasks.boot.ref);
227
- }).then(function () {
228
- // run can succeed for a nonexistent task. Read both definitions again.
229
- return read().then(function (after) { return health(after, startBudget); });
230
- }).catch(function (e) { verified.state = "blocked"; verified.reason = reason(e); return verified; });
316
+ }).then(function () { return started(verified); }).catch(function (e) { return block(verified, e, "executor_start_failed"); });
231
317
  }
318
+ // Every run follows an exact readback of both tasks in the same step, and a
319
+ // run of a missing task leaves no pinned executor to answer: only the
320
+ // executor status is asked after it.
321
+ function started(verified) { return settle(verified, alive(startBudget)); }
232
322
  // The window panel and the app's own settings share this access; each
233
323
  // result reaches every view so neither shows a stale state.
234
324
  var listeners = [];
@@ -236,32 +326,56 @@ export const ROOT_RUNTIME = `function createRootAccess(options) {
236
326
  return {
237
327
  status: function () { return read().then(function (verified) { return health(verified, 0); }).then(publish); },
238
328
  authorize: function (password, selectedScope) {
239
- if (!busy) busy = Promise.resolve().then(function () { return approve(password, selectedScope); }).catch(function (e) { return {state: "blocked", reason: reason(e), tasks: {}, retry: !!(e && e.passwordConfirm)}; }).then(function (r) { busy = null; return publish(r); });
329
+ if (!busy) busy = Promise.resolve().then(function () { return approve(password, selectedScope); }).catch(function (e) { var r = block({tasks: {}}, e); r.retry = !!(e && e.passwordConfirm); return r; }).then(function (r) { busy = null; return publish(r); });
240
330
  return busy;
241
331
  },
242
332
  control: function (action) {
243
333
  if (["start", "stop", "purge"].indexOf(action) < 0) return Promise.reject(fail("executor_control_invalid"));
244
- return read().then(function (verified) {
334
+ return read(true).then(function (verified) {
245
335
  if (verified.state !== "ready") throw fail("executor_not_ready");
246
336
  return options.call("executor.control", {action: action}).then(function () {
247
337
  return send(taskAPI, "run", 2, {tasks: [verified.tasks.control.ref]});
248
- }).then(read).then(function (after) { return action === "start" ? health(after, startBudget) : after; });
338
+ }).then(function () { return action === "start" ? started(verified) : read(true); });
249
339
  }).then(publish);
250
340
  },
251
341
  subscribe: function (listener) { listeners.push(listener); }
252
342
  };
253
343
  }`;
254
- /** The plain-UI window panel's text; a state's reason stays the runtime's English detail. */
344
+ /** The plain-UI window panel's text: a state, and a result's code and outcome; reason stays the runtime's English detail. */
255
345
  export const ROOT_PANEL_MESSAGES = {
256
346
  title: { en: "Background access", "zh-TW": "背景存取", "zh-CN": "后台访问", ja: "バックグラウンドアクセス", ko: "백그라운드 액세스", de: "Hintergrundzugriff", fr: "Accès en arrière-plan", es: "Acceso en segundo plano", it: "Accesso in background" },
257
347
  password: { en: "DSM administrator password", "zh-TW": "DSM 管理員密碼", "zh-CN": "DSM 管理员密码", ja: "DSM 管理者パスワード", ko: "DSM 관리자 비밀번호", de: "DSM-Administratorkennwort", fr: "Mot de passe administrateur DSM", es: "Contraseña de administrador de DSM", it: "Password amministratore DSM" },
258
348
  approve: { en: "Approve", "zh-TW": "核准", "zh-CN": "批准", ja: "承認", ko: "승인", de: "Genehmigen", fr: "Approuver", es: "Aprobar", it: "Approva" },
259
349
  check: { en: "Check status", "zh-TW": "檢查狀態", "zh-CN": "检查状态", ja: "状態を確認", ko: "상태 확인", de: "Status prüfen", fr: "Vérifier l'état", es: "Comprobar estado", it: "Verifica stato" },
350
+ working: { en: "Waiting for the background service. This can take up to a minute while it starts.", "zh-TW": "正在等候背景服務,啟動時最多可能需要一分鐘。", "zh-CN": "正在等候后台服务,启动时最多可能需要一分钟。", ja: "バックグラウンドサービスを待機しています。開始時は最大 1 分かかることがあります。", ko: "백그라운드 서비스를 기다리는 중입니다. 시작할 때 최대 1분이 걸릴 수 있습니다.", de: "Warten auf den Hintergrunddienst. Beim Start kann dies bis zu einer Minute dauern.", fr: "En attente du service en arrière-plan. Son démarrage peut prendre jusqu'à une minute.", es: "Esperando al servicio en segundo plano. Al iniciarse puede tardar hasta un minuto.", it: "In attesa del servizio in background. All'avvio può richiedere fino a un minuto." },
260
351
  "state.ready": { en: "Approved and running.", "zh-TW": "已核准,正在執行。", "zh-CN": "已批准,正在运行。", ja: "承認済みで、実行中です。", ko: "승인되었으며 실행 중입니다.", de: "Genehmigt und aktiv.", fr: "Approuvé et actif.", es: "Aprobado y en ejecución.", it: "Approvato e in esecuzione." },
261
352
  "state.needs-admin-approval": { en: "Needs administrator approval.", "zh-TW": "需要管理員核准。", "zh-CN": "需要管理员批准。", ja: "管理者の承認が必要です。", ko: "관리자 승인이 필요합니다.", de: "Genehmigung durch einen Administrator erforderlich.", fr: "Approbation d'un administrateur requise.", es: "Requiere la aprobación de un administrador.", it: "Richiede l'approvazione di un amministratore." },
262
353
  "state.blocked": { en: "Not available.", "zh-TW": "目前無法使用。", "zh-CN": "目前无法使用。", ja: "現在は利用できません。", ko: "현재 사용할 수 없습니다.", de: "Derzeit nicht verfügbar.", fr: "Indisponible pour le moment.", es: "No disponible por ahora.", it: "Non disponibile al momento." },
263
354
  "state.unsupported": { en: "This DSM does not support background access.", "zh-TW": "這台 DSM 不支援背景存取。", "zh-CN": "此 DSM 不支持后台访问。", ja: "この DSM はバックグラウンドアクセスに対応していません。", ko: "이 DSM은 백그라운드 액세스를 지원하지 않습니다.", de: "Dieses DSM unterstützt keinen Hintergrundzugriff.", fr: "Ce DSM ne prend pas en charge l'accès en arrière-plan.", es: "Este DSM no admite el acceso en segundo plano.", it: "Questo DSM non supporta l'accesso in background." },
264
355
  "state.cancelled": { en: "Approval was cancelled.", "zh-TW": "已取消核准。", "zh-CN": "已取消批准。", ja: "承認はキャンセルされました。", ko: "승인이 취소되었습니다.", de: "Genehmigung abgebrochen.", fr: "Approbation annulée.", es: "Aprobación cancelada.", it: "Approvazione annullata." },
356
+ "reason.password_rejected": { en: "DSM rejected the administrator password. Nothing was changed.", "zh-TW": "DSM 拒絕了管理員密碼,沒有做任何變更。", "zh-CN": "DSM 拒绝了管理员密码,没有做任何更改。", ja: "DSM が管理者パスワードを拒否しました。何も変更されていません。", ko: "DSM에서 관리자 비밀번호를 거부했습니다. 변경된 내용은 없습니다.", de: "DSM hat das Administratorkennwort abgelehnt. Es wurde nichts geändert.", fr: "DSM a refusé le mot de passe administrateur. Rien n'a été modifié.", es: "DSM rechazó la contraseña de administrador. No se ha cambiado nada.", it: "DSM ha rifiutato la password amministratore. Non è stato modificato nulla." },
357
+ "reason.password_required": { en: "Enter the DSM administrator password.", "zh-TW": "請輸入 DSM 管理員密碼。", "zh-CN": "请输入 DSM 管理员密码。", ja: "DSM 管理者パスワードを入力してください。", ko: "DSM 관리자 비밀번호를 입력하십시오.", de: "Geben Sie das DSM-Administratorkennwort ein.", fr: "Saisissez le mot de passe administrateur DSM.", es: "Introduzca la contraseña de administrador de DSM.", it: "Immettere la password amministratore DSM." },
358
+ "reason.password_confirm_failed": { en: "DSM could not confirm the administrator password. Nothing was changed.", "zh-TW": "DSM 無法確認管理員密碼,沒有做任何變更。", "zh-CN": "DSM 无法确认管理员密码,没有做任何更改。", ja: "DSM で管理者パスワードを確認できませんでした。何も変更されていません。", ko: "DSM에서 관리자 비밀번호를 확인할 수 없습니다. 변경된 내용은 없습니다.", de: "DSM konnte das Administratorkennwort nicht bestätigen. Es wurde nichts geändert.", fr: "DSM n'a pas pu confirmer le mot de passe administrateur. Rien n'a été modifié.", es: "DSM no pudo confirmar la contraseña de administrador. No se ha cambiado nada.", it: "DSM non ha potuto confermare la password amministratore. Non è stato modificato nulla." },
359
+ "reason.session_ended": { en: "The DSM session ended. Sign in to DSM again, then approve again.", "zh-TW": "DSM 工作階段已結束。請重新登入 DSM 後再核准一次。", "zh-CN": "DSM 会话已结束。请重新登录 DSM 后再批准一次。", ja: "DSM セッションが終了しました。DSM に再度サインインしてから、もう一度承認してください。", ko: "DSM 세션이 종료되었습니다. DSM에 다시 로그인한 후 다시 승인하십시오.", de: "Die DSM-Sitzung ist abgelaufen. Melden Sie sich erneut bei DSM an und genehmigen Sie dann erneut.", fr: "La session DSM a expiré. Reconnectez-vous à DSM, puis approuvez de nouveau.", es: "La sesión de DSM ha finalizado. Vuelva a iniciar sesión en DSM y apruebe de nuevo.", it: "La sessione DSM è terminata. Accedere di nuovo a DSM, quindi approvare di nuovo." },
360
+ "reason.admin_required": { en: "Only a DSM administrator can approve.", "zh-TW": "只有 DSM 管理員可以核准。", "zh-CN": "只有 DSM 管理员可以批准。", ja: "承認できるのは DSM 管理者のみです。", ko: "DSM 관리자만 승인할 수 있습니다.", de: "Nur ein DSM-Administrator kann genehmigen.", fr: "Seul un administrateur DSM peut approuver.", es: "Solo un administrador de DSM puede aprobar.", it: "Solo un amministratore DSM può approvare." },
361
+ "reason.scheduler_denied": { en: "DSM denied the administrator access to Task Scheduler. Check the DSM session; restarting the NAS resolved this before.", "zh-TW": "DSM 拒絕了管理員對任務排程表的存取。請檢查 DSM 工作階段;先前重新啟動 NAS 可解決此問題。", "zh-CN": "DSM 拒绝了管理员对任务计划的访问。请检查 DSM 会话;之前重新启动 NAS 可解决此问题。", ja: "DSM が管理者のタスクスケジューラへのアクセスを拒否しました。DSM セッションを確認してください。以前は NAS の再起動で解決しました。", ko: "DSM에서 관리자의 작업 스케줄러 액세스를 거부했습니다. DSM 세션을 확인하십시오. 이전에는 NAS를 다시 시작하여 해결되었습니다.", de: "DSM hat dem Administrator den Zugriff auf den Aufgabenplaner verweigert. Prüfen Sie die DSM-Sitzung; ein Neustart des NAS hat dies bisher behoben.", fr: "DSM a refusé à l'administrateur l'accès au Planificateur de tâches. Vérifiez la session DSM ; un redémarrage du NAS a déjà résolu ce problème.", es: "DSM denegó al administrador el acceso al Programador de tareas. Compruebe la sesión de DSM; reiniciar el NAS lo resolvió anteriormente.", it: "DSM ha negato all'amministratore l'accesso a Utilità di pianificazione. Controllare la sessione DSM; in passato il riavvio del NAS ha risolto il problema." },
362
+ "reason.scheduler_failed": { en: "Reading or changing the scheduled tasks failed.", "zh-TW": "讀取或變更排程任務失敗。", "zh-CN": "读取或更改计划任务失败。", ja: "スケジュール済みタスクの読み取りまたは変更に失敗しました。", ko: "예약된 작업을 읽거나 변경하지 못했습니다.", de: "Die geplanten Aufgaben konnten nicht gelesen oder geändert werden.", fr: "La lecture ou la modification des tâches planifiées a échoué.", es: "No se pudieron leer ni cambiar las tareas programadas.", it: "Impossibile leggere o modificare le attività pianificate." },
363
+ "reason.scheduler_unsupported": { en: "This DSM's Task Scheduler does not offer what background access needs.", "zh-TW": "這台 DSM 的任務排程表不提供背景存取所需的功能。", "zh-CN": "此 DSM 的任务计划不提供后台访问所需的功能。", ja: "この DSM のタスクスケジューラには、バックグラウンドアクセスに必要な機能がありません。", ko: "이 DSM의 작업 스케줄러는 백그라운드 액세스에 필요한 기능을 제공하지 않습니다.", de: "Der Aufgabenplaner dieses DSM bietet nicht, was der Hintergrundzugriff benötigt.", fr: "Le Planificateur de tâches de ce DSM n'offre pas ce dont l'accès en arrière-plan a besoin.", es: "El Programador de tareas de este DSM no ofrece lo que necesita el acceso en segundo plano.", it: "L'Utilità di pianificazione di questo DSM non offre quanto necessario all'accesso in background." },
364
+ "reason.task_missing": { en: "The approved tasks are not in Task Scheduler.", "zh-TW": "任務排程表中沒有已核准的任務。", "zh-CN": "任务计划中没有已批准的任务。", ja: "承認済みのタスクがタスクスケジューラにありません。", ko: "승인된 작업이 작업 스케줄러에 없습니다.", de: "Die genehmigten Aufgaben fehlen im Aufgabenplaner.", fr: "Les tâches approuvées ne figurent pas dans le Planificateur de tâches.", es: "Las tareas aprobadas no están en el Programador de tareas.", it: "Le attività approvate non sono presenti in Utilità di pianificazione." },
365
+ "reason.task_duplicate": { en: "Task Scheduler has several tasks with this package's task name.", "zh-TW": "任務排程表中有多個任務使用此套件的任務名稱。", "zh-CN": "任务计划中有多个任务使用此套件的任务名称。", ja: "タスクスケジューラに、このパッケージのタスク名を持つタスクが複数あります。", ko: "작업 스케줄러에 이 패키지의 작업 이름을 가진 작업이 여러 개 있습니다.", de: "Im Aufgabenplaner gibt es mehrere Aufgaben mit dem Aufgabennamen dieses Pakets.", fr: "Le Planificateur de tâches contient plusieurs tâches portant le nom de tâche de ce paquet.", es: "El Programador de tareas tiene varias tareas con el nombre de tarea de este paquete.", it: "Utilità di pianificazione contiene più attività con il nome attività di questo pacchetto." },
366
+ "reason.task_unverified": { en: "The owner or definition of a scheduled task cannot be verified.", "zh-TW": "無法驗證排程任務的擁有者或定義。", "zh-CN": "无法验证计划任务的所有者或定义。", ja: "スケジュール済みタスクの所有者または定義を検証できません。", ko: "예약된 작업의 소유자 또는 정의를 확인할 수 없습니다.", de: "Besitzer oder Definition einer geplanten Aufgabe lassen sich nicht überprüfen.", fr: "Le propriétaire ou la définition d'une tâche planifiée ne peut pas être vérifié.", es: "No se puede verificar el propietario o la definición de una tarea programada.", it: "Impossibile verificare il proprietario o la definizione di un'attività pianificata." },
367
+ "reason.task_outdated": { en: "The package changed since the last approval.", "zh-TW": "套件在上次核准後已變更。", "zh-CN": "套件在上次批准后已更改。", ja: "前回の承認以降にパッケージが変更されました。", ko: "마지막 승인 이후 패키지가 변경되었습니다.", de: "Das Paket hat sich seit der letzten Genehmigung geändert.", fr: "Le paquet a changé depuis la dernière approbation.", es: "El paquete ha cambiado desde la última aprobación.", it: "Il pacchetto è cambiato dall'ultima approvazione." },
368
+ "reason.task_conflict": { en: "A scheduled task with this package's task name runs another script.", "zh-TW": "有個使用此套件任務名稱的排程任務執行的是其他指令碼。", "zh-CN": "有个使用此套件任务名称的计划任务运行的是其他脚本。", ja: "このパッケージのタスク名を持つスケジュール済みタスクが、別のスクリプトを実行します。", ko: "이 패키지의 작업 이름을 가진 예약된 작업이 다른 스크립트를 실행합니다.", de: "Eine geplante Aufgabe mit dem Aufgabennamen dieses Pakets führt ein anderes Skript aus.", fr: "Une tâche planifiée portant le nom de tâche de ce paquet exécute un autre script.", es: "Una tarea programada con el nombre de tarea de este paquete ejecuta otro script.", it: "Un'attività pianificata con il nome attività di questo pacchetto esegue un altro script." },
369
+ "reason.executor_unreachable": { en: "The background service of this package does not answer.", "zh-TW": "此套件的背景服務沒有回應。", "zh-CN": "此套件的后台服务没有响应。", ja: "このパッケージのバックグラウンドサービスが応答しません。", ko: "이 패키지의 백그라운드 서비스가 응답하지 않습니다.", de: "Der Hintergrunddienst dieses Pakets antwortet nicht.", fr: "Le service en arrière-plan de ce paquet ne répond pas.", es: "El servicio en segundo plano de este paquete no responde.", it: "Il servizio in background di questo pacchetto non risponde." },
370
+ "reason.executor_mismatch": { en: "The running background service is not the one this package approved.", "zh-TW": "正在執行的背景服務不是此套件核准的版本。", "zh-CN": "正在运行的后台服务不是此套件批准的版本。", ja: "実行中のバックグラウンドサービスは、このパッケージが承認したものではありません。", ko: "실행 중인 백그라운드 서비스는 이 패키지가 승인한 서비스가 아닙니다.", de: "Der laufende Hintergrunddienst ist nicht der von diesem Paket genehmigte.", fr: "Le service en arrière-plan en cours n'est pas celui approuvé par ce paquet.", es: "El servicio en segundo plano en ejecución no es el aprobado por este paquete.", it: "Il servizio in background in esecuzione non è quello approvato da questo pacchetto." },
371
+ "reason.executor_start_failed": { en: "The background service of this package could not be started.", "zh-TW": "無法啟動此套件的背景服務。", "zh-CN": "无法启动此套件的后台服务。", ja: "このパッケージのバックグラウンドサービスを開始できませんでした。", ko: "이 패키지의 백그라운드 서비스를 시작할 수 없습니다.", de: "Der Hintergrunddienst dieses Pakets konnte nicht gestartet werden.", fr: "Le service en arrière-plan de ce paquet n'a pas pu être démarré.", es: "No se pudo iniciar el servicio en segundo plano de este paquete.", it: "Impossibile avviare il servizio in background di questo pacchetto." },
372
+ "reason.executor_starting": { en: "The background service of this package is still running its start step. Check the status again in a moment.", "zh-TW": "此套件的背景服務仍在執行啟動步驟。請稍後再檢查狀態。", "zh-CN": "此套件的后台服务仍在运行启动步骤。请稍后再检查状态。", ja: "このパッケージのバックグラウンドサービスは、まだ開始ステップを実行中です。しばらくしてから状態を確認してください。", ko: "이 패키지의 백그라운드 서비스가 아직 시작 단계를 실행 중입니다. 잠시 후 상태를 다시 확인하십시오.", de: "Der Hintergrunddienst dieses Pakets führt noch seinen Startschritt aus. Prüfen Sie den Status gleich noch einmal.", fr: "Le service en arrière-plan de ce paquet exécute encore son étape de démarrage. Vérifiez l'état de nouveau dans un instant.", es: "El servicio en segundo plano de este paquete aún está ejecutando su paso de inicio. Vuelva a comprobar el estado en un momento.", it: "Il servizio in background di questo pacchetto sta ancora eseguendo il passaggio di avvio. Verificare di nuovo lo stato tra poco." },
373
+ "reason.selection_failed": { en: "The selected shared folder was not confirmed.", "zh-TW": "未能確認所選的共用資料夾。", "zh-CN": "未能确认所选的共享文件夹。", ja: "選択した共有フォルダを確認できませんでした。", ko: "선택한 공유 폴더를 확인하지 못했습니다.", de: "Der ausgewählte freigegebene Ordner wurde nicht bestätigt.", fr: "Le dossier partagé sélectionné n'a pas été confirmé.", es: "No se confirmó la carpeta compartida seleccionada.", it: "La cartella condivisa selezionata non è stata confermata." },
374
+ "reason.failed": { en: "The operation failed.", "zh-TW": "操作失敗。", "zh-CN": "操作失败。", ja: "操作に失敗しました。", ko: "작업에 실패했습니다.", de: "Der Vorgang ist fehlgeschlagen.", fr: "L'opération a échoué.", es: "La operación ha fallado.", it: "L'operazione non è riuscita." },
375
+ "outcome.restored": { en: "The earlier approval was restored.", "zh-TW": "已還原先前的核准。", "zh-CN": "已还原先前的批准。", ja: "以前の承認を復元しました。", ko: "이전 승인을 복원했습니다.", de: "Die frühere Genehmigung wurde wiederhergestellt.", fr: "L'approbation précédente a été restaurée.", es: "Se restauró la aprobación anterior.", it: "L'approvazione precedente è stata ripristinata." },
376
+ "outcome.withdrawn": { en: "The tasks this approval created were withdrawn.", "zh-TW": "已撤回這次核准建立的任務。", "zh-CN": "已撤回这次批准创建的任务。", ja: "この承認で作成したタスクを取り消しました。", ko: "이번 승인으로 생성한 작업을 철회했습니다.", de: "Die von dieser Genehmigung erstellten Aufgaben wurden zurückgezogen.", fr: "Les tâches créées par cette approbation ont été retirées.", es: "Se retiraron las tareas que creó esta aprobación.", it: "Le attività create da questa approvazione sono state ritirate." },
377
+ "outcome.restore_failed": { en: "Restoring the earlier approval failed.", "zh-TW": "還原先前的核准失敗。", "zh-CN": "还原先前的批准失败。", ja: "以前の承認を復元できませんでした。", ko: "이전 승인을 복원하지 못했습니다.", de: "Die frühere Genehmigung konnte nicht wiederhergestellt werden.", fr: "La restauration de l'approbation précédente a échoué.", es: "No se pudo restaurar la aprobación anterior.", it: "Impossibile ripristinare l'approvazione precedente." },
378
+ "outcome.withdraw_failed": { en: "Withdrawing the tasks this approval created failed.", "zh-TW": "撤回這次核准建立的任務失敗。", "zh-CN": "撤回这次批准创建的任务失败。", ja: "この承認で作成したタスクを取り消せませんでした。", ko: "이번 승인으로 생성한 작업을 철회하지 못했습니다.", de: "Die von dieser Genehmigung erstellten Aufgaben konnten nicht zurückgezogen werden.", fr: "Le retrait des tâches créées par cette approbation a échoué.", es: "No se pudieron retirar las tareas que creó esta aprobación.", it: "Impossibile ritirare le attività create da questa approvazione." },
265
379
  };
266
380
  /** ROOT_PANEL_MESSAGES as createI18n catalogs, one per locale. */
267
381
  export function rootPanelCatalogs() {
@@ -288,12 +402,22 @@ export const ROOT_PANEL = `function mountRootPanel(host, access, t, admin) {
288
402
  form.appendChild(label); form.appendChild(actions);
289
403
  panel.appendChild(title); panel.appendChild(status); panel.appendChild(reason); panel.appendChild(form);
290
404
  host.appendChild(panel);
405
+ // The code and outcome in the DSM language, then the DSM code the English reason starts with.
406
+ function explain(result) {
407
+ if (!result.reason && !result.code) return "";
408
+ var parts = [t("reason." + (result.code || "failed"))];
409
+ if (result.outcome) parts.push(t("outcome." + result.outcome));
410
+ var detail = /^([A-Za-z0-9_]+):/.exec(result.reason || "");
411
+ if (detail) parts.push("(" + detail[1] + ")");
412
+ return parts.reduce(function (text, part) { return text + (/[\u3000-\u30ff\u4e00-\u9fff\uac00-\ud7af\uff00-\uffef]$/.test(text) ? "" : " ") + part; });
413
+ }
291
414
  function show(result) {
292
415
  status.textContent = t("state." + result.state);
293
- reason.textContent = result.reason || ""; reason.hidden = !result.reason;
416
+ reason.textContent = explain(result); reason.hidden = !reason.textContent;
294
417
  label.hidden = approve.hidden = result.state !== "needs-admin-approval" && result.retry !== true;
295
418
  }
296
- function run(fn) { approve.disabled = check.disabled = true; return fn().then(show, function(e) { show({state: "blocked", reason: e.message || e.code}); }).then(function() { approve.disabled = check.disabled = false; }); }
419
+ // A start step can hold the background service for up to a minute.
420
+ function run(fn) { approve.disabled = check.disabled = true; reason.textContent = t("working"); reason.hidden = false; return fn().then(show, function(e) { show({state: "blocked", reason: e && e.code ? e.code + ":" : "", code: "failed"}); }).then(function() { approve.disabled = check.disabled = false; }); }
297
421
  access.subscribe(show);
298
422
  form.onsubmit = function(event) {
299
423
  if (event) event.preventDefault();