@cats-inc/cats-platform 0.1.1 → 0.1.5

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 (152) hide show
  1. package/.env.example +2 -2
  2. package/README.md +6 -0
  3. package/build/desktop/bootstrapPage.d.ts.map +1 -1
  4. package/build/desktop/bootstrapPage.js +81 -4
  5. package/build/desktop/bootstrapPage.js.map +1 -1
  6. package/build/desktop/config.d.ts +11 -1
  7. package/build/desktop/config.d.ts.map +1 -1
  8. package/build/desktop/config.js +5 -2
  9. package/build/desktop/config.js.map +1 -1
  10. package/build/desktop/contracts.d.ts +66 -11
  11. package/build/desktop/contracts.d.ts.map +1 -1
  12. package/build/desktop/contracts.js +50 -1
  13. package/build/desktop/contracts.js.map +1 -1
  14. package/build/desktop/hostState.d.ts +4 -3
  15. package/build/desktop/hostState.d.ts.map +1 -1
  16. package/build/desktop/hostState.js +22 -30
  17. package/build/desktop/hostState.js.map +1 -1
  18. package/build/desktop/hostVersion.d.ts +1 -0
  19. package/build/desktop/hostVersion.d.ts.map +1 -1
  20. package/build/desktop/hostVersion.js +29 -5
  21. package/build/desktop/hostVersion.js.map +1 -1
  22. package/build/desktop/main.js +234 -37
  23. package/build/desktop/main.js.map +1 -1
  24. package/build/desktop/packaging.d.ts.map +1 -1
  25. package/build/desktop/packaging.js +10 -4
  26. package/build/desktop/packaging.js.map +1 -1
  27. package/build/desktop/preload.cjs +29 -0
  28. package/build/desktop/preload.cjs.map +1 -1
  29. package/build/desktop/processSupervisor.d.ts +2 -0
  30. package/build/desktop/processSupervisor.d.ts.map +1 -1
  31. package/build/desktop/processSupervisor.js +28 -13
  32. package/build/desktop/processSupervisor.js.map +1 -1
  33. package/build/desktop/readiness.d.ts +2 -2
  34. package/build/desktop/readiness.d.ts.map +1 -1
  35. package/build/desktop/readiness.js +2 -2
  36. package/build/desktop/readiness.js.map +1 -1
  37. package/build/desktop/releaseDescriptor.d.ts +85 -0
  38. package/build/desktop/releaseDescriptor.d.ts.map +1 -0
  39. package/build/desktop/releaseDescriptor.js +246 -0
  40. package/build/desktop/releaseDescriptor.js.map +1 -0
  41. package/build/desktop/tray.d.ts +1 -0
  42. package/build/desktop/tray.d.ts.map +1 -1
  43. package/build/desktop/tray.js +26 -0
  44. package/build/desktop/tray.js.map +1 -1
  45. package/build/desktop/trayMenu.d.ts +22 -1
  46. package/build/desktop/trayMenu.d.ts.map +1 -1
  47. package/build/desktop/trayMenu.js +61 -0
  48. package/build/desktop/trayMenu.js.map +1 -1
  49. package/build/desktop/updateInstallHandoff.d.ts +30 -0
  50. package/build/desktop/updateInstallHandoff.d.ts.map +1 -0
  51. package/build/desktop/updateInstallHandoff.js +39 -0
  52. package/build/desktop/updateInstallHandoff.js.map +1 -0
  53. package/build/desktop/updateIpc.d.ts +42 -0
  54. package/build/desktop/updateIpc.d.ts.map +1 -0
  55. package/build/desktop/updateIpc.js +71 -0
  56. package/build/desktop/updateIpc.js.map +1 -0
  57. package/build/desktop/updateManager.d.ts +76 -0
  58. package/build/desktop/updateManager.d.ts.map +1 -0
  59. package/build/desktop/updateManager.js +342 -0
  60. package/build/desktop/updateManager.js.map +1 -0
  61. package/build/desktop/updateNotifications.d.ts +64 -0
  62. package/build/desktop/updateNotifications.d.ts.map +1 -0
  63. package/build/desktop/updateNotifications.js +143 -0
  64. package/build/desktop/updateNotifications.js.map +1 -0
  65. package/build/desktop/updaterAdapter.d.ts +72 -0
  66. package/build/desktop/updaterAdapter.d.ts.map +1 -0
  67. package/build/desktop/updaterAdapter.js +198 -0
  68. package/build/desktop/updaterAdapter.js.map +1 -0
  69. package/build/renderer/assets/{ActiveSessionPermissionChip-BYr8cDpb.js → ActiveSessionPermissionChip-BoDkKkmi.js} +1 -1
  70. package/build/renderer/assets/{App-BbuTWT6Y.js → App-Bs1eRarv.js} +1 -1
  71. package/build/renderer/assets/{App-B13AzNko.js → App-C4S1Wa9f.js} +1 -1
  72. package/build/renderer/assets/{App-n-RtGh42.js → App-CNJj5gi9.js} +1 -1
  73. package/build/renderer/assets/{WorkspaceAppRoutes-BktWrQK-.js → WorkspaceAppRoutes-CFAuhWGF.js} +1 -1
  74. package/build/renderer/assets/app-uJJx-k1S.js +43 -0
  75. package/build/renderer/assets/{conversationSidebarRecentEntries-BNPz3X1V.js → conversationSidebarRecentEntries-CPGopayy.js} +1 -1
  76. package/build/renderer/assets/{useDraftSessionChips-3z_Je4ju.js → useDraftSessionChips-BsOdYfWw.js} +1 -1
  77. package/build/renderer/index.html +1 -1
  78. package/build/server/app/renderer/settings/settingsDesktopErrorLabels.d.ts +5 -0
  79. package/build/server/app/renderer/settings/settingsDesktopErrorLabels.d.ts.map +1 -0
  80. package/build/server/app/renderer/settings/settingsDesktopErrorLabels.js +16 -0
  81. package/build/server/app/renderer/settings/settingsDesktopErrorLabels.js.map +1 -0
  82. package/build/server/app/renderer/settings/settingsDesktopPreferences.d.ts +11 -0
  83. package/build/server/app/renderer/settings/settingsDesktopPreferences.d.ts.map +1 -0
  84. package/build/server/app/renderer/settings/settingsDesktopPreferences.js +30 -0
  85. package/build/server/app/renderer/settings/settingsDesktopPreferences.js.map +1 -0
  86. package/build/server/app/renderer/settings/settingsDesktopUpdateLabels.d.ts +31 -0
  87. package/build/server/app/renderer/settings/settingsDesktopUpdateLabels.d.ts.map +1 -0
  88. package/build/server/app/renderer/settings/settingsDesktopUpdateLabels.js +101 -0
  89. package/build/server/app/renderer/settings/settingsDesktopUpdateLabels.js.map +1 -0
  90. package/build/server/app/server/index.js +1 -1
  91. package/build/server/app/server/index.js.map +1 -1
  92. package/build/server/app/server/platformSetupRoutes.d.ts.map +1 -1
  93. package/build/server/app/server/platformSetupRoutes.js +42 -5
  94. package/build/server/app/server/platformSetupRoutes.js.map +1 -1
  95. package/build/server/app/server/requestRouter.d.ts.map +1 -1
  96. package/build/server/app/server/requestRouter.js +2 -0
  97. package/build/server/app/server/requestRouter.js.map +1 -1
  98. package/build/server/design/components/runtimeChips.d.ts +1 -1
  99. package/build/server/design/components/runtimeChips.d.ts.map +1 -1
  100. package/build/server/design/components/runtimeChips.js +4 -1
  101. package/build/server/design/components/runtimeChips.js.map +1 -1
  102. package/build/server/index.js +12 -1
  103. package/build/server/index.js.map +1 -1
  104. package/build/server/platform/auth/sessionSecretProvisioning.d.ts +23 -0
  105. package/build/server/platform/auth/sessionSecretProvisioning.d.ts.map +1 -0
  106. package/build/server/platform/auth/sessionSecretProvisioning.js +291 -0
  107. package/build/server/platform/auth/sessionSecretProvisioning.js.map +1 -0
  108. package/build/server/products/code/livePreview/realProcessAdapter.d.ts.map +1 -1
  109. package/build/server/products/code/livePreview/realProcessAdapter.js +7 -1
  110. package/build/server/products/code/livePreview/realProcessAdapter.js.map +1 -1
  111. package/build/server/products/code/state/appExport.d.ts.map +1 -1
  112. package/build/server/products/code/state/appExport.js +2 -0
  113. package/build/server/products/code/state/appExport.js.map +1 -1
  114. package/build/server/shared/desktopRecoveryBridge.d.ts +46 -0
  115. package/build/server/shared/desktopRecoveryBridge.d.ts.map +1 -1
  116. package/build/server/shared/desktopRecoveryBridge.js +20 -0
  117. package/build/server/shared/desktopRecoveryBridge.js.map +1 -1
  118. package/build/server/shared/i18n/catalogs/en.d.ts.map +1 -1
  119. package/build/server/shared/i18n/catalogs/en.js +37 -0
  120. package/build/server/shared/i18n/catalogs/en.js.map +1 -1
  121. package/build/server/shared/i18n/catalogs/zh-TW.d.ts.map +1 -1
  122. package/build/server/shared/i18n/catalogs/zh-TW.js +35 -0
  123. package/build/server/shared/i18n/catalogs/zh-TW.js.map +1 -1
  124. package/build/server/shared/i18n/index.d.ts +35 -0
  125. package/build/server/shared/i18n/index.d.ts.map +1 -1
  126. package/build/server/shared/i18n/messageKeys.d.ts +35 -0
  127. package/build/server/shared/i18n/messageKeys.d.ts.map +1 -1
  128. package/build/server/shared/i18n/messageKeys.js +35 -0
  129. package/build/server/shared/i18n/messageKeys.js.map +1 -1
  130. package/build/server/shared/loadProjectEnvFile.d.ts.map +1 -1
  131. package/build/server/shared/loadProjectEnvFile.js +18 -19
  132. package/build/server/shared/loadProjectEnvFile.js.map +1 -1
  133. package/build/server/shared/platformChildProcessEnv.d.ts +2 -0
  134. package/build/server/shared/platformChildProcessEnv.d.ts.map +1 -0
  135. package/build/server/shared/platformChildProcessEnv.js +22 -0
  136. package/build/server/shared/platformChildProcessEnv.js.map +1 -0
  137. package/package.json +19 -27
  138. package/scripts/build-desktop-installer.mjs +438 -37
  139. package/scripts/build-test-ui.mjs +13 -2
  140. package/scripts/generate-desktop-release-descriptor.mjs +258 -0
  141. package/scripts/validate-release-assets.mjs +422 -0
  142. package/scripts/validate-release-version.mjs +217 -0
  143. package/scripts/windows/Test-WindowsInstallerSmoke.ps1 +55 -1
  144. package/build/desktop/update.d.ts +0 -24
  145. package/build/desktop/update.d.ts.map +0 -1
  146. package/build/desktop/update.js +0 -173
  147. package/build/desktop/update.js.map +0 -1
  148. package/build/renderer/assets/app-DnVQMpW_.js +0 -43
  149. package/build/server/app/renderer/settings/settingsDesktopStartupErrorLabels.d.ts +0 -5
  150. package/build/server/app/renderer/settings/settingsDesktopStartupErrorLabels.d.ts.map +0 -1
  151. package/build/server/app/renderer/settings/settingsDesktopStartupErrorLabels.js +0 -11
  152. package/build/server/app/renderer/settings/settingsDesktopStartupErrorLabels.js.map +0 -1
@@ -1,11 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import process from 'node:process';
4
- import { access, copyFile, mkdir, rm, writeFile } from 'node:fs/promises';
4
+ import { access, copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
5
5
  import { dirname, resolve } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { spawn } from 'node:child_process';
8
8
 
9
+ import { parseStableReleaseTag } from './validate-release-version.mjs';
10
+ import { DESCRIPTOR_RELATIVE_PATH } from './generate-desktop-release-descriptor.mjs';
11
+
9
12
  const SCRIPT_PATH = fileURLToPath(import.meta.url);
10
13
  const PROJECT_ROOT = resolve(dirname(SCRIPT_PATH), '..');
11
14
  const RUNTIME_ROOT = resolve(PROJECT_ROOT, '..', 'cats-runtime');
@@ -31,13 +34,30 @@ Options:
31
34
  --sidecar-layout <split|bundle> Choose loose-file or bundled sidecars for both app/runtime.
32
35
  --skip-mobile Skip the mobile bundle (\`expo export\`). Also honored via
33
36
  CATS_SKIP_MOBILE=1 in the environment or .env.
37
+ --release Build an official release package: embed the release
38
+ descriptor, require platform signing credentials, and
39
+ allow signing identity discovery. Requires a GitHub
40
+ Actions tag run whose tag matches the package version.
41
+ Also honored via CATS_DESKTOP_RELEASE_MODE=1.
42
+ --preview Build an unsupported unsigned GitHub prerelease preview.
43
+ Requires the same tag/version provenance as --release,
44
+ omits the official descriptor, and disables signing
45
+ identity discovery. Also honored via
46
+ CATS_DESKTOP_PREVIEW_MODE=1.
47
+ --publish <never|always> Publish policy, default never. Publishing requires
48
+ --release or --preview plus a GitHub token. Also honored
49
+ via CATS_DESKTOP_PUBLISH.
34
50
  --help Show this help text.
35
51
 
36
52
  Without --arch/--format, the electron-builder target matrix from package.json is preserved.
53
+
54
+ Release mode and publishing are orthogonal. Stable releases are official and signed. Preview mode
55
+ may publish an unsigned prerelease but never embeds official update identity or discovers signing
56
+ identities. Local packaging is unofficial and never publishes.
37
57
  `);
38
58
  }
39
59
 
40
- function parseSkipMobileFlag(value) {
60
+ function parseBooleanFlag(value) {
41
61
  if (typeof value !== 'string') {
42
62
  return false;
43
63
  }
@@ -45,6 +65,20 @@ function parseSkipMobileFlag(value) {
45
65
  return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on';
46
66
  }
47
67
 
68
+ /**
69
+ * Publishing is orthogonal to building an official package. Stable tag builds
70
+ * publish; an explicit official validation may still select `never`.
71
+ */
72
+ export function resolvePublishPolicy(value) {
73
+ if (value === undefined || value === null || value === '') {
74
+ return 'never';
75
+ }
76
+ if (value === 'never' || value === 'always') {
77
+ return value;
78
+ }
79
+ throw new Error(`Unsupported publish policy: ${value}`);
80
+ }
81
+
48
82
  function resolveSidecarLayout(value) {
49
83
  if (value === undefined || value === null || value === '') {
50
84
  return 'split';
@@ -60,12 +94,56 @@ export function parseArgs(argv, env = process.env) {
60
94
  let arch = null;
61
95
  let format = null;
62
96
  let sidecarLayout = resolveSidecarLayout(env.CATS_DESKTOP_SIDECAR_LAYOUT);
63
- let skipMobile = parseSkipMobileFlag(env.CATS_SKIP_MOBILE);
97
+ let skipMobile = parseBooleanFlag(env.CATS_SKIP_MOBILE);
98
+ let releaseMode = parseBooleanFlag(env.CATS_DESKTOP_RELEASE_MODE);
99
+ let previewMode = parseBooleanFlag(env.CATS_DESKTOP_PREVIEW_MODE);
100
+ let publish = resolvePublishPolicy(env.CATS_DESKTOP_PUBLISH);
101
+ // GITHUB_REF_NAME is a runner-provided default. A workflow `env:` block
102
+ // cannot override anything in the GITHUB_ namespace, and a preview run is
103
+ // dispatched from a branch, so the tag has to arrive as an explicit input.
104
+ let tag = (env.CATS_DESKTOP_RELEASE_TAG ?? env.GITHUB_REF_NAME ?? '').trim();
64
105
 
65
106
  for (let index = 0; index < argv.length; index += 1) {
66
107
  const value = argv[index];
67
108
  if (value === '--help' || value === '-h') {
68
- return { help: true, target, arch, format, sidecarLayout, skipMobile };
109
+ return {
110
+ help: true,
111
+ target,
112
+ arch,
113
+ format,
114
+ sidecarLayout,
115
+ skipMobile,
116
+ releaseMode,
117
+ previewMode,
118
+ publish,
119
+ tag,
120
+ };
121
+ }
122
+ if (value === '--release') {
123
+ releaseMode = true;
124
+ continue;
125
+ }
126
+ if (value === '--no-release') {
127
+ releaseMode = false;
128
+ continue;
129
+ }
130
+ if (value === '--preview') {
131
+ previewMode = true;
132
+ continue;
133
+ }
134
+ if (value === '--no-preview') {
135
+ previewMode = false;
136
+ continue;
137
+ }
138
+ if (value === '--publish') {
139
+ publish = resolvePublishPolicy(argv[index + 1] ?? '');
140
+ index += 1;
141
+ continue;
142
+ }
143
+ if (value === '--tag') {
144
+ tag = (argv[index + 1] ?? '').trim();
145
+ index += 1;
146
+ continue;
69
147
  }
70
148
  if (value === '--target') {
71
149
  target = argv[index + 1] ?? 'current';
@@ -98,7 +176,18 @@ export function parseArgs(argv, env = process.env) {
98
176
  throw new Error(`Unknown option: ${value}`);
99
177
  }
100
178
 
101
- return { help: false, target, arch, format, sidecarLayout, skipMobile };
179
+ return {
180
+ help: false,
181
+ target,
182
+ arch,
183
+ format,
184
+ sidecarLayout,
185
+ skipMobile,
186
+ releaseMode,
187
+ previewMode,
188
+ publish,
189
+ tag,
190
+ };
102
191
  }
103
192
 
104
193
  async function resolveNodeCliScript(command) {
@@ -142,13 +231,32 @@ async function resolveCommandInvocation(command, args) {
142
231
  };
143
232
  }
144
233
 
145
- export function buildInstallerEnvironment(baseEnv = process.env) {
146
- const env = {
147
- ...baseEnv,
148
- CSC_IDENTITY_AUTO_DISCOVERY: 'false',
149
- };
234
+ const SIGNING_CREDENTIAL_KEYS = [
235
+ 'WIN_CSC_LINK',
236
+ 'CSC_LINK',
237
+ 'WIN_CSC_KEY_PASSWORD',
238
+ 'CSC_KEY_PASSWORD',
239
+ ];
240
+
241
+ /**
242
+ * Local and test packaging must never reach for a signing identity, because an
243
+ * unconfigured machine would otherwise pick up an unrelated certificate from
244
+ * the OS keychain. Release mode leaves identity discovery to the workflow so a
245
+ * configured certificate can actually be used.
246
+ *
247
+ * Empty credential variables are dropped in both modes: electron-builder treats
248
+ * an empty CSC_LINK as a relative path and resolves it against the project
249
+ * root.
250
+ */
251
+ export function buildInstallerEnvironment(baseEnv = process.env, options = {}) {
252
+ const releaseMode = options.releaseMode === true;
253
+ const env = { ...baseEnv };
254
+
255
+ if (!releaseMode) {
256
+ env.CSC_IDENTITY_AUTO_DISCOVERY = 'false';
257
+ }
150
258
 
151
- for (const key of ['WIN_CSC_LINK', 'CSC_LINK', 'WIN_CSC_KEY_PASSWORD', 'CSC_KEY_PASSWORD']) {
259
+ for (const key of SIGNING_CREDENTIAL_KEYS) {
152
260
  const value = env[key];
153
261
  if (typeof value !== 'string' || value.trim() === '') {
154
262
  delete env[key];
@@ -158,7 +266,119 @@ export function buildInstallerEnvironment(baseEnv = process.env) {
158
266
  return env;
159
267
  }
160
268
 
161
- async function runCommand(command, args, cwd, envOverrides = {}) {
269
+ export function hasWindowsSigningCredentials(env = process.env) {
270
+ const link = env.WIN_CSC_LINK ?? env.CSC_LINK;
271
+ return typeof link === 'string' && link.trim() !== '';
272
+ }
273
+
274
+ export function hasMacosSigningCredentials(env = process.env) {
275
+ const link = env.CSC_LINK;
276
+ return typeof link === 'string' && link.trim() !== '';
277
+ }
278
+
279
+ /**
280
+ * Signing is a release gate, not a nice-to-have.
281
+ *
282
+ * SPEC-111 section 9 forbids advertising stable self-update on Windows or
283
+ * macOS before signing is available and validated, and the workflow publishes
284
+ * whatever the platform job produced. Without this check a missing secret would
285
+ * quietly ship unsigned stable binaries.
286
+ */
287
+ export function resolveSigningProblems({ env = process.env, target } = {}) {
288
+ if (target === 'windows' && !hasWindowsSigningCredentials(env)) {
289
+ return [{
290
+ code: 'release_windows_signing_missing',
291
+ message: 'An official Windows release requires WIN_CSC_LINK or CSC_LINK.',
292
+ }];
293
+ }
294
+
295
+ if (target === 'macos' && !hasMacosSigningCredentials(env)) {
296
+ return [{
297
+ code: 'release_macos_signing_missing',
298
+ message: 'An official macOS release requires CSC_LINK.',
299
+ }];
300
+ }
301
+
302
+ return [];
303
+ }
304
+
305
+ /**
306
+ * Release and preview publication are only meaningful inside the guarded
307
+ * workflow. Anything else could publish from an unreviewed tree, so the
308
+ * provenance inputs are checked before any platform build work starts.
309
+ *
310
+ * `.env` is loaded at module scope for developer convenience, which means
311
+ * A mode environment variable alone must never be enough. The checks below
312
+ * require the surrounding workflow to be real and the selected tag to match
313
+ * the package version.
314
+ */
315
+ export function resolveReleaseModeProblems({
316
+ env = process.env,
317
+ tag = null,
318
+ target = null,
319
+ packageVersion = null,
320
+ publish = 'never',
321
+ requireSigning = true,
322
+ requireTagRef = true,
323
+ } = {}) {
324
+ const problems = [];
325
+
326
+ if (env.GITHUB_ACTIONS !== 'true') {
327
+ problems.push({
328
+ code: 'release_not_in_workflow',
329
+ message: 'Release and preview modes only run inside GitHub Actions (GITHUB_ACTIONS=true).',
330
+ });
331
+ }
332
+
333
+ // Only a stable release is triggered by a tag push, so only it can assert a
334
+ // tag ref. A preview is dispatched from a branch and creates its tag as part
335
+ // of the run, so requiring one here would reject every preview.
336
+ //
337
+ // GITHUB_REF_TYPE is runner-provided and cannot be overridden from a
338
+ // workflow `env:` block, so this reads the real trigger rather than
339
+ // something the workflow claimed.
340
+ if (requireTagRef && env.GITHUB_REF_TYPE !== undefined && env.GITHUB_REF_TYPE !== 'tag') {
341
+ problems.push({
342
+ code: 'release_ref_not_tag',
343
+ message: `A stable release requires a tag ref, but GITHUB_REF_TYPE is '${env.GITHUB_REF_TYPE}'.`,
344
+ });
345
+ }
346
+
347
+ const candidateTag = typeof tag === 'string' && tag.trim() !== ''
348
+ ? tag
349
+ : env.GITHUB_REF_NAME ?? '';
350
+ const parsedTag = parseStableReleaseTag(candidateTag);
351
+ if (!parsedTag.ok) {
352
+ problems.push({
353
+ code: `release_${parsedTag.code}`,
354
+ message: `Release and preview modes require a vX.Y.Z tag. ${parsedTag.message}`,
355
+ });
356
+ } else if (typeof packageVersion === 'string' && packageVersion !== parsedTag.version) {
357
+ problems.push({
358
+ code: 'release_version_mismatch',
359
+ message: `Tag ${parsedTag.tag} does not match package version ${packageVersion}.`,
360
+ });
361
+ }
362
+
363
+ if (requireSigning) {
364
+ problems.push(...resolveSigningProblems({ env, target }));
365
+ }
366
+
367
+ // Only publishing needs a token; non-publishing validation does not.
368
+ if (publish !== 'never') {
369
+ const token = env.GH_TOKEN ?? env.GITHUB_TOKEN;
370
+ if (typeof token !== 'string' || token.trim() === '') {
371
+ problems.push({
372
+ code: 'release_token_missing',
373
+ message: 'Publishing requires GH_TOKEN or GITHUB_TOKEN for the GitHub provider.',
374
+ });
375
+ }
376
+ }
377
+
378
+ return problems;
379
+ }
380
+
381
+ async function runCommand(command, args, cwd, envOverrides = {}, envOptions = {}) {
162
382
  const invocation = await resolveCommandInvocation(command, args);
163
383
  return new Promise((resolvePromise, reject) => {
164
384
  const child = spawn(invocation.command, invocation.args, {
@@ -166,7 +386,7 @@ async function runCommand(command, args, cwd, envOverrides = {}) {
166
386
  env: buildInstallerEnvironment({
167
387
  ...process.env,
168
388
  ...envOverrides,
169
- }),
389
+ }, envOptions),
170
390
  stdio: 'inherit',
171
391
  shell: false,
172
392
  });
@@ -193,6 +413,15 @@ async function prepareNativeBuildRoot() {
193
413
  );
194
414
  }
195
415
 
416
+ /**
417
+ * Local packaging must never ship an official release descriptor. Removing it
418
+ * rather than merely not writing it also clears a stale descriptor left behind
419
+ * by an earlier release build in the same working tree.
420
+ */
421
+ async function ensureReleaseDescriptorAbsent() {
422
+ await rm(resolve(PROJECT_ROOT, DESCRIPTOR_RELATIVE_PATH), { force: true });
423
+ }
424
+
196
425
  async function ensureMobileSkipPlaceholder() {
197
426
  await mkdir(MOBILE_BUILD_ROOT, { recursive: true });
198
427
  await writeFile(
@@ -204,18 +433,95 @@ async function ensureMobileSkipPlaceholder() {
204
433
  );
205
434
  }
206
435
 
207
- async function buildMacosVoiceHelper() {
436
+ /**
437
+ * Asks SwiftPM where it put the binary instead of reconstructing the path.
438
+ *
439
+ * The layout differs per invocation: a plain build lands in `.build/release`,
440
+ * a single `--arch` cross-build in a triple-specific directory, and a
441
+ * multi-arch build in an Xcode-style `.build/apple/Products/Release`. Guessing
442
+ * wrong fails only after the compile succeeds, which reads like a build error
443
+ * when it is really a copy error.
444
+ */
445
+ async function resolveSwiftBinPath(packageRoot, swiftArgs) {
446
+ const invocation = await resolveCommandInvocation('swift', [...swiftArgs, '--show-bin-path']);
447
+ return new Promise((resolvePromise, reject) => {
448
+ const child = spawn(invocation.command, invocation.args, {
449
+ cwd: PROJECT_ROOT,
450
+ env: buildInstallerEnvironment({ ...process.env }, {}),
451
+ stdio: ['ignore', 'pipe', 'inherit'],
452
+ shell: false,
453
+ });
454
+ let out = '';
455
+ child.stdout.on('data', (chunk) => {
456
+ out += String(chunk);
457
+ });
458
+ child.once('exit', (code) => {
459
+ if (code !== 0) {
460
+ reject(new Error(`swift --show-bin-path exited with code ${code ?? 'null'}`));
461
+ return;
462
+ }
463
+ const binPath = out.trim().split(/\r?\n/u).filter(Boolean).at(-1);
464
+ if (!binPath) {
465
+ reject(new Error('swift --show-bin-path produced no path.'));
466
+ return;
467
+ }
468
+ resolvePromise(binPath);
469
+ });
470
+ child.once('error', reject);
471
+ });
472
+ }
473
+
474
+ async function buildMacosVoiceHelper(archOverride) {
208
475
  if (process.platform !== 'darwin') {
209
476
  throw new Error('The macOS voice helper must be built on macOS.');
210
477
  }
211
478
 
212
479
  const packageRoot = resolve(PROJECT_ROOT, 'desktop', 'native', 'macos-stt');
213
- await runCommand('swift', ['build', '-c', 'release', '--package-path', packageRoot], PROJECT_ROOT);
214
-
215
480
  const outputDir = resolve(NATIVE_BUILD_ROOT, 'macos-stt');
216
481
  await mkdir(outputDir, { recursive: true });
482
+
483
+ if (archOverride === 'universal') {
484
+ // A universal DMG merges an x64 and an arm64 app bundle. electron-builder
485
+ // refuses when a binary is byte-identical in both, because it cannot tell
486
+ // whether that is a resource to share or a single-arch executable that
487
+ // should have been lipo'd. Building the helper universal removes the
488
+ // ambiguity instead of suppressing the check.
489
+ const universalArgs = [
490
+ 'build',
491
+ '-c',
492
+ 'release',
493
+ '--package-path',
494
+ packageRoot,
495
+ '--arch',
496
+ 'x86_64',
497
+ '--arch',
498
+ 'arm64',
499
+ ];
500
+ await runCommand('swift', universalArgs, PROJECT_ROOT);
501
+ await copyFile(
502
+ resolve(await resolveSwiftBinPath(packageRoot, universalArgs), 'cats-stt-macos'),
503
+ resolve(outputDir, 'cats-stt-macos'),
504
+ );
505
+ return;
506
+ }
507
+
508
+ // Cross-compilation is the normal case now: the macOS runner is arm64 while
509
+ // the release targets x64, and an unqualified `swift build` follows the host.
510
+ // That would quietly bundle an arm64 helper inside an x64 app, which only
511
+ // fails when a user tries to dictate.
512
+ const swiftArch = archOverride === 'arm64' ? 'arm64' : 'x86_64';
513
+ const swiftArgs = [
514
+ 'build',
515
+ '-c',
516
+ 'release',
517
+ '--package-path',
518
+ packageRoot,
519
+ '--arch',
520
+ swiftArch,
521
+ ];
522
+ await runCommand('swift', swiftArgs, PROJECT_ROOT);
217
523
  await copyFile(
218
- resolve(packageRoot, '.build', 'release', 'cats-stt-macos'),
524
+ resolve(await resolveSwiftBinPath(packageRoot, swiftArgs), 'cats-stt-macos'),
219
525
  resolve(outputDir, 'cats-stt-macos'),
220
526
  );
221
527
  }
@@ -249,7 +555,7 @@ async function buildWindowsVoiceHelper(archOverride) {
249
555
  async function buildNativeVoiceHelpers(target, archOverride) {
250
556
  await prepareNativeBuildRoot();
251
557
  if (target === 'macos') {
252
- await buildMacosVoiceHelper();
558
+ await buildMacosVoiceHelper(archOverride);
253
559
  return;
254
560
  }
255
561
  if (target === 'windows') {
@@ -294,46 +600,68 @@ const VALID_ARCHES = {
294
600
  linux: ['x64', 'arm64'],
295
601
  };
296
602
 
297
- function normalizeFormat(target, formatOverride) {
298
- if (formatOverride === null) {
603
+ /**
604
+ * Accepts a single format or a comma-separated list. macOS needs both the DMG
605
+ * users install and the ZIP electron-updater reads, so the release matrix
606
+ * cannot be expressed with one format per platform.
607
+ */
608
+ export function normalizeFormats(target, formatOverride) {
609
+ if (formatOverride === null || formatOverride === undefined || formatOverride === '') {
299
610
  return null;
300
611
  }
301
612
 
302
- const canonicalFormat = PLATFORM_FORMATS[target].find(
303
- (candidate) => candidate.toLowerCase() === formatOverride.toLowerCase(),
304
- );
613
+ const requested = String(formatOverride).split(',').map((value) => value.trim()).filter(Boolean);
614
+ if (requested.length === 0) {
615
+ return null;
616
+ }
305
617
 
306
- if (!canonicalFormat) {
307
- throw new Error(
308
- `Unsupported format '${formatOverride}' for ${target}. Valid: ${PLATFORM_FORMATS[target].join(', ')}`,
618
+ return requested.map((value) => {
619
+ const canonicalFormat = PLATFORM_FORMATS[target].find(
620
+ (candidate) => candidate.toLowerCase() === value.toLowerCase(),
309
621
  );
310
- }
311
622
 
312
- return canonicalFormat;
623
+ if (!canonicalFormat) {
624
+ throw new Error(
625
+ `Unsupported format '${value}' for ${target}. Valid: ${PLATFORM_FORMATS[target].join(', ')}`,
626
+ );
627
+ }
628
+
629
+ return canonicalFormat;
630
+ });
313
631
  }
314
632
 
315
- function electronBuilderArgs(target, archOverride, formatOverride) {
633
+ export function electronBuilderArgs(target, archOverride, formatOverride, options = {}) {
316
634
  if (archOverride !== null && !VALID_ARCHES[target].includes(archOverride)) {
317
635
  throw new Error(
318
636
  `Unsupported arch '${archOverride}' for ${target}. Valid: ${VALID_ARCHES[target].join(', ')}`,
319
637
  );
320
638
  }
321
639
 
322
- const format = normalizeFormat(target, formatOverride);
640
+ const releaseMode = options.releaseMode === true;
641
+ const publish = resolvePublishPolicy(options.publish ?? 'never');
642
+ const signWindowsExecutable = options.signWindowsExecutable === true;
643
+ const formats = normalizeFormats(target, formatOverride);
323
644
  const platformFlag = target === 'windows' ? '--win' : target === 'macos' ? '--mac' : '--linux';
324
645
  const args = ['electron-builder', platformFlag];
325
646
 
326
- if (format !== null) {
327
- args.push(format);
647
+ if (formats !== null) {
648
+ args.push(...formats);
328
649
  } else if (archOverride !== null) {
329
650
  args.push(...PLATFORM_FORMATS[target], `--${archOverride}`);
330
651
  }
331
652
 
332
- if (format !== null && archOverride !== null) {
653
+ if (formats !== null && archOverride !== null) {
333
654
  args.push(`--${archOverride}`);
334
655
  }
335
656
 
336
- args.push('--publish', 'never');
657
+ // package.json pins signAndEditExecutable to false so unsigned local builds
658
+ // avoid the winCodeSign download. A release build with real credentials has
659
+ // to opt back in, and only then.
660
+ if (releaseMode && target === 'windows' && signWindowsExecutable) {
661
+ args.push('-c.win.signAndEditExecutable=true');
662
+ }
663
+
664
+ args.push('--publish', publish);
337
665
  return args;
338
666
  }
339
667
 
@@ -345,20 +673,86 @@ async function main() {
345
673
  }
346
674
 
347
675
  const resolvedTarget = resolveBuilderTarget(parsed.target);
676
+ const envOptions = { releaseMode: parsed.releaseMode };
677
+
678
+ if (parsed.releaseMode && parsed.previewMode) {
679
+ throw new Error('--release and --preview are mutually exclusive build modes.');
680
+ }
681
+
682
+ if (parsed.publish !== 'never' && !parsed.releaseMode && !parsed.previewMode) {
683
+ throw new Error('Publishing requires --release or --preview.');
684
+ }
685
+
686
+ if (parsed.releaseMode || parsed.previewMode) {
687
+ const { version: packageVersion } = JSON.parse(
688
+ await readFile(resolve(PROJECT_ROOT, 'package.json'), 'utf8'),
689
+ );
690
+ const problems = resolveReleaseModeProblems({
691
+ env: process.env,
692
+ tag: parsed.tag,
693
+ target: resolvedTarget,
694
+ packageVersion,
695
+ publish: parsed.publish,
696
+ requireSigning: parsed.releaseMode,
697
+ requireTagRef: parsed.releaseMode,
698
+ });
699
+ if (problems.length > 0) {
700
+ throw new Error(
701
+ `Release/preview inputs are not valid:\n${problems
702
+ .map((problem) => ` ${problem.code}: ${problem.message}`)
703
+ .join('\n')}`,
704
+ );
705
+ }
706
+ const buildKind = parsed.releaseMode ? 'official' : 'unsigned preview';
707
+ process.stdout.write(
708
+ `[build-desktop-installer] ${buildKind} ${resolvedTarget} build for `
709
+ + `${process.env.GITHUB_REF_NAME} (publish=${parsed.publish}).\n`,
710
+ );
711
+ }
712
+
348
713
  const sidecarBuildEnv = {
349
714
  CATS_DESKTOP_SIDECAR_LAYOUT: parsed.sidecarLayout,
350
715
  };
351
- await runCommand('npm', ['run', 'build'], RUNTIME_ROOT, sidecarBuildEnv);
716
+ await runCommand('npm', ['run', 'build'], RUNTIME_ROOT, sidecarBuildEnv, envOptions);
352
717
  const platformBuildScript = parsed.skipMobile ? 'build:no-mobile' : 'build';
353
718
  if (parsed.skipMobile) {
354
719
  process.stdout.write(
355
720
  '[build-desktop-installer] CATS_SKIP_MOBILE active — running build:no-mobile and seeding build/mobile/.skip-marker after build.\n',
356
721
  );
357
722
  }
358
- await runCommand('npm', ['run', platformBuildScript], PROJECT_ROOT, sidecarBuildEnv);
723
+ await runCommand('npm', ['run', platformBuildScript], PROJECT_ROOT, sidecarBuildEnv, envOptions);
359
724
  if (parsed.skipMobile) {
360
725
  await ensureMobileSkipPlaceholder();
361
726
  }
727
+
728
+ // npm run build wipes build/, so the descriptor has to be written after the
729
+ // platform build and before packaging collects build/desktop.
730
+ //
731
+ // A preview gets a descriptor too, marked `preview`, so the upgrade path can
732
+ // be exercised before signing exists. It resolves to its own distribution
733
+ // mode, so it never claims official update identity.
734
+ if (parsed.releaseMode || parsed.previewMode) {
735
+ await runCommand(
736
+ 'node',
737
+ [
738
+ 'scripts/generate-desktop-release-descriptor.mjs',
739
+ '--platform',
740
+ resolvedTarget,
741
+ '--kind',
742
+ parsed.releaseMode ? 'official' : 'preview',
743
+ // Forwarded rather than left to GITHUB_REF_NAME, which is the branch
744
+ // name on a preview dispatch.
745
+ '--tag',
746
+ parsed.tag,
747
+ ],
748
+ PROJECT_ROOT,
749
+ {},
750
+ envOptions,
751
+ );
752
+ } else {
753
+ await ensureReleaseDescriptorAbsent();
754
+ }
755
+
362
756
  await buildNativeVoiceHelpers(resolvedTarget, parsed.arch);
363
757
  await runCommand(
364
758
  'node',
@@ -371,11 +765,18 @@ async function main() {
371
765
  ],
372
766
  PROJECT_ROOT,
373
767
  sidecarBuildEnv,
768
+ envOptions,
374
769
  );
375
770
  await runCommand(
376
771
  'npx',
377
- electronBuilderArgs(resolvedTarget, parsed.arch, parsed.format),
772
+ electronBuilderArgs(resolvedTarget, parsed.arch, parsed.format, {
773
+ releaseMode: parsed.releaseMode,
774
+ publish: parsed.publish,
775
+ signWindowsExecutable: hasWindowsSigningCredentials(process.env),
776
+ }),
378
777
  PROJECT_ROOT,
778
+ {},
779
+ envOptions,
379
780
  );
380
781
  }
381
782
 
@@ -5,13 +5,24 @@ import { build } from 'esbuild';
5
5
 
6
6
  const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), '..');
7
7
  const testsDir = join(rootDir, 'tests');
8
+
9
+ /**
10
+ * TypeScript tests are bundled here because `node --test` only collects the
11
+ * plain `.js` files under `tests/`. Both extensions must be picked up: a
12
+ * `.test.ts` that is not bundled is not merely unchecked, it never runs at all,
13
+ * and a test that never runs looks exactly like a passing one.
14
+ */
15
+ const TEST_ENTRY_EXTENSIONS = ['.test.ts', '.test.tsx'];
16
+
8
17
  const entryPoints = (await readdir(testsDir))
9
- .filter((fileName) => fileName.endsWith('.test.tsx'))
18
+ .filter((fileName) => TEST_ENTRY_EXTENSIONS.some((ext) => fileName.endsWith(ext)))
10
19
  .sort()
11
20
  .map((fileName) => join(testsDir, fileName));
12
21
 
13
22
  if (entryPoints.length === 0) {
14
- throw new Error('No test UI entry points found in tests/*.test.tsx');
23
+ throw new Error(
24
+ `No test entry points found in tests/*{${TEST_ENTRY_EXTENSIONS.join(',')}}`,
25
+ );
15
26
  }
16
27
 
17
28
  await build({