@chatcut/cli 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -2
- package/bin/chatcut.mjs +4 -0
- package/package.json +25 -4
- package/src/cli.mjs +110 -0
- package/src/desktop.mjs +947 -0
- package/src/doctor.mjs +338 -0
- package/src/rpc.mjs +360 -0
package/src/desktop.mjs
ADDED
|
@@ -0,0 +1,947 @@
|
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
const BUNDLE_ID = "io.chatcut.desktop";
|
|
10
|
+
const MAC_REQUIREMENT = `identifier "${BUNDLE_ID}" and anchor apple generic and certificate leaf[subject.OU] = "7WK2VURFPK"`;
|
|
11
|
+
const PUBLISHER = "ChatCut Inc.";
|
|
12
|
+
const DOWNLOAD_ROOT = "https://api.chatcut.io/desktop/download/";
|
|
13
|
+
const MAX_DOWNLOAD_BYTES = 4 * 1024 ** 3;
|
|
14
|
+
const DOWNLOAD_TIMEOUT = 15 * 60_000;
|
|
15
|
+
|
|
16
|
+
export class DesktopError extends Error {
|
|
17
|
+
constructor(code, stage, message, { cause, ...details } = {}) {
|
|
18
|
+
super(message, cause ? { cause } : undefined);
|
|
19
|
+
this.name = "DesktopError";
|
|
20
|
+
this.code = code;
|
|
21
|
+
this.stage = stage;
|
|
22
|
+
this.details = details;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
toJSON() {
|
|
26
|
+
return {
|
|
27
|
+
code: this.code,
|
|
28
|
+
message: this.message,
|
|
29
|
+
stage: this.stage,
|
|
30
|
+
...this.details,
|
|
31
|
+
...(this.cause ? { cause: describeCause(this.cause) } : {}),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Install the signed production Desktop only if absent, then open that app. */
|
|
37
|
+
export async function ensureDesktop(options = {}) {
|
|
38
|
+
const ctx = context(options);
|
|
39
|
+
let installed = await inspect(ctx);
|
|
40
|
+
let action = "launched";
|
|
41
|
+
let warnings;
|
|
42
|
+
if (!installed.installed) {
|
|
43
|
+
const result = await install(ctx, installed, false);
|
|
44
|
+
installed = result.desktop;
|
|
45
|
+
warnings = result.warnings;
|
|
46
|
+
action = "installed";
|
|
47
|
+
}
|
|
48
|
+
progress(ctx, "launch", "Opening ChatCut Desktop");
|
|
49
|
+
try {
|
|
50
|
+
if (ctx.platform === "darwin") {
|
|
51
|
+
await native(ctx, "launch", "/usr/bin/open", ["-a", installed.appPath]);
|
|
52
|
+
} else {
|
|
53
|
+
await native(ctx, "launch", installed.appPath, [], {
|
|
54
|
+
waitForExit: false,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
} catch (cause) {
|
|
58
|
+
throw new DesktopError(
|
|
59
|
+
"launch_failed",
|
|
60
|
+
"launch",
|
|
61
|
+
"ChatCut is installed but could not be opened. Open it from Applications or the Start menu; use chatcut doctor for connection checks.",
|
|
62
|
+
{ appPath: installed.appPath, cause },
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
return { ...installed, action, ...(warnings?.length ? { warnings } : {}) };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Native boundaries are injectable for fixtures: platform, arch, release, home,
|
|
70
|
+
* env, tmpdir, fs (node:fs/promises compatible), run(command, args, options), and
|
|
71
|
+
* download(url, destination). Overrides are a programmatic test seam, never CLI
|
|
72
|
+
* flags. run resolves { stdout, stderr }; waitForExit:false only waits for spawn.
|
|
73
|
+
* onProgress receives { stage, message }. Inspection never launches or installs.
|
|
74
|
+
*/
|
|
75
|
+
export async function inspectDesktop(options = {}) {
|
|
76
|
+
return inspect(context(options));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Canonical production profile paths, also available before Desktop is installed. */
|
|
80
|
+
export function resolveDesktopPaths(options = {}) {
|
|
81
|
+
const ctx = context(options);
|
|
82
|
+
return profilePaths(ctx);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Explicit update; never terminates a running app or opens it after updating. */
|
|
86
|
+
export async function updateDesktop(options = {}) {
|
|
87
|
+
const ctx = context(options);
|
|
88
|
+
const installed = await inspect(ctx);
|
|
89
|
+
if (installed.installed) await requireClosed(ctx, installed.appPath);
|
|
90
|
+
if (ctx.platform === "win32" && installed.scope === "machine") {
|
|
91
|
+
throw new DesktopError(
|
|
92
|
+
"app_updater_required",
|
|
93
|
+
"update",
|
|
94
|
+
"This ChatCut installation is shared by all users. Update it from ChatCut's app updater or run the official installer interactively.",
|
|
95
|
+
{ appPath: installed.appPath },
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
const result = await install(ctx, installed, true);
|
|
99
|
+
return {
|
|
100
|
+
...result.desktop,
|
|
101
|
+
action: result.unchanged
|
|
102
|
+
? "up_to_date"
|
|
103
|
+
: installed.installed
|
|
104
|
+
? "updated"
|
|
105
|
+
: "installed",
|
|
106
|
+
...(result.warnings.length ? { warnings: result.warnings } : {}),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function asDesktopError(error, stage) {
|
|
111
|
+
return error instanceof DesktopError
|
|
112
|
+
? error
|
|
113
|
+
: new DesktopError(
|
|
114
|
+
"install_failed",
|
|
115
|
+
stage,
|
|
116
|
+
"Desktop installation failed. Your projects and application data were not changed.",
|
|
117
|
+
{ cause: error },
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function context(options) {
|
|
122
|
+
const ctx = {
|
|
123
|
+
arch: options.arch ?? process.arch,
|
|
124
|
+
download: options.download ?? downloadOfficial,
|
|
125
|
+
env: options.env ?? process.env,
|
|
126
|
+
fs: options.fs ?? fs,
|
|
127
|
+
home: options.home ?? os.homedir(),
|
|
128
|
+
onProgress: options.onProgress,
|
|
129
|
+
platform: options.platform ?? process.platform,
|
|
130
|
+
release: options.release ?? os.release(),
|
|
131
|
+
run: options.run ?? runNative,
|
|
132
|
+
tmpdir: options.tmpdir ?? os.tmpdir(),
|
|
133
|
+
};
|
|
134
|
+
if (
|
|
135
|
+
!(
|
|
136
|
+
(ctx.platform === "darwin" && ["arm64", "x64"].includes(ctx.arch)) ||
|
|
137
|
+
(ctx.platform === "win32" &&
|
|
138
|
+
ctx.arch === "x64" &&
|
|
139
|
+
!/arm/i.test(
|
|
140
|
+
ctx.env.PROCESSOR_ARCHITEW6432 ??
|
|
141
|
+
ctx.env.PROCESSOR_ARCHITECTURE ??
|
|
142
|
+
"",
|
|
143
|
+
))
|
|
144
|
+
)
|
|
145
|
+
) {
|
|
146
|
+
throw new DesktopError(
|
|
147
|
+
"unsupported_platform",
|
|
148
|
+
"platform",
|
|
149
|
+
"ChatCut Desktop supports macOS 13+ on Apple Silicon or Intel, and Windows x64.",
|
|
150
|
+
{ arch: ctx.arch, platform: ctx.platform },
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
if (ctx.platform === "darwin" && Number.parseInt(ctx.release, 10) < 22) {
|
|
154
|
+
throw new DesktopError(
|
|
155
|
+
"unsupported_os",
|
|
156
|
+
"platform",
|
|
157
|
+
"ChatCut Desktop requires macOS 13 or newer.",
|
|
158
|
+
{ release: ctx.release },
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
ctx.paths = ctx.platform === "win32" ? path.win32 : path.posix;
|
|
162
|
+
return ctx;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function describeCause(error) {
|
|
166
|
+
const parts = [];
|
|
167
|
+
for (let depth = 0; error && depth < 5; depth++, error = error.cause)
|
|
168
|
+
parts.push(String(error.message ?? error).slice(0, 2000));
|
|
169
|
+
return parts.join(": ");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function downloadOfficial(url, destination) {
|
|
173
|
+
const controller = new AbortController();
|
|
174
|
+
const timer = setTimeout(
|
|
175
|
+
() => controller.abort(new Error("Desktop download timed out")),
|
|
176
|
+
DOWNLOAD_TIMEOUT,
|
|
177
|
+
);
|
|
178
|
+
let file;
|
|
179
|
+
let primary;
|
|
180
|
+
try {
|
|
181
|
+
let response;
|
|
182
|
+
for (let redirects = 0; redirects <= 5; redirects++) {
|
|
183
|
+
const parsed = new URL(url);
|
|
184
|
+
if (parsed.protocol !== "https:" || parsed.username || parsed.password)
|
|
185
|
+
throw new Error("Desktop downloads require HTTPS");
|
|
186
|
+
response = await fetch(parsed, {
|
|
187
|
+
redirect: "manual",
|
|
188
|
+
signal: controller.signal,
|
|
189
|
+
});
|
|
190
|
+
if (![301, 302, 303, 307, 308].includes(response.status)) break;
|
|
191
|
+
const location = response.headers.get("location");
|
|
192
|
+
await response.body?.cancel();
|
|
193
|
+
if (!location || redirects === 5)
|
|
194
|
+
throw new Error("Too many or invalid Desktop download redirects");
|
|
195
|
+
url = new URL(location, parsed).href;
|
|
196
|
+
}
|
|
197
|
+
if (!response.ok || !response.body)
|
|
198
|
+
throw new Error(`Desktop download returned HTTP ${response.status}`);
|
|
199
|
+
const length = Number(response.headers.get("content-length"));
|
|
200
|
+
if (length > MAX_DOWNLOAD_BYTES)
|
|
201
|
+
throw new Error("Desktop download exceeds the size limit");
|
|
202
|
+
file = await fs.open(destination, "wx", 0o600);
|
|
203
|
+
let bytes = 0;
|
|
204
|
+
for await (const chunk of response.body) {
|
|
205
|
+
bytes += chunk.byteLength;
|
|
206
|
+
if (bytes > MAX_DOWNLOAD_BYTES) {
|
|
207
|
+
controller.abort();
|
|
208
|
+
throw new Error("Desktop download exceeds the size limit");
|
|
209
|
+
}
|
|
210
|
+
for (let offset = 0; offset < chunk.byteLength; ) {
|
|
211
|
+
const { bytesWritten } = await file.write(
|
|
212
|
+
chunk,
|
|
213
|
+
offset,
|
|
214
|
+
chunk.byteLength - offset,
|
|
215
|
+
);
|
|
216
|
+
if (!bytesWritten)
|
|
217
|
+
throw new Error("Desktop download could not be written to disk");
|
|
218
|
+
offset += bytesWritten;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (!bytes || (length > 0 && bytes !== length))
|
|
222
|
+
throw new Error("Desktop download was incomplete");
|
|
223
|
+
} catch (error) {
|
|
224
|
+
primary = error;
|
|
225
|
+
throw error;
|
|
226
|
+
} finally {
|
|
227
|
+
clearTimeout(timer);
|
|
228
|
+
controller.abort();
|
|
229
|
+
try {
|
|
230
|
+
await file?.close();
|
|
231
|
+
} catch (error) {
|
|
232
|
+
if (!primary) throw error;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function exists(ctx, filePath) {
|
|
238
|
+
try {
|
|
239
|
+
await ctx.fs.lstat(filePath);
|
|
240
|
+
return true;
|
|
241
|
+
} catch (error) {
|
|
242
|
+
if (error.code === "ENOENT") return false;
|
|
243
|
+
throw asDesktopError(error, "discover");
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function inspect(ctx) {
|
|
248
|
+
const base = {
|
|
249
|
+
arch: ctx.arch,
|
|
250
|
+
installed: false,
|
|
251
|
+
platform: ctx.platform,
|
|
252
|
+
...profilePaths(ctx),
|
|
253
|
+
};
|
|
254
|
+
const candidates =
|
|
255
|
+
ctx.platform === "darwin"
|
|
256
|
+
? [
|
|
257
|
+
"/Applications/ChatCut.app",
|
|
258
|
+
ctx.paths.join(ctx.home, "Applications", "ChatCut.app"),
|
|
259
|
+
].map((appPath) => ({ appPath }))
|
|
260
|
+
: await windowsCandidates(ctx);
|
|
261
|
+
for (const candidate of candidates) {
|
|
262
|
+
if (!(await exists(ctx, candidate.appPath))) continue;
|
|
263
|
+
const version = await verifyApp(ctx, candidate.appPath, "verify_installed");
|
|
264
|
+
return {
|
|
265
|
+
...base,
|
|
266
|
+
...candidate,
|
|
267
|
+
installed: true,
|
|
268
|
+
resourcesSkillsDir:
|
|
269
|
+
ctx.platform === "darwin"
|
|
270
|
+
? ctx.paths.join(candidate.appPath, "Contents", "Resources", "skills")
|
|
271
|
+
: ctx.paths.join(
|
|
272
|
+
ctx.paths.dirname(candidate.appPath),
|
|
273
|
+
"resources",
|
|
274
|
+
"skills",
|
|
275
|
+
),
|
|
276
|
+
version,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
return base;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function install(ctx, previous, updating) {
|
|
283
|
+
progress(
|
|
284
|
+
ctx,
|
|
285
|
+
"download",
|
|
286
|
+
updating
|
|
287
|
+
? "Downloading the current production Desktop"
|
|
288
|
+
: "Downloading ChatCut Desktop",
|
|
289
|
+
);
|
|
290
|
+
const workspace = await ctx.fs.mkdtemp(
|
|
291
|
+
ctx.paths.join(ctx.tmpdir, "chatcut-desktop-"),
|
|
292
|
+
);
|
|
293
|
+
const artifact = ctx.paths.join(
|
|
294
|
+
workspace,
|
|
295
|
+
ctx.platform === "darwin" ? "ChatCut.dmg" : "ChatCut-Setup.exe",
|
|
296
|
+
);
|
|
297
|
+
const warnings = [];
|
|
298
|
+
let mounted = false;
|
|
299
|
+
let mountAttempted = false;
|
|
300
|
+
let mountPath;
|
|
301
|
+
let primary;
|
|
302
|
+
let result;
|
|
303
|
+
try {
|
|
304
|
+
const target =
|
|
305
|
+
ctx.platform === "darwin"
|
|
306
|
+
? ctx.arch === "arm64"
|
|
307
|
+
? "macos"
|
|
308
|
+
: "macos-x64"
|
|
309
|
+
: "windows";
|
|
310
|
+
try {
|
|
311
|
+
await ctx.download(`${DOWNLOAD_ROOT}${target}`, artifact);
|
|
312
|
+
} catch (cause) {
|
|
313
|
+
throw new DesktopError(
|
|
314
|
+
"download_failed",
|
|
315
|
+
"download",
|
|
316
|
+
"The official Desktop download failed. Retry chatcut app when the network is available.",
|
|
317
|
+
{ cause },
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
if (ctx.platform === "darwin") {
|
|
321
|
+
mountPath = ctx.paths.join(workspace, "mount");
|
|
322
|
+
await ctx.fs.mkdir(mountPath);
|
|
323
|
+
mountAttempted = true;
|
|
324
|
+
await native(
|
|
325
|
+
ctx,
|
|
326
|
+
"mount",
|
|
327
|
+
"/usr/bin/hdiutil",
|
|
328
|
+
[
|
|
329
|
+
"attach",
|
|
330
|
+
"-readonly",
|
|
331
|
+
"-nobrowse",
|
|
332
|
+
"-mountpoint",
|
|
333
|
+
mountPath,
|
|
334
|
+
artifact,
|
|
335
|
+
],
|
|
336
|
+
{ timeout: 120_000 },
|
|
337
|
+
);
|
|
338
|
+
mounted = true;
|
|
339
|
+
const source = ctx.paths.join(mountPath, "ChatCut.app");
|
|
340
|
+
const version = await verifyApp(ctx, source, "verify_download");
|
|
341
|
+
if (
|
|
342
|
+
previous.installed &&
|
|
343
|
+
versionsEqualOrNewer(previous.version, version)
|
|
344
|
+
) {
|
|
345
|
+
result = { desktop: previous, unchanged: true };
|
|
346
|
+
} else {
|
|
347
|
+
const installed = await installMac(ctx, source, previous, updating);
|
|
348
|
+
warnings.push(...installed.warnings);
|
|
349
|
+
result = {
|
|
350
|
+
desktop: installed.desktop,
|
|
351
|
+
unchanged: installed.unchanged,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
} else {
|
|
355
|
+
const version = await verifyWindows(
|
|
356
|
+
ctx,
|
|
357
|
+
artifact,
|
|
358
|
+
"verify_download",
|
|
359
|
+
true,
|
|
360
|
+
);
|
|
361
|
+
const beforeInstall = await inspect(ctx);
|
|
362
|
+
if (
|
|
363
|
+
beforeInstall.installed &&
|
|
364
|
+
(!previous.installed ||
|
|
365
|
+
beforeInstall.appPath.toLowerCase() !==
|
|
366
|
+
previous.appPath.toLowerCase())
|
|
367
|
+
)
|
|
368
|
+
throw new DesktopError(
|
|
369
|
+
"install_conflict",
|
|
370
|
+
"install",
|
|
371
|
+
"The ChatCut installation changed while the download was in progress. Retry the command to use the current app.",
|
|
372
|
+
{ appPath: beforeInstall.appPath },
|
|
373
|
+
);
|
|
374
|
+
if (
|
|
375
|
+
beforeInstall.installed &&
|
|
376
|
+
versionsEqualOrNewer(beforeInstall.version, version)
|
|
377
|
+
) {
|
|
378
|
+
result = { desktop: beforeInstall, unchanged: true };
|
|
379
|
+
} else {
|
|
380
|
+
if (beforeInstall.installed)
|
|
381
|
+
await requireClosed(ctx, beforeInstall.appPath);
|
|
382
|
+
progress(
|
|
383
|
+
ctx,
|
|
384
|
+
"install",
|
|
385
|
+
"Installing signed ChatCut Desktop for the current user",
|
|
386
|
+
);
|
|
387
|
+
await native(ctx, "install", artifact, ["/S", "/currentuser"], {
|
|
388
|
+
timeout: 10 * 60_000,
|
|
389
|
+
});
|
|
390
|
+
const desktop = await inspect(ctx);
|
|
391
|
+
if (!desktop.installed)
|
|
392
|
+
throw new DesktopError(
|
|
393
|
+
"install_failed",
|
|
394
|
+
"verify_destination",
|
|
395
|
+
"The installer completed, but a signed production ChatCut installation was not found.",
|
|
396
|
+
);
|
|
397
|
+
if (
|
|
398
|
+
previous.installed &&
|
|
399
|
+
desktop.appPath.toLowerCase() !== previous.appPath.toLowerCase()
|
|
400
|
+
)
|
|
401
|
+
throw new DesktopError(
|
|
402
|
+
"install_location_changed",
|
|
403
|
+
"verify_destination",
|
|
404
|
+
"The installer registered a different application location. Open ChatCut from the Start menu and check the installation before retrying.",
|
|
405
|
+
{ appPath: desktop.appPath, previousAppPath: previous.appPath },
|
|
406
|
+
);
|
|
407
|
+
if (!versionsEqualOrNewer(desktop.version, version))
|
|
408
|
+
throw new DesktopError(
|
|
409
|
+
"update_not_applied",
|
|
410
|
+
"verify_destination",
|
|
411
|
+
"The installer completed, but the installed app is older than the verified download. Use the updater inside ChatCut or the official installer interactively.",
|
|
412
|
+
{
|
|
413
|
+
appPath: desktop.appPath,
|
|
414
|
+
downloadedVersion: version,
|
|
415
|
+
installedVersion: desktop.version,
|
|
416
|
+
},
|
|
417
|
+
);
|
|
418
|
+
result = { desktop, unchanged: false };
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
} catch (error) {
|
|
422
|
+
primary = asDesktopError(error, "install");
|
|
423
|
+
} finally {
|
|
424
|
+
if (mountAttempted) {
|
|
425
|
+
try {
|
|
426
|
+
await native(
|
|
427
|
+
ctx,
|
|
428
|
+
"cleanup",
|
|
429
|
+
"/usr/bin/hdiutil",
|
|
430
|
+
["detach", mountPath],
|
|
431
|
+
{ timeout: 30_000 },
|
|
432
|
+
);
|
|
433
|
+
mounted = false;
|
|
434
|
+
} catch (error) {
|
|
435
|
+
// A failed attach can leave a mounted volume. Never remove its parent
|
|
436
|
+
// recursively unless detach succeeded, even when attach itself failed.
|
|
437
|
+
mounted = true;
|
|
438
|
+
warnings.push({
|
|
439
|
+
code: "mount_cleanup_failed",
|
|
440
|
+
message: error.message,
|
|
441
|
+
path: mountPath,
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
if (!mounted) {
|
|
446
|
+
try {
|
|
447
|
+
await ctx.fs.rm(workspace, { force: true, recursive: true });
|
|
448
|
+
} catch (error) {
|
|
449
|
+
warnings.push({
|
|
450
|
+
code: "cleanup_failed",
|
|
451
|
+
message: error.message,
|
|
452
|
+
path: workspace,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
if (primary) {
|
|
458
|
+
if (warnings.length)
|
|
459
|
+
primary.details.cleanupWarnings = [
|
|
460
|
+
...(primary.details.cleanupWarnings ?? []),
|
|
461
|
+
...warnings,
|
|
462
|
+
];
|
|
463
|
+
throw primary;
|
|
464
|
+
}
|
|
465
|
+
return { ...result, warnings };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
async function installMac(ctx, source, previous, updating) {
|
|
469
|
+
let destination = previous.appPath;
|
|
470
|
+
if (!destination) {
|
|
471
|
+
let parent = "/Applications";
|
|
472
|
+
try {
|
|
473
|
+
await ctx.fs.access(parent, constants.W_OK);
|
|
474
|
+
} catch {
|
|
475
|
+
parent = ctx.paths.join(ctx.home, "Applications");
|
|
476
|
+
}
|
|
477
|
+
destination = ctx.paths.join(parent, "ChatCut.app");
|
|
478
|
+
}
|
|
479
|
+
const parent = ctx.paths.dirname(destination);
|
|
480
|
+
await ctx.fs.mkdir(parent, { recursive: true });
|
|
481
|
+
// Both app renames stay on the destination filesystem. ditto receives a new
|
|
482
|
+
// absent destination, so app updates can never merge old and new bundles.
|
|
483
|
+
const stageRoot = await ctx.fs.mkdtemp(
|
|
484
|
+
ctx.paths.join(parent, ".chatcut-install-"),
|
|
485
|
+
);
|
|
486
|
+
const staged = ctx.paths.join(stageRoot, "ChatCut.app");
|
|
487
|
+
const backup = ctx.paths.join(stageRoot, "previous.app");
|
|
488
|
+
const warnings = [];
|
|
489
|
+
let movedPrevious = false;
|
|
490
|
+
let movedNew = false;
|
|
491
|
+
let keepBackup = false;
|
|
492
|
+
let primary;
|
|
493
|
+
let desktop;
|
|
494
|
+
try {
|
|
495
|
+
progress(
|
|
496
|
+
ctx,
|
|
497
|
+
"stage",
|
|
498
|
+
"Checking the signed application before installation",
|
|
499
|
+
);
|
|
500
|
+
await native(ctx, "stage", "/usr/bin/ditto", [source, staged], {
|
|
501
|
+
timeout: 5 * 60_000,
|
|
502
|
+
});
|
|
503
|
+
const stagedVersion = await verifyApp(ctx, staged, "verify_stage");
|
|
504
|
+
if (await exists(ctx, destination)) {
|
|
505
|
+
if (!updating || !previous.installed)
|
|
506
|
+
throw new DesktopError(
|
|
507
|
+
"install_conflict",
|
|
508
|
+
"activate",
|
|
509
|
+
"A ChatCut app appeared while installation was in progress. Retry chatcut app to use it.",
|
|
510
|
+
{ appPath: destination },
|
|
511
|
+
);
|
|
512
|
+
const currentVersion = await verifyApp(
|
|
513
|
+
ctx,
|
|
514
|
+
destination,
|
|
515
|
+
"verify_existing",
|
|
516
|
+
);
|
|
517
|
+
if (versionsEqualOrNewer(currentVersion, stagedVersion)) {
|
|
518
|
+
return {
|
|
519
|
+
desktop: { ...previous, version: currentVersion },
|
|
520
|
+
unchanged: true,
|
|
521
|
+
warnings,
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
await requireClosed(ctx, destination);
|
|
525
|
+
await ctx.fs.rename(destination, backup);
|
|
526
|
+
movedPrevious = true;
|
|
527
|
+
}
|
|
528
|
+
await ctx.fs.rename(staged, destination);
|
|
529
|
+
movedNew = true;
|
|
530
|
+
const version = await verifyApp(ctx, destination, "verify_destination");
|
|
531
|
+
desktop = {
|
|
532
|
+
arch: ctx.arch,
|
|
533
|
+
installed: true,
|
|
534
|
+
platform: ctx.platform,
|
|
535
|
+
...profilePaths(ctx),
|
|
536
|
+
appPath: destination,
|
|
537
|
+
resourcesSkillsDir: ctx.paths.join(
|
|
538
|
+
destination,
|
|
539
|
+
"Contents",
|
|
540
|
+
"Resources",
|
|
541
|
+
"skills",
|
|
542
|
+
),
|
|
543
|
+
version,
|
|
544
|
+
};
|
|
545
|
+
} catch (error) {
|
|
546
|
+
primary = asDesktopError(error, "activate");
|
|
547
|
+
try {
|
|
548
|
+
if (movedNew)
|
|
549
|
+
await ctx.fs.rm(destination, { force: true, recursive: true });
|
|
550
|
+
if (movedPrevious) await ctx.fs.rename(backup, destination);
|
|
551
|
+
} catch (rollbackError) {
|
|
552
|
+
keepBackup = movedPrevious;
|
|
553
|
+
primary.details.rollback = {
|
|
554
|
+
backupPath: movedPrevious ? backup : undefined,
|
|
555
|
+
message: rollbackError.message,
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
} finally {
|
|
559
|
+
if (!keepBackup) {
|
|
560
|
+
try {
|
|
561
|
+
await ctx.fs.rm(stageRoot, { force: true, recursive: true });
|
|
562
|
+
} catch (cleanupError) {
|
|
563
|
+
warnings.push({
|
|
564
|
+
code: "cleanup_failed",
|
|
565
|
+
message: cleanupError.message,
|
|
566
|
+
path: stageRoot,
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
if (primary) {
|
|
572
|
+
if (warnings.length)
|
|
573
|
+
primary.details.cleanupWarnings = [
|
|
574
|
+
...(primary.details.cleanupWarnings ?? []),
|
|
575
|
+
...warnings,
|
|
576
|
+
];
|
|
577
|
+
throw primary;
|
|
578
|
+
}
|
|
579
|
+
return { desktop, unchanged: false, warnings };
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
async function native(ctx, stage, command, args, options = {}) {
|
|
583
|
+
const env = { ...ctx.env };
|
|
584
|
+
if (options.waitForExit === false) {
|
|
585
|
+
delete env.ELECTRON_RUN_AS_NODE;
|
|
586
|
+
delete env.ELECTRON_NO_ATTACH_CONSOLE;
|
|
587
|
+
}
|
|
588
|
+
try {
|
|
589
|
+
return await ctx.run(command, args, {
|
|
590
|
+
env,
|
|
591
|
+
maxBuffer: 1024 * 1024,
|
|
592
|
+
timeout: 30_000,
|
|
593
|
+
...options,
|
|
594
|
+
});
|
|
595
|
+
} catch (cause) {
|
|
596
|
+
throw new DesktopError(
|
|
597
|
+
"native_command_failed",
|
|
598
|
+
stage,
|
|
599
|
+
`Desktop ${stage.replaceAll("_", " ")} failed.`,
|
|
600
|
+
{
|
|
601
|
+
cause,
|
|
602
|
+
command,
|
|
603
|
+
...(typeof cause.stderr === "string"
|
|
604
|
+
? { stderr: cause.stderr.slice(0, 4000) }
|
|
605
|
+
: {}),
|
|
606
|
+
...(cause.code !== undefined ? { exitCode: cause.code } : {}),
|
|
607
|
+
},
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
async function powershellJson(ctx, stage, script) {
|
|
613
|
+
const executable = ctx.paths.join(
|
|
614
|
+
ctx.env.SystemRoot || ctx.env.WINDIR || "C:\\Windows",
|
|
615
|
+
"System32",
|
|
616
|
+
"WindowsPowerShell",
|
|
617
|
+
"v1.0",
|
|
618
|
+
"powershell.exe",
|
|
619
|
+
);
|
|
620
|
+
const { stdout } = await native(ctx, stage, executable, [
|
|
621
|
+
"-NoProfile",
|
|
622
|
+
"-NonInteractive",
|
|
623
|
+
"-InputFormat",
|
|
624
|
+
"None",
|
|
625
|
+
"-Command",
|
|
626
|
+
`$ErrorActionPreference = 'Stop'; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; ${script}`,
|
|
627
|
+
]);
|
|
628
|
+
try {
|
|
629
|
+
return JSON.parse(stdout.replace(/^\uFEFF/, ""));
|
|
630
|
+
} catch (cause) {
|
|
631
|
+
throw new DesktopError(
|
|
632
|
+
"invalid_native_output",
|
|
633
|
+
stage,
|
|
634
|
+
"A Windows system check returned invalid data.",
|
|
635
|
+
{ cause },
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function profilePaths(ctx) {
|
|
641
|
+
const userDataPath =
|
|
642
|
+
ctx.platform === "darwin"
|
|
643
|
+
? ctx.paths.join(ctx.home, "Library", "Application Support", "ChatCut")
|
|
644
|
+
: ctx.paths.join(
|
|
645
|
+
ctx.env.APPDATA || ctx.paths.join(ctx.home, "AppData", "Roaming"),
|
|
646
|
+
"ChatCut",
|
|
647
|
+
);
|
|
648
|
+
return {
|
|
649
|
+
launcherPath: ctx.paths.join(
|
|
650
|
+
userDataPath,
|
|
651
|
+
ctx.platform === "darwin" ? "chatcut-mcp" : "chatcut-mcp.cmd",
|
|
652
|
+
),
|
|
653
|
+
userDataPath,
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function progress(ctx, stage, message) {
|
|
658
|
+
ctx.onProgress?.({ message, stage });
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function psQuote(value) {
|
|
662
|
+
return `'${value.replaceAll("'", "''")}'`;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
async function requireClosed(ctx, appPath) {
|
|
666
|
+
let running;
|
|
667
|
+
if (ctx.platform === "darwin") {
|
|
668
|
+
const { stdout } = await native(ctx, "check_running", "/bin/ps", [
|
|
669
|
+
"-wwaxo",
|
|
670
|
+
"comm=",
|
|
671
|
+
]);
|
|
672
|
+
running = stdout
|
|
673
|
+
.split(/\r?\n/)
|
|
674
|
+
.some((line) => line.trim().startsWith(`${appPath}/Contents/`));
|
|
675
|
+
} else {
|
|
676
|
+
running = await powershellJson(
|
|
677
|
+
ctx,
|
|
678
|
+
"check_running",
|
|
679
|
+
`$target = ${psQuote(appPath)}; $processes = @(Get-CimInstance Win32_Process -Filter "Name='ChatCut.exe'"); [bool](@($processes | Where-Object { !$_.ExecutablePath -or $_.ExecutablePath -eq $target }).Count -gt 0) | ConvertTo-Json -Compress`,
|
|
680
|
+
);
|
|
681
|
+
if (typeof running !== "boolean")
|
|
682
|
+
throw new DesktopError(
|
|
683
|
+
"invalid_native_output",
|
|
684
|
+
"check_running",
|
|
685
|
+
"Could not determine whether ChatCut is running.",
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
if (running)
|
|
689
|
+
throw new DesktopError(
|
|
690
|
+
"close_app_required",
|
|
691
|
+
"check_running",
|
|
692
|
+
"ChatCut is running. Save your work and close ChatCut before retrying chatcut update, or use the updater inside the app.",
|
|
693
|
+
{ appPath },
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
async function runNative(command, args, options) {
|
|
698
|
+
const { waitForExit, ...execOptions } = options;
|
|
699
|
+
const env = { ...execOptions.env };
|
|
700
|
+
// Prevent caller-provided module search paths from replacing system cmdlets.
|
|
701
|
+
if (command.toLowerCase().endsWith("\\powershell.exe"))
|
|
702
|
+
env.PSModulePath = path.win32.join(path.win32.dirname(command), "Modules");
|
|
703
|
+
if (waitForExit === false) {
|
|
704
|
+
await new Promise((resolve, reject) => {
|
|
705
|
+
const child = spawn(command, args, {
|
|
706
|
+
detached: true,
|
|
707
|
+
env,
|
|
708
|
+
stdio: "ignore",
|
|
709
|
+
windowsHide: true,
|
|
710
|
+
});
|
|
711
|
+
child.once("error", reject);
|
|
712
|
+
child.once("spawn", () => {
|
|
713
|
+
child.unref();
|
|
714
|
+
resolve();
|
|
715
|
+
});
|
|
716
|
+
});
|
|
717
|
+
return { stderr: "", stdout: "" };
|
|
718
|
+
}
|
|
719
|
+
return execFileAsync(command, args, {
|
|
720
|
+
...execOptions,
|
|
721
|
+
encoding: "utf8",
|
|
722
|
+
env,
|
|
723
|
+
windowsHide: true,
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
async function verifyApp(ctx, appPath, stage) {
|
|
728
|
+
let version;
|
|
729
|
+
try {
|
|
730
|
+
const stat = await ctx.fs.lstat(appPath);
|
|
731
|
+
if (
|
|
732
|
+
stat.isSymbolicLink() ||
|
|
733
|
+
(ctx.platform === "darwin" ? !stat.isDirectory() : !stat.isFile())
|
|
734
|
+
)
|
|
735
|
+
throw new Error("The app path is not a regular installed application");
|
|
736
|
+
if (ctx.platform === "win32")
|
|
737
|
+
return await verifyWindows(ctx, appPath, stage, true);
|
|
738
|
+
const plist = ctx.paths.join(appPath, "Contents", "Info.plist");
|
|
739
|
+
const { stdout } = await native(ctx, stage, "/usr/bin/plutil", [
|
|
740
|
+
"-convert",
|
|
741
|
+
"json",
|
|
742
|
+
"-o",
|
|
743
|
+
"-",
|
|
744
|
+
plist,
|
|
745
|
+
]);
|
|
746
|
+
const info = JSON.parse(stdout);
|
|
747
|
+
if (
|
|
748
|
+
info.CFBundleIdentifier !== BUNDLE_ID ||
|
|
749
|
+
info.CFBundleExecutable !== "ChatCut"
|
|
750
|
+
)
|
|
751
|
+
throw new Error("The bundle is not the production ChatCut application");
|
|
752
|
+
await native(ctx, stage, "/usr/bin/codesign", [
|
|
753
|
+
"--verify",
|
|
754
|
+
"--deep",
|
|
755
|
+
"--strict",
|
|
756
|
+
"--verbose=2",
|
|
757
|
+
"-R",
|
|
758
|
+
`=${MAC_REQUIREMENT}`,
|
|
759
|
+
appPath,
|
|
760
|
+
]);
|
|
761
|
+
version = info.CFBundleShortVersionString || info.CFBundleVersion || null;
|
|
762
|
+
} catch (cause) {
|
|
763
|
+
if (ctx.platform === "win32" && cause instanceof DesktopError) throw cause;
|
|
764
|
+
throw new DesktopError(
|
|
765
|
+
"signature_invalid",
|
|
766
|
+
stage,
|
|
767
|
+
"ChatCut's production identity or trusted signature could not be verified. The existing app has been left in place; install the official signed Desktop from chatcut.io/download.",
|
|
768
|
+
{ ...cause.details, appPath, cause },
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
const assessmentStage = stage.replace(/^verify_/, "assess_");
|
|
772
|
+
try {
|
|
773
|
+
await native(ctx, assessmentStage, "/usr/sbin/spctl", [
|
|
774
|
+
"--assess",
|
|
775
|
+
"--type",
|
|
776
|
+
"execute",
|
|
777
|
+
"--verbose=2",
|
|
778
|
+
appPath,
|
|
779
|
+
]);
|
|
780
|
+
} catch (cause) {
|
|
781
|
+
throw new DesktopError(
|
|
782
|
+
"gatekeeper_assessment_failed",
|
|
783
|
+
assessmentStage,
|
|
784
|
+
"ChatCut's signature is valid, but macOS Gatekeeper could not approve this app. Check the system assessment details before retrying; the app has not been opened.",
|
|
785
|
+
{ ...cause.details, appPath, cause },
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
return version;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
async function verifyWindows(ctx, filePath, stage, requireProduct) {
|
|
792
|
+
const result = await powershellJson(
|
|
793
|
+
ctx,
|
|
794
|
+
stage,
|
|
795
|
+
[
|
|
796
|
+
`$item = Get-Item -LiteralPath ${psQuote(filePath)}`,
|
|
797
|
+
"$signature = Get-AuthenticodeSignature -LiteralPath $item.FullName",
|
|
798
|
+
"[PSCustomObject]@{ Status=[int]$signature.Status; StatusMessage=$signature.StatusMessage; Subject=$signature.SignerCertificate.Subject; Path=$signature.Path; ProductName=$item.VersionInfo.ProductName; Version=$item.VersionInfo.ProductVersion } | ConvertTo-Json -Compress",
|
|
799
|
+
].join("; "),
|
|
800
|
+
);
|
|
801
|
+
const textField = (value) =>
|
|
802
|
+
typeof value === "string" ? value.slice(0, 2048) : null;
|
|
803
|
+
const observed = {
|
|
804
|
+
productName: textField(result?.ProductName),
|
|
805
|
+
signaturePath: textField(result?.Path),
|
|
806
|
+
status: typeof result?.Status === "number" ? result.Status : null,
|
|
807
|
+
statusMessage: textField(result?.StatusMessage),
|
|
808
|
+
subject: textField(result?.Subject),
|
|
809
|
+
version: textField(result?.Version),
|
|
810
|
+
};
|
|
811
|
+
const cn =
|
|
812
|
+
typeof result?.Subject === "string"
|
|
813
|
+
? /(?:^|,\s*)CN=("(?:[^"]|"")*"|[^,]*)/i
|
|
814
|
+
.exec(result.Subject)?.[1]
|
|
815
|
+
?.replace(/^"|"$/g, "")
|
|
816
|
+
.replaceAll('""', '"')
|
|
817
|
+
: undefined;
|
|
818
|
+
// The native command binds verification to Get-Item -LiteralPath above.
|
|
819
|
+
// Signature.Path is display metadata: Windows may expand an 8.3 path alias.
|
|
820
|
+
if (result?.Status !== 0) {
|
|
821
|
+
throw new DesktopError(
|
|
822
|
+
"signature_invalid",
|
|
823
|
+
stage,
|
|
824
|
+
"Windows could not verify the file's Authenticode signature as trusted.",
|
|
825
|
+
{ appPath: filePath, observed },
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
if (cn !== PUBLISHER)
|
|
829
|
+
throw new DesktopError(
|
|
830
|
+
"publisher_mismatch",
|
|
831
|
+
stage,
|
|
832
|
+
"The Windows file's signed publisher does not match ChatCut Inc.",
|
|
833
|
+
{ appPath: filePath, expectedPublisher: PUBLISHER, observed },
|
|
834
|
+
);
|
|
835
|
+
if (requireProduct && result.ProductName !== "ChatCut")
|
|
836
|
+
throw new DesktopError(
|
|
837
|
+
"product_mismatch",
|
|
838
|
+
stage,
|
|
839
|
+
"The Windows file's signed product is not the production ChatCut application.",
|
|
840
|
+
{ appPath: filePath, expectedProductName: "ChatCut", observed },
|
|
841
|
+
);
|
|
842
|
+
return observed.version;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function versionsEqualOrNewer(installed, downloaded) {
|
|
846
|
+
if (installed === downloaded && installed) return true;
|
|
847
|
+
const pattern =
|
|
848
|
+
/^(\d+(?:\.\d+){1,3})(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
849
|
+
const installedMatch = pattern.exec(installed ?? "");
|
|
850
|
+
const downloadedMatch = pattern.exec(downloaded ?? "");
|
|
851
|
+
if (!installedMatch || !downloadedMatch)
|
|
852
|
+
throw new DesktopError(
|
|
853
|
+
"version_unreadable",
|
|
854
|
+
"compare_versions",
|
|
855
|
+
"The installed and downloaded versions could not be compared safely. Use the updater inside ChatCut or the official installer interactively.",
|
|
856
|
+
{ downloadedVersion: downloaded, installedVersion: installed },
|
|
857
|
+
);
|
|
858
|
+
const left = installedMatch[1].split(".").map(Number);
|
|
859
|
+
const right = downloadedMatch[1].split(".").map(Number);
|
|
860
|
+
for (let i = 0; i < Math.max(left.length, right.length); i++) {
|
|
861
|
+
if ((left[i] ?? 0) !== (right[i] ?? 0))
|
|
862
|
+
return (left[i] ?? 0) > (right[i] ?? 0);
|
|
863
|
+
}
|
|
864
|
+
const leftPrerelease = installedMatch[2]?.split(".");
|
|
865
|
+
const rightPrerelease = downloadedMatch[2]?.split(".");
|
|
866
|
+
if (!leftPrerelease || !rightPrerelease) return !leftPrerelease;
|
|
867
|
+
for (
|
|
868
|
+
let i = 0;
|
|
869
|
+
i < Math.max(leftPrerelease.length, rightPrerelease.length);
|
|
870
|
+
i++
|
|
871
|
+
) {
|
|
872
|
+
const a = leftPrerelease[i];
|
|
873
|
+
const b = rightPrerelease[i];
|
|
874
|
+
if (a === b) continue;
|
|
875
|
+
if (a === undefined || b === undefined) return b === undefined;
|
|
876
|
+
if (/^\d+$/.test(a) && /^\d+$/.test(b)) return Number(a) > Number(b);
|
|
877
|
+
if (/^\d+$/.test(a) !== /^\d+$/.test(b)) return !/^\d+$/.test(a);
|
|
878
|
+
return a > b;
|
|
879
|
+
}
|
|
880
|
+
return true;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
async function windowsCandidates(ctx) {
|
|
884
|
+
const programFiles = ctx.env.ProgramFiles || "C:\\Program Files";
|
|
885
|
+
const local =
|
|
886
|
+
ctx.env.LOCALAPPDATA || ctx.paths.join(ctx.home, "AppData", "Local");
|
|
887
|
+
const script = [
|
|
888
|
+
"$items = @()",
|
|
889
|
+
"foreach ($entry in @(@{Root='HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall';Scope='user'}, @{Root='HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall';Scope='machine'}, @{Root='HKLM:\\Software\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall';Scope='machine'})) { $items += @(Get-ItemProperty ($entry.Root + '\\*') -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName -eq 'ChatCut' } | ForEach-Object { [PSCustomObject]@{ InstallLocation=$_.InstallLocation; DisplayIcon=$_.DisplayIcon; Scope=$entry.Scope } }) }",
|
|
890
|
+
"ConvertTo-Json -InputObject @($items) -Compress",
|
|
891
|
+
].join("; ");
|
|
892
|
+
const records = await powershellJson(ctx, "discover", script);
|
|
893
|
+
if (!Array.isArray(records))
|
|
894
|
+
throw new DesktopError(
|
|
895
|
+
"invalid_native_output",
|
|
896
|
+
"discover",
|
|
897
|
+
"Windows installation discovery returned invalid data.",
|
|
898
|
+
);
|
|
899
|
+
const candidates = [];
|
|
900
|
+
for (const record of records) {
|
|
901
|
+
const icon =
|
|
902
|
+
typeof record.DisplayIcon === "string"
|
|
903
|
+
? record.DisplayIcon.replace(/,\s*-?\d+$/, "").replace(/^"|"$/g, "")
|
|
904
|
+
: "";
|
|
905
|
+
const appPath =
|
|
906
|
+
typeof record.InstallLocation === "string" && record.InstallLocation
|
|
907
|
+
? ctx.paths.join(record.InstallLocation, "ChatCut.exe")
|
|
908
|
+
: icon;
|
|
909
|
+
if (
|
|
910
|
+
typeof appPath === "string" &&
|
|
911
|
+
ctx.paths.isAbsolute(appPath) &&
|
|
912
|
+
ctx.paths.basename(appPath).toLowerCase() === "chatcut.exe"
|
|
913
|
+
) {
|
|
914
|
+
candidates.push({
|
|
915
|
+
appPath,
|
|
916
|
+
scope: record.Scope === "machine" ? "machine" : "user",
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
candidates.push(
|
|
921
|
+
{
|
|
922
|
+
appPath: ctx.paths.join(local, "Programs", "ChatCut", "ChatCut.exe"),
|
|
923
|
+
scope: "user",
|
|
924
|
+
},
|
|
925
|
+
{
|
|
926
|
+
appPath: ctx.paths.join(
|
|
927
|
+
local,
|
|
928
|
+
"Programs",
|
|
929
|
+
"chatcut-desktop",
|
|
930
|
+
"ChatCut.exe",
|
|
931
|
+
),
|
|
932
|
+
scope: "user",
|
|
933
|
+
},
|
|
934
|
+
{
|
|
935
|
+
appPath: ctx.paths.join(programFiles, "ChatCut", "ChatCut.exe"),
|
|
936
|
+
scope: "machine",
|
|
937
|
+
},
|
|
938
|
+
);
|
|
939
|
+
return [
|
|
940
|
+
...new Map(
|
|
941
|
+
candidates.map((candidate) => [
|
|
942
|
+
candidate.appPath.toLowerCase(),
|
|
943
|
+
candidate,
|
|
944
|
+
]),
|
|
945
|
+
).values(),
|
|
946
|
+
];
|
|
947
|
+
}
|