cartethyia 2.4.1 → 2.4.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.
- package/dist/dashboard/assets/{BannedPage-hGt6sYfC.js → BannedPage-CFWM4WEi.js} +1 -1
- package/dist/dashboard/assets/{CliToolDetailPage-C-v5GoF-.js → CliToolDetailPage-DSGFhzfL.js} +1 -1
- package/dist/dashboard/assets/{CliToolsPage-Da2XCM9A.js → CliToolsPage-tsIxtVpX.js} +1 -1
- package/dist/dashboard/assets/{ConsoleLogPage-xT-LP8xZ.js → ConsoleLogPage-BY7Y_0z8.js} +1 -1
- package/dist/dashboard/assets/{CustomizationPage-D0PQsW3f.js → CustomizationPage-DlyXzyIf.js} +1 -1
- package/dist/dashboard/assets/{HealthEventsModal-DTiQ4ulO.js → HealthEventsModal-KG3K2C5M.js} +1 -1
- package/dist/dashboard/assets/{LoginPage-jMx71m_m.js → LoginPage-B4T7XQcx.js} +1 -1
- package/dist/dashboard/assets/{OverviewPage-BQpanEmj.js → OverviewPage-BeVW5HiF.js} +1 -1
- package/dist/dashboard/assets/{ProviderDetailPage-DMh9bfu0.js → ProviderDetailPage-D8CTrTMh.js} +1 -1
- package/dist/dashboard/assets/{ProvidersPage-Bfg0qSLE.js → ProvidersPage-DSSnMPdU.js} +1 -1
- package/dist/dashboard/assets/{ProxyPage-CSSN-6vg.js → ProxyPage-DjXFNVqh.js} +1 -1
- package/dist/dashboard/assets/{QuotaPage-BPksahOs.js → QuotaPage-ZRiPzRxI.js} +1 -1
- package/dist/dashboard/assets/{SetupPage-DxKaH8LS.js → SetupPage-4v0f70wB.js} +1 -1
- package/dist/dashboard/assets/{StudioPage-B9WEa99R.js → StudioPage-DQ3V_uhU.js} +1 -1
- package/dist/dashboard/assets/{UsagePage-BOSJhVq0.js → UsagePage-DA2RUnXI.js} +1 -1
- package/dist/dashboard/assets/{entry-C1Dn2nQu.js → entry-8jVcj7OO.js} +3 -3
- package/dist/dashboard/assets/{index-TZNbg9dv.js → index-gJaMvav9.js} +2 -2
- package/dist/dashboard/index.html +1 -1
- package/package.json +1 -1
- package/scripts/cli/cli.ts +97 -305
- package/scripts/cli/tray.ps1 +98 -0
- package/scripts/cli/tray.ts +213 -0
package/scripts/cli/cli.ts
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { spawn, type ChildProcess } from "node:child_process";
|
|
1
|
+
import { existsSync, mkdirSync, openSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
4
3
|
import { tmpdir } from "node:os";
|
|
5
4
|
import { join } from "node:path";
|
|
6
|
-
import SysTray from "systray2";
|
|
7
5
|
import { isNewerRelease } from "../../src/console/updates/version-compare";
|
|
8
6
|
import { PACKAGE_ROOT, resolvePackagePath, resolveUserConfigDir } from "../../src/config/package-paths";
|
|
9
7
|
import { resolvePort } from "../../src/config/runtime";
|
|
10
8
|
import { MINIMUM_BUN_VERSION, bunVersionAtLeast } from "../shared/env";
|
|
11
9
|
import { chooseFromMenu, confirmByTyping, confirmStep } from "./menu";
|
|
12
10
|
import { ensureDrainToken, readPersistedDrainToken } from "./runtime-state";
|
|
11
|
+
import { createTray } from "./tray";
|
|
13
12
|
import { npmUpdate, readInstalledVersion, readLatestPublishedVersion } from "./version";
|
|
14
13
|
import { renderWordmark } from "./wordmark";
|
|
15
14
|
|
|
@@ -69,36 +68,6 @@ function reportTrayStartup(signal: TrayStartupSignal): void {
|
|
|
69
68
|
}
|
|
70
69
|
}
|
|
71
70
|
|
|
72
|
-
async function waitForTrayStartupSignal(
|
|
73
|
-
signalPath: string,
|
|
74
|
-
child: ChildProcess,
|
|
75
|
-
timeoutMs: number,
|
|
76
|
-
): Promise<string> {
|
|
77
|
-
let spawnError: Error | undefined;
|
|
78
|
-
child.once("error", (error) => {
|
|
79
|
-
spawnError = error;
|
|
80
|
-
});
|
|
81
|
-
const deadline = Date.now() + timeoutMs;
|
|
82
|
-
while (Date.now() < deadline) {
|
|
83
|
-
if (existsSync(signalPath)) {
|
|
84
|
-
try {
|
|
85
|
-
const parsed: unknown = JSON.parse(readFileSync(signalPath, "utf8"));
|
|
86
|
-
if (parsed !== null && typeof parsed === "object" && "status" in parsed) {
|
|
87
|
-
if (parsed.status === "ready" && "url" in parsed && typeof parsed.url === "string") return parsed.url;
|
|
88
|
-
if (parsed.status === "failed" && "message" in parsed && typeof parsed.message === "string") {
|
|
89
|
-
throw new Error(parsed.message);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
} catch (error) {
|
|
93
|
-
if (!(error instanceof SyntaxError)) throw error;
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
if (spawnError) throw spawnError;
|
|
97
|
-
if (child.exitCode !== null) throw new Error(`updated Cartethyia exited during startup (exit code ${child.exitCode})`);
|
|
98
|
-
await Bun.sleep(150);
|
|
99
|
-
}
|
|
100
|
-
throw new Error("Cartethyia did not finish tray and gateway startup within 45 seconds");
|
|
101
|
-
}
|
|
102
71
|
|
|
103
72
|
|
|
104
73
|
function openUrl(url: string): Promise<void> {
|
|
@@ -116,16 +85,6 @@ function openUrl(url: string): Promise<void> {
|
|
|
116
85
|
}
|
|
117
86
|
|
|
118
87
|
|
|
119
|
-
function launchUpdatedTray(signalPath: string): ChildProcess {
|
|
120
|
-
const env = { ...process.env, CARTETHYIA_TRAY_STARTUP_SIGNAL: signalPath };
|
|
121
|
-
const child = spawn(
|
|
122
|
-
process.execPath,
|
|
123
|
-
["run", resolvePackagePath("scripts", "cli", "cli.ts"), "tray"],
|
|
124
|
-
{ cwd: tmpdir(), detached: true, stdio: "ignore", windowsHide: true, env },
|
|
125
|
-
);
|
|
126
|
-
child.unref();
|
|
127
|
-
return child;
|
|
128
|
-
}
|
|
129
88
|
|
|
130
89
|
async function startGateway(
|
|
131
90
|
port: number,
|
|
@@ -259,42 +218,7 @@ async function stopGateway(
|
|
|
259
218
|
}
|
|
260
219
|
}
|
|
261
220
|
|
|
262
|
-
async function waitForTray(tray: SysTray): Promise<void> {
|
|
263
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
264
|
-
try {
|
|
265
|
-
await Promise.race([
|
|
266
|
-
tray.ready(),
|
|
267
|
-
new Promise<never>((_, reject) => {
|
|
268
|
-
timer = setTimeout(() => reject(new Error("System tray startup timed out")), 15_000);
|
|
269
|
-
}),
|
|
270
|
-
]);
|
|
271
|
-
} finally {
|
|
272
|
-
if (timer !== undefined) clearTimeout(timer);
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
221
|
|
|
276
|
-
async function stopTray(tray: SysTray, ready: boolean, exited: boolean): Promise<void> {
|
|
277
|
-
if (exited) return;
|
|
278
|
-
if (!ready) {
|
|
279
|
-
tray.process.kill();
|
|
280
|
-
return;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
284
|
-
try {
|
|
285
|
-
await Promise.race([
|
|
286
|
-
tray.kill(false),
|
|
287
|
-
new Promise<void>((resolveExit) => {
|
|
288
|
-
timer = setTimeout(() => resolveExit(), 5_000);
|
|
289
|
-
}),
|
|
290
|
-
]);
|
|
291
|
-
} catch {
|
|
292
|
-
// Fall through to a direct child termination below.
|
|
293
|
-
} finally {
|
|
294
|
-
if (timer !== undefined) clearTimeout(timer);
|
|
295
|
-
}
|
|
296
|
-
if (!tray.killed) tray.process.kill();
|
|
297
|
-
}
|
|
298
222
|
|
|
299
223
|
/**
|
|
300
224
|
* The default `carte` experience: start the gateway, then offer an arrow-key
|
|
@@ -437,18 +361,13 @@ async function startMenu(): Promise<void> {
|
|
|
437
361
|
latestVersion = await readLatestPublishedVersion();
|
|
438
362
|
},
|
|
439
363
|
async () => {
|
|
440
|
-
//
|
|
441
|
-
//
|
|
442
|
-
//
|
|
443
|
-
//
|
|
444
|
-
//
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
handedOff = true;
|
|
448
|
-
console.log("Handing the gateway to the tray…");
|
|
449
|
-
await shutDown();
|
|
450
|
-
const replacement = await startGateway(port, drainToken, { detached: true });
|
|
451
|
-
replacement.unref();
|
|
364
|
+
// Start a detached `tray` process that owns the gateway from scratch,
|
|
365
|
+
// then drain this one so the new process can claim the port. This is
|
|
366
|
+
// the pattern 9router uses and it is simpler than handing a live
|
|
367
|
+
// gateway between processes: the tray is a normal first-class run, not
|
|
368
|
+
// an adopted orphan, so Exit and update behave the same whether the
|
|
369
|
+
// gateway was started here or from `carte --tray`.
|
|
370
|
+
console.log("Starting the tray in the background…");
|
|
452
371
|
const tray = spawn(
|
|
453
372
|
process.execPath,
|
|
454
373
|
["run", resolvePackagePath("scripts", "cli", "cli.ts"), "tray"],
|
|
@@ -457,12 +376,16 @@ async function startMenu(): Promise<void> {
|
|
|
457
376
|
detached: true,
|
|
458
377
|
stdio: "ignore",
|
|
459
378
|
windowsHide: true,
|
|
460
|
-
env:
|
|
379
|
+
env: process.env,
|
|
461
380
|
},
|
|
462
381
|
);
|
|
463
382
|
tray.unref();
|
|
464
|
-
|
|
465
|
-
|
|
383
|
+
running = false;
|
|
384
|
+
stopping = true;
|
|
385
|
+
handedOff = true;
|
|
386
|
+
await shutDown();
|
|
387
|
+
console.log(`Cartethyia is running in the background at ${dashboardUrl}.`);
|
|
388
|
+
console.log("The tray icon owns it. Stop it with: cartethyia stop");
|
|
466
389
|
},
|
|
467
390
|
async () => {
|
|
468
391
|
running = false;
|
|
@@ -621,262 +544,130 @@ async function stopRunningGateway(): Promise<number> {
|
|
|
621
544
|
}
|
|
622
545
|
|
|
623
546
|
|
|
547
|
+
/**
|
|
548
|
+
* Runs the gateway with a tray icon. The tray is platform-specific (`tray.ts`):
|
|
549
|
+
* on Windows it is a PowerShell NotifyIcon, because a Node tray library leaves
|
|
550
|
+
* an empty console tab open for the tray's whole lifetime, and that window is
|
|
551
|
+
* owned by Windows Terminal — not by this process, so it cannot be found and
|
|
552
|
+
* hidden from inside.
|
|
553
|
+
*/
|
|
624
554
|
async function startTray(): Promise<void> {
|
|
625
555
|
const port = resolvePort();
|
|
626
556
|
const drainToken = ensureDrainToken();
|
|
627
|
-
const currentVersion = await readInstalledVersion();
|
|
628
557
|
const dashboardUrl = `http://127.0.0.1:${port}/console`;
|
|
629
|
-
const
|
|
630
|
-
|
|
631
|
-
process.platform === "win32" ? "cartethyia.ico" : "cartethyia.png",
|
|
632
|
-
);
|
|
633
|
-
const repository = {
|
|
634
|
-
title: "Repository: risunCode/cartethyia",
|
|
635
|
-
tooltip: "Open the Cartethyia GitHub repository",
|
|
636
|
-
enabled: true,
|
|
637
|
-
};
|
|
638
|
-
const update = {
|
|
639
|
-
title: "Checking for updates…",
|
|
640
|
-
tooltip: "Update Cartethyia and restart the tray process",
|
|
641
|
-
enabled: false,
|
|
642
|
-
};
|
|
643
|
-
const openConsole = {
|
|
644
|
-
title: `Open Console · ${dashboardUrl}`,
|
|
645
|
-
tooltip: `Open the local Console at ${dashboardUrl}`,
|
|
646
|
-
enabled: true,
|
|
647
|
-
};
|
|
648
|
-
const resetPassword = {
|
|
649
|
-
title: "Reset console password",
|
|
650
|
-
tooltip: "Delete the console account so /console asks for a new one",
|
|
651
|
-
enabled: true,
|
|
652
|
-
};
|
|
653
|
-
const backupRestore = {
|
|
654
|
-
title: "Backup",
|
|
655
|
-
tooltip: "Write a gzipped config backup to your download directory",
|
|
656
|
-
enabled: true,
|
|
657
|
-
};
|
|
658
|
-
const exit = {
|
|
659
|
-
title: "Exit",
|
|
660
|
-
tooltip: "Stop Cartethyia gracefully and close the tray",
|
|
661
|
-
enabled: true,
|
|
662
|
-
};
|
|
663
|
-
const tray = new SysTray({
|
|
664
|
-
menu: {
|
|
665
|
-
icon,
|
|
666
|
-
title: "Cartethyia",
|
|
667
|
-
tooltip: "Cartethyia AI gateway",
|
|
668
|
-
items: [
|
|
669
|
-
repository,
|
|
670
|
-
update,
|
|
671
|
-
SysTray.separator,
|
|
672
|
-
openConsole,
|
|
673
|
-
resetPassword,
|
|
674
|
-
backupRestore,
|
|
675
|
-
SysTray.separator,
|
|
676
|
-
exit,
|
|
677
|
-
],
|
|
678
|
-
},
|
|
679
|
-
copyDir: true,
|
|
680
|
-
});
|
|
558
|
+
const repositoryUrl = "https://github.com/risunCode/cartethyia";
|
|
559
|
+
const currentVersion = await readInstalledVersion();
|
|
681
560
|
|
|
682
|
-
let gateway:
|
|
683
|
-
let trayReady = false;
|
|
684
|
-
let trayExited = false;
|
|
561
|
+
let gateway: Bun.Subprocess | null = null;
|
|
685
562
|
let closing = false;
|
|
686
563
|
let busy = false;
|
|
687
564
|
let resetArmed = false;
|
|
688
|
-
const adopted = process.env.CARTETHYIA_TRAY_ADOPT_RUNNING === "1";
|
|
689
|
-
let latestVersion: string | null = null;
|
|
690
565
|
let closePromise: Promise<void> | undefined;
|
|
691
566
|
|
|
567
|
+
const tray = createTray({
|
|
568
|
+
iconPath: resolvePackagePath(
|
|
569
|
+
"tray-assets",
|
|
570
|
+
process.platform === "win32" ? "cartethyia.ico" : "cartethyia.png",
|
|
571
|
+
),
|
|
572
|
+
tooltip: `Cartethyia ${currentVersion}`,
|
|
573
|
+
items: [
|
|
574
|
+
{ kind: "action", id: "repository", title: "Visit repository" },
|
|
575
|
+
{ kind: "action", id: "update", title: `Cartethyia ${currentVersion} \u00b7 checking\u2026`, enabled: false },
|
|
576
|
+
{ kind: "separator" },
|
|
577
|
+
{ kind: "action", id: "console", title: `Open Console \u00b7 ${dashboardUrl}` },
|
|
578
|
+
{ kind: "action", id: "reset", title: "Reset console password" },
|
|
579
|
+
{ kind: "action", id: "backup", title: "Backup" },
|
|
580
|
+
{ kind: "separator" },
|
|
581
|
+
{ kind: "action", id: "exit", title: "Exit" },
|
|
582
|
+
],
|
|
583
|
+
onSelect: (id: string) => void handleSelect(id),
|
|
584
|
+
onExit: () => void close(1),
|
|
585
|
+
});
|
|
586
|
+
|
|
692
587
|
const close = (exitCode = 0): Promise<void> => {
|
|
693
588
|
if (closePromise) return closePromise;
|
|
694
589
|
closing = true;
|
|
695
590
|
closePromise = (async () => {
|
|
696
|
-
const current:
|
|
591
|
+
const current: Bun.Subprocess | null = gateway;
|
|
697
592
|
gateway = null;
|
|
698
|
-
if (current)
|
|
699
|
-
|
|
700
|
-
} else if (adopted) {
|
|
701
|
-
// An adopted tray never held the gateway as a child, so there is no
|
|
702
|
-
// process to signal. Draining through the loopback endpoint is the only
|
|
703
|
-
// way Exit stops it — without this, Exit closed the icon and left the
|
|
704
|
-
// gateway listening, which looked like a phantom process.
|
|
705
|
-
await drainThroughEndpoint(port, drainToken);
|
|
706
|
-
}
|
|
707
|
-
await stopTray(tray, trayReady, trayExited);
|
|
593
|
+
if (current) await stopGateway(current, port, drainToken);
|
|
594
|
+
await tray.close();
|
|
708
595
|
process.exitCode = exitCode;
|
|
709
596
|
})();
|
|
710
597
|
return closePromise;
|
|
711
598
|
};
|
|
712
599
|
|
|
713
600
|
const refreshVersion = async (): Promise<void> => {
|
|
714
|
-
const
|
|
601
|
+
const fetched = await readLatestPublishedVersion();
|
|
715
602
|
if (closing) return;
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
update.enabled = true;
|
|
720
|
-
} else {
|
|
721
|
-
if (isNewerRelease(currentVersion, fetchedVersion)) {
|
|
722
|
-
update.title = `Cartethyia ${currentVersion} · update to ${fetchedVersion}`;
|
|
723
|
-
update.enabled = true;
|
|
724
|
-
} else {
|
|
725
|
-
update.title = `Cartethyia ${currentVersion} · up to date`;
|
|
726
|
-
update.enabled = false;
|
|
727
|
-
}
|
|
728
|
-
}
|
|
729
|
-
try {
|
|
730
|
-
await tray.sendAction({ type: "update-item", item: update });
|
|
731
|
-
} catch {
|
|
732
|
-
// The tray may be closing while the background version check completes.
|
|
603
|
+
if (fetched === null) {
|
|
604
|
+
tray.update("update", `Cartethyia ${currentVersion} \u00b7 check for updates`, true);
|
|
605
|
+
return;
|
|
733
606
|
}
|
|
607
|
+
const newer = isNewerRelease(currentVersion, fetched);
|
|
608
|
+
tray.update(
|
|
609
|
+
"update",
|
|
610
|
+
newer
|
|
611
|
+
? `Cartethyia ${currentVersion} \u00b7 update to ${fetched}`
|
|
612
|
+
: `Cartethyia ${currentVersion} \u00b7 up to date`,
|
|
613
|
+
newer,
|
|
614
|
+
);
|
|
734
615
|
};
|
|
735
616
|
|
|
736
|
-
const
|
|
617
|
+
const runUpdate = async (): Promise<void> => {
|
|
618
|
+
if (busy) return;
|
|
619
|
+
busy = true;
|
|
620
|
+
tray.update("update", "Updating Cartethyia\u2026", false);
|
|
621
|
+
const code = await npmUpdate();
|
|
622
|
+
busy = false;
|
|
623
|
+
tray.update(
|
|
624
|
+
"update",
|
|
625
|
+
code === 0 ? "Updated \u2014 restart Cartethyia" : "Update failed \u2014 retry",
|
|
626
|
+
code !== 0,
|
|
627
|
+
);
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
async function handleSelect(id: string): Promise<void> {
|
|
737
631
|
if (closing) return;
|
|
738
|
-
if (
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
if (title === openConsole.title) {
|
|
743
|
-
await openUrl(dashboardUrl);
|
|
744
|
-
return;
|
|
745
|
-
}
|
|
746
|
-
if (title === resetPassword.title) {
|
|
747
|
-
// Same recovery the menu offers: /change-password needs the current
|
|
748
|
-
// password, so deleting the account is the only way back in. A tray click
|
|
749
|
-
// cannot ask for a typed confirmation, so the first click arms the action
|
|
750
|
-
// and the second one runs it.
|
|
751
|
-
if (!resetArmed) {
|
|
752
|
-
resetArmed = true;
|
|
753
|
-
console.log(
|
|
754
|
-
"Click \"Reset console password\" again to delete the console account. This removes every operator login; providers, API keys, and routing are kept.",
|
|
755
|
-
);
|
|
756
|
-
return;
|
|
757
|
-
}
|
|
758
|
-
resetArmed = false;
|
|
759
|
-
const removed = await resetConsoleAccount(port, drainToken);
|
|
760
|
-
if (removed === 0) {
|
|
761
|
-
console.log(`Console account removed. Open ${dashboardUrl} to create a new one.`);
|
|
762
|
-
await openUrl(dashboardUrl);
|
|
763
|
-
}
|
|
764
|
-
return;
|
|
765
|
-
}
|
|
766
|
-
if (title === backupRestore.title) {
|
|
767
|
-
// Export straight to disk, like the menu does. Restore stays in the
|
|
768
|
-
// console, where an operator picks the file and confirms the overwrite.
|
|
632
|
+
if (id === "exit") return void (await close());
|
|
633
|
+
if (id === "console") return void (await openUrl(dashboardUrl));
|
|
634
|
+
if (id === "repository") return void (await openUrl(repositoryUrl));
|
|
635
|
+
if (id === "backup") {
|
|
769
636
|
const result = await exportBackup(port, drainToken);
|
|
770
|
-
if (result !== null)
|
|
637
|
+
if (result !== null) {
|
|
771
638
|
console.log(
|
|
772
639
|
`Backup written to ${result.path} (${(result.bytes / 1024).toFixed(1)} KiB). Telemetry is not included.`,
|
|
773
640
|
);
|
|
641
|
+
}
|
|
774
642
|
return;
|
|
775
643
|
}
|
|
776
|
-
if (
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
return;
|
|
783
|
-
}
|
|
784
|
-
if (title !== update.title || !update.enabled || busy) return;
|
|
785
|
-
if (latestVersion === null || !isNewerRelease(currentVersion, latestVersion)) {
|
|
786
|
-
await refreshVersion();
|
|
787
|
-
return;
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
busy = true;
|
|
791
|
-
update.title = "Updating Cartethyia…";
|
|
792
|
-
update.enabled = false;
|
|
793
|
-
try {
|
|
794
|
-
await tray.sendAction({ type: "update-item", item: update });
|
|
795
|
-
const current = gateway;
|
|
796
|
-
if (!current) throw new Error("Cartethyia gateway is not running");
|
|
797
|
-
gateway = null;
|
|
798
|
-
await stopGateway(current, port, drainToken);
|
|
799
|
-
if (closing) return;
|
|
800
|
-
|
|
801
|
-
console.log("Updating Cartethyia through npm...");
|
|
802
|
-
const code = await npmUpdate();
|
|
803
|
-
if (closing) return;
|
|
804
|
-
if (code !== 0) {
|
|
805
|
-
console.error(`npm update failed with exit code ${code}; restarting the current installation.`);
|
|
806
|
-
gateway = await startGateway(port, drainToken);
|
|
807
|
-
await refreshVersion();
|
|
644
|
+
if (id === "reset") {
|
|
645
|
+
// A tray click cannot ask for the typed confirmation the menu uses, so
|
|
646
|
+
// the first click arms the action and the second one runs it.
|
|
647
|
+
if (!resetArmed) {
|
|
648
|
+
resetArmed = true;
|
|
649
|
+
tray.update("reset", "Reset console password \u00b7 click again to delete", true);
|
|
808
650
|
return;
|
|
809
651
|
}
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
const updatedUrl = await waitForTrayStartupSignal(signalPath, updatedProcess, 60_000);
|
|
815
|
-
console.log(`Cartethyia updated to ${await readInstalledVersion()} and restarted at ${updatedUrl}`);
|
|
816
|
-
await close();
|
|
817
|
-
} finally {
|
|
818
|
-
if (existsSync(signalPath)) unlinkSync(signalPath);
|
|
819
|
-
}
|
|
820
|
-
} catch (error) {
|
|
821
|
-
const reason = error instanceof Error ? error.message : "unknown error";
|
|
822
|
-
console.error(`Cartethyia update or restart failed: ${reason}`);
|
|
823
|
-
if (!closing && gateway === null) {
|
|
824
|
-
try {
|
|
825
|
-
gateway = await startGateway(port, drainToken);
|
|
826
|
-
} catch {
|
|
827
|
-
await close(1);
|
|
828
|
-
}
|
|
829
|
-
}
|
|
830
|
-
if (!closing) {
|
|
831
|
-
update.title = "Update failed — retry";
|
|
832
|
-
update.enabled = true;
|
|
833
|
-
try {
|
|
834
|
-
await tray.sendAction({ type: "update-item", item: update });
|
|
835
|
-
} catch {
|
|
836
|
-
// The tray may have failed while the update was being recovered.
|
|
837
|
-
}
|
|
838
|
-
}
|
|
839
|
-
} finally {
|
|
840
|
-
busy = false;
|
|
652
|
+
resetArmed = false;
|
|
653
|
+
tray.update("reset", "Reset console password", true);
|
|
654
|
+
await resetConsoleAccount(port, drainToken);
|
|
655
|
+
return;
|
|
841
656
|
}
|
|
842
|
-
|
|
657
|
+
if (id === "update") await runUpdate();
|
|
658
|
+
}
|
|
843
659
|
|
|
844
|
-
await tray.onClick((event) => {
|
|
845
|
-
void handleClick(event.item.title).catch(() => {
|
|
846
|
-
console.error("The selected system-tray action failed.");
|
|
847
|
-
});
|
|
848
|
-
});
|
|
849
|
-
tray.onExit(() => {
|
|
850
|
-
trayExited = true;
|
|
851
|
-
if (!closing) void close(1);
|
|
852
|
-
});
|
|
853
|
-
tray.onError(() => {
|
|
854
|
-
console.error("The system-tray process failed; stopping Cartethyia.");
|
|
855
|
-
if (!closing) void close(1);
|
|
856
|
-
});
|
|
857
660
|
process.once("SIGINT", () => void close());
|
|
858
661
|
process.once("SIGTERM", () => void close());
|
|
859
662
|
|
|
860
663
|
try {
|
|
861
|
-
await
|
|
862
|
-
trayReady = true;
|
|
863
|
-
// "Hide to tray" hands over a gateway that is already listening. Spawning a
|
|
864
|
-
// second one would fail on the port, so an adopted tray only watches the
|
|
865
|
-
// existing process and stops owning it when the menu process exits.
|
|
866
|
-
if (process.env.CARTETHYIA_TRAY_ADOPT_RUNNING === "1") {
|
|
867
|
-
reportTrayStartup({ status: "ready", url: dashboardUrl });
|
|
868
|
-
console.log(`Cartethyia is running at ${dashboardUrl}`);
|
|
869
|
-
void refreshVersion();
|
|
870
|
-
while (!closing) await Bun.sleep(500);
|
|
871
|
-
return;
|
|
872
|
-
}
|
|
664
|
+
await tray.ready();
|
|
873
665
|
gateway = await startGateway(port, drainToken);
|
|
874
|
-
reportTrayStartup({ status: "ready", url: dashboardUrl });
|
|
875
666
|
console.log(`Cartethyia is running at ${dashboardUrl}`);
|
|
876
667
|
void refreshVersion();
|
|
877
668
|
|
|
878
669
|
while (!closing) {
|
|
879
|
-
const current:
|
|
670
|
+
const current: Bun.Subprocess | null = gateway;
|
|
880
671
|
if (!current) {
|
|
881
672
|
await Bun.sleep(100);
|
|
882
673
|
continue;
|
|
@@ -893,6 +684,7 @@ async function startTray(): Promise<void> {
|
|
|
893
684
|
}
|
|
894
685
|
}
|
|
895
686
|
|
|
687
|
+
|
|
896
688
|
async function main(): Promise<void> {
|
|
897
689
|
assertBunVersion();
|
|
898
690
|
const [command] = process.argv.slice(2);
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# Cartethyia tray icon for Windows using NotifyIcon.
|
|
2
|
+
#
|
|
3
|
+
# Why PowerShell instead of a Node tray library: `systray2` and friends bundle a
|
|
4
|
+
# helper binary and still leave the spawning process attached to a console, so
|
|
5
|
+
# Windows Terminal shows an empty tab for the tray's whole lifetime. A
|
|
6
|
+
# PowerShell NotifyIcon owns no console of its own when started with
|
|
7
|
+
# -WindowStyle Hidden, and the protocol here is plain JSON lines on
|
|
8
|
+
# stdin/stdout, so the parent stays in charge.
|
|
9
|
+
#
|
|
10
|
+
# Protocol: the parent writes one JSON object per line.
|
|
11
|
+
# {"action":"add-item","index":0,"title":"...","enabled":true}
|
|
12
|
+
# {"action":"update-item","index":0,"title":"...","enabled":true}
|
|
13
|
+
# {"action":"set-tooltip","text":"..."}
|
|
14
|
+
# {"action":"kill"}
|
|
15
|
+
# This script writes one JSON object per line:
|
|
16
|
+
# {"type":"started"} {"type":"ready"} {"type":"click","index":0}
|
|
17
|
+
param([string]$IconPath, [string]$Tooltip)
|
|
18
|
+
|
|
19
|
+
$ErrorActionPreference = "Stop"
|
|
20
|
+
|
|
21
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
22
|
+
Add-Type -AssemblyName System.Drawing
|
|
23
|
+
|
|
24
|
+
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
|
25
|
+
[Console]::InputEncoding = [System.Text.Encoding]::UTF8
|
|
26
|
+
$OutputEncoding = [System.Text.Encoding]::UTF8
|
|
27
|
+
|
|
28
|
+
[System.Windows.Forms.Application]::EnableVisualStyles()
|
|
29
|
+
[System.Windows.Forms.Application]::SetCompatibleTextRenderingDefault($false)
|
|
30
|
+
|
|
31
|
+
$script:notifyIcon = New-Object System.Windows.Forms.NotifyIcon
|
|
32
|
+
$script:notifyIcon.Icon = New-Object System.Drawing.Icon($IconPath)
|
|
33
|
+
$script:notifyIcon.Text = $Tooltip
|
|
34
|
+
$script:notifyIcon.Visible = $true
|
|
35
|
+
|
|
36
|
+
$script:menu = New-Object System.Windows.Forms.ContextMenuStrip
|
|
37
|
+
$script:notifyIcon.ContextMenuStrip = $script:menu
|
|
38
|
+
$script:items = @()
|
|
39
|
+
|
|
40
|
+
function Write-Event($obj) {
|
|
41
|
+
$json = $obj | ConvertTo-Json -Compress
|
|
42
|
+
[Console]::Out.WriteLine($json)
|
|
43
|
+
[Console]::Out.Flush()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function Add-MenuItem($index, $title, $enabled) {
|
|
47
|
+
$item = New-Object System.Windows.Forms.ToolStripMenuItem
|
|
48
|
+
$item.Text = $title
|
|
49
|
+
$item.Enabled = $enabled
|
|
50
|
+
$idx = $index
|
|
51
|
+
$item.Add_Click({ Write-Event @{ type = "click"; index = $idx } }.GetNewClosure())
|
|
52
|
+
$script:menu.Items.Add($item) | Out-Null
|
|
53
|
+
$script:items += $item
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function Update-MenuItem($index, $title, $enabled) {
|
|
57
|
+
if ($index -lt $script:items.Count) {
|
|
58
|
+
$script:items[$index].Text = $title
|
|
59
|
+
$script:items[$index].Enabled = $enabled
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function Set-Tooltip($text) {
|
|
64
|
+
# NotifyIcon.Text is capped at 63 characters.
|
|
65
|
+
if ($text.Length -gt 63) { $text = $text.Substring(0, 63) }
|
|
66
|
+
$script:notifyIcon.Text = $text
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
# Stdin is polled from the UI thread: a background thread reading Console.In
|
|
70
|
+
# races the message loop and deadlocks when the menu is open.
|
|
71
|
+
$script:timer = New-Object System.Windows.Forms.Timer
|
|
72
|
+
$script:timer.Interval = 100
|
|
73
|
+
$script:timer.Add_Tick({
|
|
74
|
+
try {
|
|
75
|
+
while ([Console]::In.Peek() -ne -1) {
|
|
76
|
+
$line = [Console]::In.ReadLine()
|
|
77
|
+
if ([string]::IsNullOrWhiteSpace($line)) { continue }
|
|
78
|
+
$cmd = $line | ConvertFrom-Json
|
|
79
|
+
switch ($cmd.action) {
|
|
80
|
+
"add-item" { Add-MenuItem $cmd.index $cmd.title $cmd.enabled }
|
|
81
|
+
"update-item" { Update-MenuItem $cmd.index $cmd.title $cmd.enabled }
|
|
82
|
+
"set-tooltip" { Set-Tooltip $cmd.text }
|
|
83
|
+
"ready" { Write-Event @{ type = "ready" } }
|
|
84
|
+
"kill" {
|
|
85
|
+
$script:notifyIcon.Visible = $false
|
|
86
|
+
$script:notifyIcon.Dispose()
|
|
87
|
+
[System.Windows.Forms.Application]::Exit()
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
} catch {
|
|
92
|
+
Write-Event @{ type = "error"; message = $_.Exception.Message }
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
$script:timer.Start()
|
|
96
|
+
|
|
97
|
+
Write-Event @{ type = "started" }
|
|
98
|
+
[System.Windows.Forms.Application]::Run()
|