@wrongstack/desktop 1.0.4 → 1.0.7
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/main/main.js +2351 -2337
- package/dist/preload/preload.cjs +9 -2
- package/dist/preload/webui-preload.cjs +6 -1
- package/dist/renderer/assets/index-0iZUYcLZ.js +10 -0
- package/dist/renderer/assets/{index-PckFmyB3.css → index-By9A0swX.css} +1 -1
- package/dist/renderer/index.html +2 -2
- package/package.json +5 -5
- package/dist/renderer/assets/index-DgACI2f2.js +0 -10
package/dist/main/main.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// src/main/main.ts
|
|
2
|
-
import * as
|
|
2
|
+
import * as path6 from "node:path";
|
|
3
|
+
import { watchProviderConfig } from "@wrongstack/core/storage";
|
|
3
4
|
import { installCrashShield, resolveWstackPaths as resolveWstackPaths3 } from "@wrongstack/core/utils";
|
|
4
5
|
import {
|
|
5
6
|
app as app2,
|
|
@@ -10,52 +11,6 @@ import {
|
|
|
10
11
|
WebContentsView as WebContentsView2
|
|
11
12
|
} from "electron";
|
|
12
13
|
|
|
13
|
-
// src/main/macos-platform.ts
|
|
14
|
-
import { app, Menu } from "electron";
|
|
15
|
-
function initMacOS() {
|
|
16
|
-
if (process.platform !== "darwin") return;
|
|
17
|
-
app.setActivationPolicy("regular");
|
|
18
|
-
app.on("open-file", (_event, filePath) => {
|
|
19
|
-
if (app.isReady()) {
|
|
20
|
-
handleFileOpen(filePath);
|
|
21
|
-
}
|
|
22
|
-
});
|
|
23
|
-
app.dock?.setMenu(
|
|
24
|
-
Menu.buildFromTemplate([
|
|
25
|
-
{
|
|
26
|
-
label: "New Window",
|
|
27
|
-
click: () => {
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
])
|
|
31
|
-
);
|
|
32
|
-
app.setAboutPanelOptions({
|
|
33
|
-
applicationName: "WrongStack Desktop",
|
|
34
|
-
applicationVersion: app.getVersion(),
|
|
35
|
-
version: process.versions.electron ? `Electron ${process.versions.electron}` : ""
|
|
36
|
-
});
|
|
37
|
-
}
|
|
38
|
-
function handleFileOpen(filePath) {
|
|
39
|
-
pendingOpenFilePath = filePath;
|
|
40
|
-
}
|
|
41
|
-
var pendingOpenFilePath = null;
|
|
42
|
-
function drainPendingOpenFilePath() {
|
|
43
|
-
const path8 = pendingOpenFilePath;
|
|
44
|
-
pendingOpenFilePath = null;
|
|
45
|
-
return path8;
|
|
46
|
-
}
|
|
47
|
-
function firstOpenFileArg(argv) {
|
|
48
|
-
if (process.platform !== "darwin") return null;
|
|
49
|
-
for (let index = 1; index < argv.length; index++) {
|
|
50
|
-
const arg = argv[index];
|
|
51
|
-
if (arg == null) continue;
|
|
52
|
-
if (arg.startsWith("-")) continue;
|
|
53
|
-
if (arg === "." || arg === "--") continue;
|
|
54
|
-
return arg;
|
|
55
|
-
}
|
|
56
|
-
return null;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
14
|
// src/main/agent-bridge.ts
|
|
60
15
|
import { randomUUID } from "node:crypto";
|
|
61
16
|
import { EventEmitter } from "node:events";
|
|
@@ -147,7 +102,7 @@ var DesktopAgentBridge = class extends EventEmitter {
|
|
|
147
102
|
conversation.error = void 0;
|
|
148
103
|
this.emitChanged(conversation);
|
|
149
104
|
this.emitReconnectEvent(conversation, "connecting");
|
|
150
|
-
const promise = new Promise((
|
|
105
|
+
const promise = new Promise((resolve4, reject) => {
|
|
151
106
|
const ws = new WebSocket(wsUrl);
|
|
152
107
|
conversation.ws = ws;
|
|
153
108
|
const myGeneration = ++conversation.socketGeneration;
|
|
@@ -172,7 +127,7 @@ var DesktopAgentBridge = class extends EventEmitter {
|
|
|
172
127
|
conversation.connectionState = markConnectionOpen(conversation.connectionState);
|
|
173
128
|
this.emitChanged(conversation);
|
|
174
129
|
this.emitReconnectEvent(conversation, "connected");
|
|
175
|
-
|
|
130
|
+
resolve4();
|
|
176
131
|
});
|
|
177
132
|
ws.once("error", (err) => {
|
|
178
133
|
clearTimeout(timeout);
|
|
@@ -455,6 +410,99 @@ function stringValue(value) {
|
|
|
455
410
|
return typeof value === "string" ? value : void 0;
|
|
456
411
|
}
|
|
457
412
|
|
|
413
|
+
// src/main/app-icon.ts
|
|
414
|
+
import * as fs from "node:fs/promises";
|
|
415
|
+
import { fileURLToPath } from "node:url";
|
|
416
|
+
import { nativeImage } from "electron";
|
|
417
|
+
var ICON_CANDIDATES = ["../../assets/icon.png", "../../assets/icon.icns"];
|
|
418
|
+
async function readIcon(relativePath) {
|
|
419
|
+
try {
|
|
420
|
+
const iconPath = fileURLToPath(new URL(relativePath, import.meta.url));
|
|
421
|
+
await fs.stat(iconPath);
|
|
422
|
+
const icon = nativeImage.createFromPath(iconPath);
|
|
423
|
+
return icon.isEmpty() ? void 0 : icon;
|
|
424
|
+
} catch {
|
|
425
|
+
return void 0;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
async function loadDesktopAppIcon() {
|
|
429
|
+
for (const candidate of ICON_CANDIDATES) {
|
|
430
|
+
const icon = await readIcon(candidate);
|
|
431
|
+
if (icon) return icon;
|
|
432
|
+
}
|
|
433
|
+
return void 0;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// src/main/desktop-config-io.ts
|
|
437
|
+
import * as fs2 from "node:fs/promises";
|
|
438
|
+
import * as path from "node:path";
|
|
439
|
+
import {
|
|
440
|
+
decryptConfigSecrets,
|
|
441
|
+
DefaultSecretVault,
|
|
442
|
+
encryptConfigSecrets
|
|
443
|
+
} from "@wrongstack/core/security";
|
|
444
|
+
import { atomicWrite, wstackGlobalRoot } from "@wrongstack/core/utils";
|
|
445
|
+
var globalRoot = wstackGlobalRoot();
|
|
446
|
+
var bootstrapConfigPath = path.join(globalRoot, "config.json");
|
|
447
|
+
var defaultProfileConfigPath = path.join(globalRoot, "profiles", "default", "config.json");
|
|
448
|
+
var vault = new DefaultSecretVault({
|
|
449
|
+
keyFile: path.join(globalRoot, ".key")
|
|
450
|
+
});
|
|
451
|
+
function safeProfileName(value) {
|
|
452
|
+
if (typeof value !== "string" || !value.trim()) return "default";
|
|
453
|
+
return value.replace(/[/\\:]/g, "_").replace(/\.\./g, "_") || "default";
|
|
454
|
+
}
|
|
455
|
+
async function resolveActiveProfileConfigPath() {
|
|
456
|
+
try {
|
|
457
|
+
const raw = await fs2.readFile(bootstrapConfigPath, "utf8");
|
|
458
|
+
const parsed = JSON.parse(raw);
|
|
459
|
+
return path.join(globalRoot, "profiles", safeProfileName(parsed.activeProfile), "config.json");
|
|
460
|
+
} catch {
|
|
461
|
+
return defaultProfileConfigPath;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
async function readUiLocale() {
|
|
465
|
+
const profileConfigPath = await resolveActiveProfileConfigPath();
|
|
466
|
+
let raw;
|
|
467
|
+
try {
|
|
468
|
+
raw = await fs2.readFile(profileConfigPath, "utf8");
|
|
469
|
+
} catch {
|
|
470
|
+
return void 0;
|
|
471
|
+
}
|
|
472
|
+
try {
|
|
473
|
+
const decrypted = decryptConfigSecrets(JSON.parse(raw), vault);
|
|
474
|
+
const value = decrypted.uiLocale;
|
|
475
|
+
return typeof value === "string" && value ? value : void 0;
|
|
476
|
+
} catch {
|
|
477
|
+
return void 0;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
var desktopConfigPaths = {
|
|
481
|
+
bootstrapConfigPath,
|
|
482
|
+
profileConfigPath: defaultProfileConfigPath,
|
|
483
|
+
vault
|
|
484
|
+
};
|
|
485
|
+
async function writeUiLocale(code) {
|
|
486
|
+
const profileConfigPath = await resolveActiveProfileConfigPath();
|
|
487
|
+
let raw;
|
|
488
|
+
try {
|
|
489
|
+
raw = await fs2.readFile(profileConfigPath, "utf8");
|
|
490
|
+
} catch {
|
|
491
|
+
raw = "{}";
|
|
492
|
+
}
|
|
493
|
+
let parsed;
|
|
494
|
+
try {
|
|
495
|
+
parsed = JSON.parse(raw);
|
|
496
|
+
} catch {
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
const decrypted = decryptConfigSecrets(parsed, vault);
|
|
500
|
+
decrypted.uiLocale = code;
|
|
501
|
+
const encrypted = encryptConfigSecrets(decrypted, vault);
|
|
502
|
+
await fs2.mkdir(path.dirname(profileConfigPath), { recursive: true });
|
|
503
|
+
await atomicWrite(profileConfigPath, JSON.stringify(encrypted, null, 2), { mode: 384 });
|
|
504
|
+
}
|
|
505
|
+
|
|
458
506
|
// src/main/desktop-privileged-actions.ts
|
|
459
507
|
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
460
508
|
import {
|
|
@@ -462,19 +510,26 @@ import {
|
|
|
462
510
|
isTrustDecisionAllowed
|
|
463
511
|
} from "@wrongstack/core/security";
|
|
464
512
|
var desktopCompatibilityTrustBoundary = createCompatibilityTrustBoundary({
|
|
465
|
-
policyId: "desktop-trusted-host-compat-v1"
|
|
513
|
+
policyId: "desktop-trusted-host-compat-v1",
|
|
514
|
+
// WS-SEC-03: the desktop shell hosts a renderer that loads *remote* content
|
|
515
|
+
// (the WebUI runtime over http://127.0.0.1). Actions originating there are
|
|
516
|
+
// attributed to `remote-client`, so refuse the high/critical tiers from it —
|
|
517
|
+
// spawning or terminating a runtime is a shell-menu action, never something
|
|
518
|
+
// page content should be able to drive.
|
|
519
|
+
denyHighRiskRemoteClient: true
|
|
466
520
|
});
|
|
467
521
|
async function authorizeDesktopAction(boundary, action, logger) {
|
|
522
|
+
const principalId = action.origin === "user" ? "desktop-user" : "desktop-webui-view";
|
|
468
523
|
const request = {
|
|
469
524
|
version: 1,
|
|
470
525
|
requestId: randomUUID2(),
|
|
471
|
-
actor: { kind:
|
|
526
|
+
actor: { kind: action.origin, id: principalId },
|
|
472
527
|
surface: "desktop",
|
|
473
528
|
capability: action.capability,
|
|
474
529
|
subject: action.subject,
|
|
475
530
|
risk: action.risk,
|
|
476
531
|
scope: action.cwd ? { cwd: action.cwd } : {},
|
|
477
|
-
authContext: { method: "local-process", principalId
|
|
532
|
+
authContext: { method: "local-process", principalId },
|
|
478
533
|
...action.metadata ? { metadata: action.metadata } : {}
|
|
479
534
|
};
|
|
480
535
|
const decision = await boundary.evaluate(request);
|
|
@@ -482,6 +537,10 @@ async function authorizeDesktopAction(boundary, action, logger) {
|
|
|
482
537
|
event: "desktop.trust_boundary.decision",
|
|
483
538
|
requestId: request.requestId,
|
|
484
539
|
capability: request.capability,
|
|
540
|
+
// The origin is the whole point of the decision now, so an audit line that
|
|
541
|
+
// omits it cannot be used to tell an allowed shell action from an allowed
|
|
542
|
+
// renderer-driven one.
|
|
543
|
+
actor: request.actor.kind,
|
|
485
544
|
decision: decision.kind,
|
|
486
545
|
policyId: decision.policyId
|
|
487
546
|
};
|
|
@@ -497,6 +556,8 @@ async function authorizeDesktopAction(boundary, action, logger) {
|
|
|
497
556
|
function authorizeDesktopRuntimeStart(boundary, cwd, runtimeKind) {
|
|
498
557
|
return authorizeDesktopAction(boundary, {
|
|
499
558
|
capability: "process.spawn",
|
|
559
|
+
// Shell-driven: reached from the app menu / shell renderer's open-project.
|
|
560
|
+
origin: "user",
|
|
500
561
|
subject: {
|
|
501
562
|
kind: "command",
|
|
502
563
|
id: "wrongstack-webui-runtime",
|
|
@@ -510,6 +571,7 @@ function authorizeDesktopRuntimeStart(boundary, cwd, runtimeKind) {
|
|
|
510
571
|
function authorizeDesktopRuntimeStop(boundary, runtime) {
|
|
511
572
|
return authorizeDesktopAction(boundary, {
|
|
512
573
|
capability: "process.terminate",
|
|
574
|
+
origin: "user",
|
|
513
575
|
subject: {
|
|
514
576
|
kind: "process",
|
|
515
577
|
id: runtime.id,
|
|
@@ -524,46 +586,6 @@ function authorizeDesktopRuntimeStop(boundary, runtime) {
|
|
|
524
586
|
});
|
|
525
587
|
}
|
|
526
588
|
|
|
527
|
-
// src/main/ipc.ts
|
|
528
|
-
var IPC = {
|
|
529
|
-
getState: "desktop:get-state",
|
|
530
|
-
getConversation: "desktop:get-conversation",
|
|
531
|
-
getWebuiStatus: "desktop:get-webui-status",
|
|
532
|
-
listProjectSessions: "desktop:list-project-sessions",
|
|
533
|
-
openProject: "desktop:open-project",
|
|
534
|
-
registerProject: "desktop:register-project",
|
|
535
|
-
unregisterProject: "desktop:unregister-project",
|
|
536
|
-
openProjectSession: "desktop:open-project-session",
|
|
537
|
-
activateRuntime: "desktop:activate-runtime",
|
|
538
|
-
closeRuntime: "desktop:close-runtime",
|
|
539
|
-
navigateWebui: "desktop:navigate-webui",
|
|
540
|
-
reloadWebui: "desktop:reload-webui",
|
|
541
|
-
setShellSidebarCollapsed: "desktop:set-shell-sidebar-collapsed",
|
|
542
|
-
openSettings: "desktop:open-settings",
|
|
543
|
-
sendMessage: "desktop:send-message",
|
|
544
|
-
abortRuntime: "desktop:abort-runtime",
|
|
545
|
-
openRuntimeInBrowser: "desktop:open-runtime-in-browser",
|
|
546
|
-
revealRuntimeRoot: "desktop:reveal-runtime-root",
|
|
547
|
-
stateChanged: "desktop:state-changed",
|
|
548
|
-
conversationChanged: "desktop:conversation-changed",
|
|
549
|
-
webuiStatusChanged: "desktop:webui-status-changed",
|
|
550
|
-
webuiReadyChanged: "desktop:webui-ready-changed",
|
|
551
|
-
webuiPrefsChanged: "desktop:webui-prefs-changed",
|
|
552
|
-
webuiCommandAck: "desktop:webui-command-ack",
|
|
553
|
-
webuiCommand: "desktop:webui-command",
|
|
554
|
-
shellSidebarCollapsedChanged: "desktop:shell-sidebar-collapsed-changed",
|
|
555
|
-
setLocale: "desktop:set-locale",
|
|
556
|
-
localeChanged: "desktop:locale-changed",
|
|
557
|
-
// Embedded WebUI view side — the desktop shell pushes locale changes here
|
|
558
|
-
// so the React WebUI inside Electron can swap i18n instantly, without waiting
|
|
559
|
-
// for the config-file watcher → WS prefs.updated round-trip.
|
|
560
|
-
webuiLocaleChanged: "desktop:webui-locale-changed",
|
|
561
|
-
// macOS open-file event forwarded from main process to shell renderer.
|
|
562
|
-
// The shell uses this to decide whether to open a dragged/double-clicked
|
|
563
|
-
// path as a project directory.
|
|
564
|
-
openFile: "desktop:open-file"
|
|
565
|
-
};
|
|
566
|
-
|
|
567
589
|
// src/main/i18n-main.ts
|
|
568
590
|
var en = {
|
|
569
591
|
windowTitle: "WrongStack Desktop",
|
|
@@ -864,2386 +886,2370 @@ function tMain(key) {
|
|
|
864
886
|
return CATALOGS[mainLocale]?.[key] ?? CATALOGS.en[key] ?? key;
|
|
865
887
|
}
|
|
866
888
|
|
|
867
|
-
// src/main/
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
889
|
+
// src/main/ipc.ts
|
|
890
|
+
var IPC = {
|
|
891
|
+
getState: "desktop:get-state",
|
|
892
|
+
getConversation: "desktop:get-conversation",
|
|
893
|
+
getWebuiStatus: "desktop:get-webui-status",
|
|
894
|
+
getOpenSessions: "desktop:get-open-sessions",
|
|
895
|
+
openProject: "desktop:open-project",
|
|
896
|
+
registerProject: "desktop:register-project",
|
|
897
|
+
unregisterProject: "desktop:unregister-project",
|
|
898
|
+
openProjectSession: "desktop:open-project-session",
|
|
899
|
+
activateRuntime: "desktop:activate-runtime",
|
|
900
|
+
closeRuntime: "desktop:close-runtime",
|
|
901
|
+
navigateWebui: "desktop:navigate-webui",
|
|
902
|
+
reloadWebui: "desktop:reload-webui",
|
|
903
|
+
setShellSidebarCollapsed: "desktop:set-shell-sidebar-collapsed",
|
|
904
|
+
openSettings: "desktop:open-settings",
|
|
905
|
+
sendMessage: "desktop:send-message",
|
|
906
|
+
abortRuntime: "desktop:abort-runtime",
|
|
907
|
+
openRuntimeInBrowser: "desktop:open-runtime-in-browser",
|
|
908
|
+
revealRuntimeRoot: "desktop:reveal-runtime-root",
|
|
909
|
+
stateChanged: "desktop:state-changed",
|
|
910
|
+
conversationChanged: "desktop:conversation-changed",
|
|
911
|
+
webuiStatusChanged: "desktop:webui-status-changed",
|
|
912
|
+
webuiReadyChanged: "desktop:webui-ready-changed",
|
|
913
|
+
webuiPrefsChanged: "desktop:webui-prefs-changed",
|
|
914
|
+
webuiOpenSessionsChanged: "desktop:webui-open-sessions-changed",
|
|
915
|
+
openSessionsChanged: "desktop:open-sessions-changed",
|
|
916
|
+
webuiCommandAck: "desktop:webui-command-ack",
|
|
917
|
+
webuiCommand: "desktop:webui-command",
|
|
918
|
+
shellSidebarCollapsedChanged: "desktop:shell-sidebar-collapsed-changed",
|
|
919
|
+
setLocale: "desktop:set-locale",
|
|
920
|
+
localeChanged: "desktop:locale-changed",
|
|
921
|
+
// Embedded WebUI view side — the desktop shell pushes locale changes here
|
|
922
|
+
// so the React WebUI inside Electron can swap i18n instantly, without waiting
|
|
923
|
+
// for the config-file watcher → WS prefs.updated round-trip.
|
|
924
|
+
webuiLocaleChanged: "desktop:webui-locale-changed",
|
|
925
|
+
// macOS open-file event forwarded from main process to shell renderer.
|
|
926
|
+
// The shell uses this to decide whether to open a dragged/double-clicked
|
|
927
|
+
// path as a project directory.
|
|
928
|
+
openFile: "desktop:open-file"
|
|
929
|
+
};
|
|
930
|
+
|
|
931
|
+
// src/main/ipc-handlers/index.ts
|
|
932
|
+
import { ipcMain } from "electron";
|
|
933
|
+
|
|
934
|
+
// src/main/validation/index.ts
|
|
935
|
+
function validate(schema, data) {
|
|
887
936
|
try {
|
|
888
|
-
const
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
return
|
|
937
|
+
const result = schema.safeParse(data);
|
|
938
|
+
if (result.success) {
|
|
939
|
+
return { success: true, data: result.data };
|
|
940
|
+
}
|
|
941
|
+
return { success: false, error: formatZodError(result.error) };
|
|
942
|
+
} catch (err) {
|
|
943
|
+
return { success: false, error: String(err) };
|
|
893
944
|
}
|
|
894
945
|
}
|
|
895
|
-
|
|
896
|
-
const
|
|
897
|
-
|
|
898
|
-
try {
|
|
899
|
-
raw = await fs.readFile(profileConfigPath, "utf8");
|
|
900
|
-
} catch {
|
|
901
|
-
return void 0;
|
|
902
|
-
}
|
|
903
|
-
try {
|
|
904
|
-
const decrypted = decryptConfigSecrets(JSON.parse(raw), vault);
|
|
905
|
-
const value = decrypted.uiLocale;
|
|
906
|
-
return typeof value === "string" && value ? value : void 0;
|
|
907
|
-
} catch {
|
|
908
|
-
return void 0;
|
|
909
|
-
}
|
|
946
|
+
function validateOrDefault(schema, data, defaultValue) {
|
|
947
|
+
const result = validate(schema, data);
|
|
948
|
+
return result.success ? result.data : defaultValue;
|
|
910
949
|
}
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
950
|
+
function formatZodError(error) {
|
|
951
|
+
return error.issues.map((e) => `${e.path.join(".")}: ${e.message}`).join("; ");
|
|
952
|
+
}
|
|
953
|
+
function createValidationLogger(prefix) {
|
|
954
|
+
const loggedErrors = /* @__PURE__ */ new Set();
|
|
955
|
+
const maxErrors = 100;
|
|
956
|
+
return {
|
|
957
|
+
log(error) {
|
|
958
|
+
if (loggedErrors.size >= maxErrors) return;
|
|
959
|
+
const key = `${prefix}:${error}`;
|
|
960
|
+
if (!loggedErrors.has(key)) {
|
|
961
|
+
loggedErrors.add(key);
|
|
962
|
+
console.warn(
|
|
963
|
+
JSON.stringify({
|
|
964
|
+
level: "warn",
|
|
965
|
+
event: "desktop.validation_error",
|
|
966
|
+
prefix,
|
|
967
|
+
message: error,
|
|
968
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
969
|
+
})
|
|
970
|
+
);
|
|
971
|
+
}
|
|
972
|
+
},
|
|
973
|
+
reset() {
|
|
974
|
+
loggedErrors.clear();
|
|
975
|
+
}
|
|
976
|
+
};
|
|
935
977
|
}
|
|
936
978
|
|
|
937
|
-
// src/main/
|
|
938
|
-
import {
|
|
939
|
-
import { randomBytes } from "node:crypto";
|
|
940
|
-
import { EventEmitter as EventEmitter2 } from "node:events";
|
|
941
|
-
import * as fs2 from "node:fs/promises";
|
|
942
|
-
import * as http from "node:http";
|
|
943
|
-
import * as net from "node:net";
|
|
944
|
-
import * as os from "node:os";
|
|
945
|
-
import * as path3 from "node:path";
|
|
946
|
-
import {
|
|
947
|
-
atomicWrite as atomicWrite2,
|
|
948
|
-
buildChildEnv,
|
|
949
|
-
projectSlug,
|
|
950
|
-
resolveWstackPaths as resolveWstackPaths2,
|
|
951
|
-
toErrorMessage,
|
|
952
|
-
wstackGlobalRoot as wstackGlobalRoot2
|
|
953
|
-
} from "@wrongstack/core/utils";
|
|
954
|
-
|
|
955
|
-
// src/main/runtime-manager-paths.ts
|
|
956
|
-
import { existsSync } from "node:fs";
|
|
957
|
-
import { createRequire } from "node:module";
|
|
979
|
+
// src/main/validation/schemas.ts
|
|
980
|
+
import { statSync } from "node:fs";
|
|
958
981
|
import * as path2 from "node:path";
|
|
959
|
-
import {
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
982
|
+
import { z } from "zod";
|
|
983
|
+
var RUNTIME_ID_PATTERN = /^[a-zA-Z0-9._:-]{3,120}$/;
|
|
984
|
+
var runtimeIdSchema = z.string().regex(RUNTIME_ID_PATTERN, "Invalid runtime ID format");
|
|
985
|
+
var pathSchema = z.string().min(1).max(1e4);
|
|
986
|
+
var projectRootSchema = pathSchema.refine((value) => !value.includes("\0"), {
|
|
987
|
+
message: "path must not contain a NUL byte"
|
|
988
|
+
}).refine(
|
|
989
|
+
(value) => {
|
|
990
|
+
try {
|
|
991
|
+
return statSync(path2.resolve(value)).isDirectory();
|
|
992
|
+
} catch {
|
|
993
|
+
return false;
|
|
994
|
+
}
|
|
995
|
+
},
|
|
996
|
+
{ message: "path must be an existing directory" }
|
|
997
|
+
);
|
|
998
|
+
var booleanSchema = z.boolean();
|
|
999
|
+
var numberSchema = z.number().int().finite();
|
|
1000
|
+
var openProjectSchema = z.object({
|
|
1001
|
+
requestedRoot: pathSchema.optional()
|
|
1002
|
+
});
|
|
1003
|
+
var registerProjectSchema = z.object({
|
|
1004
|
+
requestedRoot: pathSchema.optional()
|
|
1005
|
+
});
|
|
1006
|
+
var unregisterProjectSchema = z.object({
|
|
1007
|
+
root: pathSchema
|
|
1008
|
+
});
|
|
1009
|
+
var openProjectSessionSchema = z.object({
|
|
1010
|
+
runtimeId: runtimeIdSchema.optional()
|
|
1011
|
+
});
|
|
1012
|
+
var activateRuntimeSchema = z.object({
|
|
1013
|
+
id: runtimeIdSchema
|
|
1014
|
+
});
|
|
1015
|
+
var closeRuntimeSchema = z.object({
|
|
1016
|
+
id: runtimeIdSchema
|
|
1017
|
+
});
|
|
1018
|
+
var sendMessageSchema = z.object({
|
|
1019
|
+
id: runtimeIdSchema,
|
|
1020
|
+
content: z.string()
|
|
1021
|
+
});
|
|
1022
|
+
var abortRuntimeSchema = z.object({
|
|
1023
|
+
id: runtimeIdSchema
|
|
1024
|
+
});
|
|
1025
|
+
var openRuntimeInBrowserSchema = z.object({
|
|
1026
|
+
id: runtimeIdSchema
|
|
1027
|
+
});
|
|
1028
|
+
var revealRuntimeRootSchema = z.object({
|
|
1029
|
+
id: runtimeIdSchema
|
|
1030
|
+
});
|
|
1031
|
+
var setShellSidebarCollapsedSchema = z.object({
|
|
1032
|
+
collapsed: booleanSchema
|
|
1033
|
+
});
|
|
1034
|
+
var navigateWebuiSchema = z.object({
|
|
1035
|
+
command: z.unknown()
|
|
1036
|
+
});
|
|
1037
|
+
var getConversationSchema = z.object({
|
|
1038
|
+
runtimeId: runtimeIdSchema
|
|
1039
|
+
});
|
|
1040
|
+
var webuiReadyChangedSchema = z.object({
|
|
1041
|
+
ready: booleanSchema
|
|
1042
|
+
});
|
|
1043
|
+
var webuiPrefsChangedSchema = z.object({
|
|
1044
|
+
prefs: z.record(z.string(), z.unknown())
|
|
1045
|
+
});
|
|
1046
|
+
var webuiCommandAckSchema = z.object({
|
|
1047
|
+
requestId: z.string(),
|
|
1048
|
+
handled: z.boolean(),
|
|
1049
|
+
message: z.string().optional()
|
|
1050
|
+
});
|
|
1051
|
+
var setLocaleSchema = z.object({
|
|
1052
|
+
locale: z.string().min(2).max(10)
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
// src/main/webui/open-sessions.ts
|
|
1056
|
+
function sanitizeOpenSessions(value) {
|
|
1057
|
+
if (!Array.isArray(value)) return [];
|
|
1058
|
+
const sessions = [];
|
|
1059
|
+
const ids = /* @__PURE__ */ new Set();
|
|
1060
|
+
const slots = /* @__PURE__ */ new Set();
|
|
1061
|
+
for (const raw of value) {
|
|
1062
|
+
if (!isRecord(raw)) continue;
|
|
1063
|
+
const id = raw["id"];
|
|
1064
|
+
const title = raw["title"];
|
|
1065
|
+
const slot = raw["slot"];
|
|
1066
|
+
if (typeof id !== "string" || id.length === 0 || id.length > 512 || typeof title !== "string" || !Number.isInteger(slot) || slot < 0 || slot > 3 || typeof raw["active"] !== "boolean" || typeof raw["running"] !== "boolean" || ids.has(id) || slots.has(slot)) {
|
|
1067
|
+
continue;
|
|
1068
|
+
}
|
|
1069
|
+
ids.add(id);
|
|
1070
|
+
slots.add(slot);
|
|
1071
|
+
sessions.push({
|
|
1072
|
+
id,
|
|
1073
|
+
title: title.trim().slice(0, 200) || id.slice(0, 8),
|
|
1074
|
+
slot,
|
|
1075
|
+
active: raw["active"],
|
|
1076
|
+
running: raw["running"]
|
|
1077
|
+
});
|
|
1078
|
+
if (sessions.length === 4) break;
|
|
978
1079
|
}
|
|
979
|
-
|
|
980
|
-
const serverEntry = require2.resolve("@wrongstack/webui");
|
|
981
|
-
const candidate = path2.dirname(serverEntry);
|
|
982
|
-
if (existsSync(candidate)) return candidate;
|
|
983
|
-
throw new Error(
|
|
984
|
-
`WebUI frontend assets not found at ${candidate}. Build @wrongstack/webui or set WRONGSTACK_WEBUI_DIST.`
|
|
985
|
-
);
|
|
986
|
-
}
|
|
987
|
-
function rendererIndexPath() {
|
|
988
|
-
return new URL("../renderer/index.html", import.meta.url).href;
|
|
989
|
-
}
|
|
990
|
-
function preloadPath() {
|
|
991
|
-
return fileURLToPath(new URL("../preload/preload.cjs", import.meta.url));
|
|
992
|
-
}
|
|
993
|
-
function webuiPreloadPath() {
|
|
994
|
-
return fileURLToPath(new URL("../preload/webui-preload.cjs", import.meta.url));
|
|
1080
|
+
return sessions.sort((a, b) => a.slot - b.slot);
|
|
995
1081
|
}
|
|
996
|
-
function
|
|
997
|
-
return
|
|
1082
|
+
function isRecord(value) {
|
|
1083
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
998
1084
|
}
|
|
999
1085
|
|
|
1000
|
-
// src/main/
|
|
1001
|
-
var
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
function
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
for (const [id, runtime] of runtimes) {
|
|
1017
|
-
if (id === options.activeRuntimeId) continue;
|
|
1018
|
-
if (runtime.status !== "running") continue;
|
|
1019
|
-
if (options.now - runtime.lastActivityAt < options.idleTimeoutMs) continue;
|
|
1020
|
-
out.push(id);
|
|
1021
|
-
}
|
|
1022
|
-
return out;
|
|
1086
|
+
// src/main/ipc-handlers/index.ts
|
|
1087
|
+
var validationLogger = createValidationLogger("IPC");
|
|
1088
|
+
function isShellSender(ctx, senderId) {
|
|
1089
|
+
const shell3 = ctx.getShellView();
|
|
1090
|
+
if (shell3 === null) return false;
|
|
1091
|
+
const contents = shell3.webContents;
|
|
1092
|
+
return !contents.isDestroyed() && contents.id === senderId;
|
|
1093
|
+
}
|
|
1094
|
+
function handleShellOnly(ctx, channel, handler) {
|
|
1095
|
+
ipcMain.handle(channel, (event, ...args) => {
|
|
1096
|
+
if (!isShellSender(ctx, event.sender.id)) {
|
|
1097
|
+
validationLogger.log(`${channel}: rejected invoke from sender ${event.sender.id}`);
|
|
1098
|
+
throw new Error(`IPC channel ${channel} is restricted to the shell renderer`);
|
|
1099
|
+
}
|
|
1100
|
+
return handler(event, ...args);
|
|
1101
|
+
});
|
|
1023
1102
|
}
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
);
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
(
|
|
1103
|
+
function registerIpcHandlers(ctx) {
|
|
1104
|
+
handleShellOnly(ctx, IPC.getState, () => ctx.getRuntimeManager().snapshot());
|
|
1105
|
+
handleShellOnly(ctx, IPC.getConversation, (_event, runtimeId) => {
|
|
1106
|
+
const result = validate(runtimeIdSchema, runtimeId);
|
|
1107
|
+
if (!result.success) {
|
|
1108
|
+
validationLogger.log(`getConversation: ${result.error}`);
|
|
1109
|
+
return ctx.getAgentBridge().snapshot("");
|
|
1110
|
+
}
|
|
1111
|
+
return ctx.getAgentBridge().snapshot(result.data);
|
|
1112
|
+
});
|
|
1113
|
+
handleShellOnly(ctx, IPC.getWebuiStatus, () => ctx.getWebuiStatus());
|
|
1114
|
+
handleShellOnly(ctx, IPC.getOpenSessions, () => ctx.getOpenSessions());
|
|
1115
|
+
handleShellOnly(ctx, IPC.navigateWebui, async (_event, command) => {
|
|
1116
|
+
return ctx.dispatchWebuiCommand(command);
|
|
1117
|
+
});
|
|
1118
|
+
handleShellOnly(ctx, IPC.reloadWebui, async () => ctx.reloadActiveWebuiView());
|
|
1119
|
+
handleShellOnly(ctx, IPC.setShellSidebarCollapsed, (_event, collapsed) => {
|
|
1120
|
+
const result = validateOrDefault(booleanSchema, collapsed, true);
|
|
1121
|
+
ctx.setShellSidebarCollapsed(result);
|
|
1122
|
+
return true;
|
|
1123
|
+
});
|
|
1124
|
+
handleShellOnly(ctx, IPC.openSettings, async () => ctx.openSettings());
|
|
1125
|
+
handleShellOnly(ctx, IPC.openProjectSession, async (_event, runtimeId) => {
|
|
1126
|
+
const validated = validateOptional(runtimeIdSchema, runtimeId);
|
|
1127
|
+
return ctx.openProjectSession(validated);
|
|
1128
|
+
});
|
|
1129
|
+
handleShellOnly(ctx, IPC.openProject, async (_event, requestedRoot) => {
|
|
1130
|
+
const validated = validateOptional(projectRootSchema, requestedRoot);
|
|
1131
|
+
return ctx.openProject(validated);
|
|
1132
|
+
});
|
|
1133
|
+
handleShellOnly(ctx, IPC.registerProject, async (_event, requestedRoot) => {
|
|
1134
|
+
const validated = validateOptional(projectRootSchema, requestedRoot);
|
|
1135
|
+
return ctx.registerProject(validated);
|
|
1136
|
+
});
|
|
1137
|
+
handleShellOnly(ctx, IPC.unregisterProject, async (_event, root) => {
|
|
1138
|
+
const result = validate(pathSchema, root);
|
|
1139
|
+
if (!result.success) {
|
|
1140
|
+
validationLogger.log(`unregisterProject: ${result.error}`);
|
|
1141
|
+
return ctx.getRuntimeManager().snapshot();
|
|
1142
|
+
}
|
|
1143
|
+
return ctx.unregisterProject(result.data);
|
|
1144
|
+
});
|
|
1145
|
+
handleShellOnly(ctx, IPC.activateRuntime, async (_event, id) => {
|
|
1146
|
+
const result = validate(runtimeIdSchema, id);
|
|
1147
|
+
if (!result.success) {
|
|
1148
|
+
validationLogger.log(`activateRuntime: ${result.error}`);
|
|
1149
|
+
return ctx.getRuntimeManager().snapshot();
|
|
1150
|
+
}
|
|
1151
|
+
return ctx.activateRuntime(result.data);
|
|
1152
|
+
});
|
|
1153
|
+
handleShellOnly(ctx, IPC.closeRuntime, async (_event, id) => {
|
|
1154
|
+
const result = validate(runtimeIdSchema, id);
|
|
1155
|
+
if (!result.success) {
|
|
1156
|
+
validationLogger.log(`closeRuntime: ${result.error}`);
|
|
1157
|
+
return ctx.getRuntimeManager().snapshot();
|
|
1158
|
+
}
|
|
1159
|
+
return ctx.closeRuntime(result.data);
|
|
1160
|
+
});
|
|
1161
|
+
handleShellOnly(ctx, IPC.sendMessage, async (_event, id, content) => {
|
|
1162
|
+
const idResult = validate(runtimeIdSchema, id);
|
|
1163
|
+
if (!idResult.success) {
|
|
1164
|
+
validationLogger.log(`sendMessage (id): ${idResult.error}`);
|
|
1165
|
+
return ctx.sendMessage("", "", "");
|
|
1166
|
+
}
|
|
1167
|
+
const contentResult = validate(
|
|
1168
|
+
pathSchema.transform(() => String(content ?? "")),
|
|
1169
|
+
content
|
|
1079
1170
|
);
|
|
1080
|
-
|
|
1081
|
-
|
|
1171
|
+
const safeContent = contentResult.success ? contentResult.data : String(content ?? "");
|
|
1172
|
+
return ctx.sendMessage(
|
|
1173
|
+
idResult.data,
|
|
1174
|
+
ctx.getRuntimeManager().getRuntimeWsUrlWithToken(idResult.data) ?? "",
|
|
1175
|
+
safeContent
|
|
1176
|
+
);
|
|
1177
|
+
});
|
|
1178
|
+
handleShellOnly(ctx, IPC.abortRuntime, async (_event, id) => {
|
|
1179
|
+
const result = validate(runtimeIdSchema, id);
|
|
1180
|
+
if (!result.success) {
|
|
1181
|
+
validationLogger.log(`abortRuntime: ${result.error}`);
|
|
1182
|
+
return ctx.abortRuntime("", "");
|
|
1183
|
+
}
|
|
1184
|
+
return ctx.abortRuntime(
|
|
1185
|
+
result.data,
|
|
1186
|
+
ctx.getRuntimeManager().getRuntimeWsUrlWithToken(result.data) ?? ""
|
|
1187
|
+
);
|
|
1188
|
+
});
|
|
1189
|
+
handleShellOnly(ctx, IPC.openRuntimeInBrowser, async (_event, id) => {
|
|
1190
|
+
const result = validate(runtimeIdSchema, id);
|
|
1191
|
+
if (!result.success) {
|
|
1192
|
+
validationLogger.log(`openRuntimeInBrowser: ${result.error}`);
|
|
1082
1193
|
return;
|
|
1083
1194
|
}
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
await this.openProject(session.root, {
|
|
1093
|
-
forceNew: seenCount > 0,
|
|
1094
|
-
name: session.name,
|
|
1095
|
-
runtimeId: session.runtimeId
|
|
1096
|
-
}).catch((err) => {
|
|
1097
|
-
process.stderr.write(
|
|
1098
|
-
`[desktop:restore] Failed to restore ${session.root}: ${toErrorMessage(err)}
|
|
1099
|
-
`
|
|
1100
|
-
);
|
|
1101
|
-
});
|
|
1102
|
-
}
|
|
1103
|
-
let restoredActive = false;
|
|
1104
|
-
if (this.restoreActiveRuntimeId) {
|
|
1105
|
-
const active = this.runtimes.get(this.restoreActiveRuntimeId);
|
|
1106
|
-
if (active) {
|
|
1107
|
-
await this.activateRuntime(active.id);
|
|
1108
|
-
restoredActive = true;
|
|
1109
|
-
}
|
|
1110
|
-
}
|
|
1111
|
-
if (!restoredActive && this.restoreActiveProjectRoot) {
|
|
1112
|
-
const active = Array.from(this.runtimes.values()).find(
|
|
1113
|
-
(runtime) => samePath(runtime.root, this.restoreActiveProjectRoot ?? "")
|
|
1114
|
-
);
|
|
1115
|
-
if (active) await this.activateRuntime(active.id);
|
|
1116
|
-
}
|
|
1117
|
-
} finally {
|
|
1118
|
-
this.restoring = false;
|
|
1119
|
-
this.workspaceRestoreCompleted = true;
|
|
1120
|
-
this.emitChanged();
|
|
1121
|
-
await this.saveDesktopState();
|
|
1195
|
+
const url = ctx.getRuntimeManager().getRuntimeUrlWithToken(result.data);
|
|
1196
|
+
if (url) ctx.openExternal(url);
|
|
1197
|
+
});
|
|
1198
|
+
handleShellOnly(ctx, IPC.revealRuntimeRoot, async (_event, id) => {
|
|
1199
|
+
const result = validate(runtimeIdSchema, id);
|
|
1200
|
+
if (!result.success) {
|
|
1201
|
+
validationLogger.log(`revealRuntimeRoot: ${result.error}`);
|
|
1202
|
+
return;
|
|
1122
1203
|
}
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
const
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
getRuntimeWsUrlWithToken(id) {
|
|
1137
|
-
const runtime = this.runtimes.get(id);
|
|
1138
|
-
if (!runtime) return void 0;
|
|
1139
|
-
const url = new URL(`ws://127.0.0.1:${runtime.wsPort}`);
|
|
1140
|
-
url.searchParams.set("token", runtime.token);
|
|
1141
|
-
return url.toString();
|
|
1142
|
-
}
|
|
1143
|
-
async openProject(projectRoot, options = {}) {
|
|
1144
|
-
const resolved = path3.resolve(projectRoot);
|
|
1145
|
-
const stat4 = await fs2.stat(resolved).catch(() => null);
|
|
1146
|
-
if (!stat4?.isDirectory()) throw new Error(`Not a directory: ${resolved}`);
|
|
1147
|
-
const kind = options.kind ?? "project";
|
|
1148
|
-
const touchRecent = options.touchRecent ?? kind === "project";
|
|
1149
|
-
const forceNew = options.forceNew === true;
|
|
1150
|
-
const authorization = await authorizeDesktopRuntimeStart(this.trustBoundary, resolved, kind);
|
|
1151
|
-
if (!authorization.allowed) {
|
|
1152
|
-
throw new Error(`Desktop runtime start denied: ${authorization.reason}`);
|
|
1204
|
+
const runtime = ctx.getRuntimeManager().getRuntime(result.data);
|
|
1205
|
+
if (runtime) ctx.revealInExplorer(runtime.root);
|
|
1206
|
+
});
|
|
1207
|
+
ipcMain.on(IPC.webuiReadyChanged, (event, ready) => {
|
|
1208
|
+
const entry = ctx.findWebuiEntryBySenderId(event.sender.id);
|
|
1209
|
+
if (!entry) return;
|
|
1210
|
+
const isReady = ready === true;
|
|
1211
|
+
entry.bridgeReady = isReady;
|
|
1212
|
+
if (entry.bridgeReady) {
|
|
1213
|
+
ctx.setEntryWebuiStatus(entry, { ...entry.status, status: "ready" });
|
|
1214
|
+
ctx.schedulePendingWebuiFlush(entry);
|
|
1215
|
+
} else if (entry.status.status === "ready") {
|
|
1216
|
+
ctx.setEntryWebuiStatus(entry, { ...entry.status, status: "loading" });
|
|
1153
1217
|
}
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1218
|
+
});
|
|
1219
|
+
ipcMain.on(IPC.webuiPrefsChanged, (event, prefs) => {
|
|
1220
|
+
const entry = ctx.findWebuiEntryBySenderId(event.sender.id);
|
|
1221
|
+
if (!entry) return;
|
|
1222
|
+
const sanitized = sanitizeWebuiPrefs(prefs);
|
|
1223
|
+
if (Object.keys(sanitized).length === 0) return;
|
|
1224
|
+
ctx.setEntryWebuiStatus(entry, {
|
|
1225
|
+
...entry.status,
|
|
1226
|
+
prefs: { ...entry.status.prefs ?? {}, ...sanitized }
|
|
1227
|
+
});
|
|
1228
|
+
});
|
|
1229
|
+
ipcMain.on(IPC.webuiOpenSessionsChanged, (event, sessions) => {
|
|
1230
|
+
const entry = ctx.findWebuiEntryBySenderId(event.sender.id);
|
|
1231
|
+
if (!entry) return;
|
|
1232
|
+
ctx.setOpenSessions(entry.runtimeId, sanitizeOpenSessions(sessions));
|
|
1233
|
+
});
|
|
1234
|
+
ipcMain.on(
|
|
1235
|
+
IPC.webuiCommandAck,
|
|
1236
|
+
(event, requestId, handled, _message) => {
|
|
1237
|
+
const entry = ctx.findWebuiEntryBySenderId(event.sender.id);
|
|
1238
|
+
if (!entry) return;
|
|
1239
|
+
const result = validate(webuiCommandAckSchema, {
|
|
1240
|
+
requestId,
|
|
1241
|
+
handled,
|
|
1242
|
+
message: _message
|
|
1243
|
+
});
|
|
1244
|
+
if (!result.success) {
|
|
1245
|
+
validationLogger.log(`webuiCommandAck: ${result.error}`);
|
|
1246
|
+
return;
|
|
1174
1247
|
}
|
|
1248
|
+
const pending = ctx.getPendingWebuiCommandAcks().get(result.data.requestId);
|
|
1249
|
+
if (!pending || pending.runtimeId !== entry.runtimeId) return;
|
|
1250
|
+
ctx.settlePendingWebuiCommandAck(result.data.requestId, result.data.handled);
|
|
1175
1251
|
}
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
const
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
WS_PORT_START,
|
|
1183
|
-
/* @__PURE__ */ new Set([...usedPorts(this.runtimes), httpPort])
|
|
1184
|
-
);
|
|
1185
|
-
const token = randomBytes(24).toString("hex");
|
|
1186
|
-
const runtime = {
|
|
1187
|
-
id: runtimeId,
|
|
1188
|
-
name,
|
|
1189
|
-
root: resolved,
|
|
1190
|
-
slug,
|
|
1191
|
-
kind,
|
|
1192
|
-
status: "starting",
|
|
1193
|
-
httpPort,
|
|
1194
|
-
wsPort,
|
|
1195
|
-
url: `http://127.0.0.1:${httpPort}`,
|
|
1196
|
-
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1197
|
-
token,
|
|
1198
|
-
child: null,
|
|
1199
|
-
logs: [],
|
|
1200
|
-
logNotifyTimer: null,
|
|
1201
|
-
lastActivityAt: Date.now()
|
|
1202
|
-
};
|
|
1203
|
-
this.runtimes.set(runtimeId, runtime);
|
|
1204
|
-
this.activeRuntimeId = runtimeId;
|
|
1205
|
-
if (kind === "project") this.lastActiveProjectRoot = resolved;
|
|
1206
|
-
if (touchRecent) {
|
|
1207
|
-
await this.touchProject(resolved);
|
|
1208
|
-
} else {
|
|
1209
|
-
await this.persistWorkspaceState();
|
|
1210
|
-
}
|
|
1211
|
-
this.emitChanged();
|
|
1212
|
-
try {
|
|
1213
|
-
const entry = resolveWebUiEntry();
|
|
1214
|
-
const distDir = resolveWebUiDistDir();
|
|
1215
|
-
const child = spawn(
|
|
1216
|
-
process.execPath,
|
|
1217
|
-
[
|
|
1218
|
-
entry,
|
|
1219
|
-
"--host",
|
|
1220
|
-
"127.0.0.1",
|
|
1221
|
-
"--port",
|
|
1222
|
-
String(httpPort),
|
|
1223
|
-
"--ws-port",
|
|
1224
|
-
String(wsPort),
|
|
1225
|
-
"--dist-dir",
|
|
1226
|
-
distDir,
|
|
1227
|
-
"--require-token"
|
|
1228
|
-
],
|
|
1229
|
-
{
|
|
1230
|
-
cwd: resolved,
|
|
1231
|
-
env: {
|
|
1232
|
-
...buildChildEnv(),
|
|
1233
|
-
ELECTRON_RUN_AS_NODE: "1",
|
|
1234
|
-
WEBUI_STRICT_PORT: "1",
|
|
1235
|
-
WRONGSTACK_DESKTOP: "1",
|
|
1236
|
-
// Passed by environment, not argv. A process command line is
|
|
1237
|
-
// world-readable on every platform this ships to — `ps -ef` on
|
|
1238
|
-
// POSIX, Task Manager's command-line column or a plain WMI query on
|
|
1239
|
-
// Windows — so `--token <secret>` handed the WebUI access token to
|
|
1240
|
-
// any local process that cared to look. The token grants full agent
|
|
1241
|
-
// control, so that is a credential disclosure, not a nuisance.
|
|
1242
|
-
// entry.ts already reads WEBUI_TOKEN; argv only took precedence
|
|
1243
|
-
// over it (WS-087).
|
|
1244
|
-
WEBUI_TOKEN: token
|
|
1245
|
-
},
|
|
1246
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
1247
|
-
windowsHide: true
|
|
1248
|
-
}
|
|
1249
|
-
);
|
|
1250
|
-
runtime.child = child;
|
|
1251
|
-
runtime.pid = child.pid;
|
|
1252
|
-
child.stdout?.on("data", (chunk) => {
|
|
1253
|
-
const text = chunk.toString();
|
|
1254
|
-
appendRuntimeLog(runtime, "stdout", text);
|
|
1255
|
-
runtime.lastActivityAt = Date.now();
|
|
1256
|
-
this.scheduleLogChanged(runtime);
|
|
1257
|
-
process.stdout.write(`[desktop:${runtime.id}] ${text}`);
|
|
1258
|
-
});
|
|
1259
|
-
child.stderr?.on("data", (chunk) => {
|
|
1260
|
-
const text = chunk.toString();
|
|
1261
|
-
appendRuntimeLog(runtime, "stderr", text);
|
|
1262
|
-
runtime.lastActivityAt = Date.now();
|
|
1263
|
-
this.scheduleLogChanged(runtime);
|
|
1264
|
-
process.stderr.write(`[desktop:${runtime.id}] ${text}`);
|
|
1265
|
-
});
|
|
1266
|
-
child.once("error", (err) => {
|
|
1267
|
-
runtime.status = "error";
|
|
1268
|
-
runtime.error = toErrorMessage(err);
|
|
1269
|
-
runtime.child = null;
|
|
1270
|
-
if (this.activeRuntimeId === runtime.id) {
|
|
1271
|
-
this.activeRuntimeId = firstRunningRuntimeId(this.runtimes);
|
|
1272
|
-
}
|
|
1273
|
-
this.emitChanged();
|
|
1274
|
-
});
|
|
1275
|
-
child.once("exit", (code, signal) => {
|
|
1276
|
-
if (runtime.status === "error") return;
|
|
1277
|
-
runtime.status = "stopped";
|
|
1278
|
-
runtime.error = code === 0 ? void 0 : `Exited with ${signal ?? `code ${code ?? "unknown"}`}`;
|
|
1279
|
-
runtime.child = null;
|
|
1280
|
-
if (this.activeRuntimeId === runtime.id) {
|
|
1281
|
-
this.activeRuntimeId = firstRunningRuntimeId(this.runtimes);
|
|
1282
|
-
}
|
|
1283
|
-
this.emitChanged();
|
|
1284
|
-
});
|
|
1285
|
-
await waitForHttpReady(runtime.url, token, START_TIMEOUT_MS);
|
|
1286
|
-
if (runtime.status !== "starting") {
|
|
1287
|
-
throw new Error(runtime.error ?? "WebUI process exited during startup");
|
|
1288
|
-
}
|
|
1289
|
-
runtime.status = "running";
|
|
1290
|
-
await this.persistWorkspaceState();
|
|
1291
|
-
this.emitChanged();
|
|
1292
|
-
return publicRuntime(runtime, true);
|
|
1293
|
-
} catch (err) {
|
|
1294
|
-
if (runtime.status !== "stopped" && runtime.status !== "error") {
|
|
1295
|
-
runtime.status = "error";
|
|
1296
|
-
}
|
|
1297
|
-
if (runtime.error === void 0) {
|
|
1298
|
-
runtime.error = toErrorMessage(err);
|
|
1299
|
-
}
|
|
1300
|
-
await terminateProcessTree(runtime.child);
|
|
1301
|
-
runtime.child = null;
|
|
1302
|
-
if (this.activeRuntimeId === runtime.id) {
|
|
1303
|
-
this.activeRuntimeId = firstRunningRuntimeId(this.runtimes);
|
|
1304
|
-
}
|
|
1305
|
-
this.emitChanged();
|
|
1306
|
-
throw err;
|
|
1252
|
+
);
|
|
1253
|
+
ipcMain.on(IPC.setLocale, (_event, locale) => {
|
|
1254
|
+
const result = validate(setLocaleSchema, { locale });
|
|
1255
|
+
if (!result.success) {
|
|
1256
|
+
validationLogger.log(`setLocale: ${result.error}`);
|
|
1257
|
+
return;
|
|
1307
1258
|
}
|
|
1259
|
+
ctx.getI18n().setMainLocale(result.data.locale);
|
|
1260
|
+
ctx.configureApplicationMenu();
|
|
1261
|
+
ctx.broadcastLocaleToEmbeddedWebuis(result.data.locale);
|
|
1262
|
+
void ctx.getConfigIo().writeUiLocale(result.data.locale);
|
|
1263
|
+
});
|
|
1264
|
+
}
|
|
1265
|
+
function validateOptional(schema, value) {
|
|
1266
|
+
if (value === void 0 || value === null) return void 0;
|
|
1267
|
+
const result = schema.safeParse(value);
|
|
1268
|
+
return result.success ? result.data : void 0;
|
|
1269
|
+
}
|
|
1270
|
+
function sanitizeWebuiPrefs(prefs) {
|
|
1271
|
+
const next = {};
|
|
1272
|
+
if (!isRecord2(prefs)) return next;
|
|
1273
|
+
if (typeof prefs["yolo"] === "boolean") next.yolo = prefs["yolo"];
|
|
1274
|
+
if (typeof prefs["nextPrediction"] === "boolean") next.nextPrediction = prefs["nextPrediction"];
|
|
1275
|
+
if (typeof prefs["contextAutoCompact"] === "boolean") {
|
|
1276
|
+
next.contextAutoCompact = prefs["contextAutoCompact"];
|
|
1308
1277
|
}
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1278
|
+
return next;
|
|
1279
|
+
}
|
|
1280
|
+
function isRecord2(value) {
|
|
1281
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
// src/main/state/constants.ts
|
|
1285
|
+
var OPEN_EXTERNAL_ALLOWED_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:", "mailto:"]);
|
|
1286
|
+
var SIDEBAR_WIDTH_WIDE = 292;
|
|
1287
|
+
var SIDEBAR_WIDTH_MEDIUM = 276;
|
|
1288
|
+
var SIDEBAR_WIDTH_NARROW = 252;
|
|
1289
|
+
var SIDEBAR_WIDTH_COLLAPSED = 56;
|
|
1290
|
+
var MIN_WINDOW_WIDTH = 760;
|
|
1291
|
+
var MIN_WINDOW_HEIGHT = 520;
|
|
1292
|
+
var MAX_PENDING_WEBUI_COMMANDS = 50;
|
|
1293
|
+
var MAX_PENDING_FLUSH_ATTEMPTS = 80;
|
|
1294
|
+
var WEBUI_COMMAND_FALLBACK_MS = 350;
|
|
1295
|
+
var WEBUI_COMMAND_ACK_TIMEOUT_MS = 2e3;
|
|
1296
|
+
|
|
1297
|
+
// src/main/layout/sidebar.ts
|
|
1298
|
+
function getSidebarWidth(windowWidth, collapsed) {
|
|
1299
|
+
if (collapsed) return SIDEBAR_WIDTH_COLLAPSED;
|
|
1300
|
+
if (windowWidth < 900) return SIDEBAR_WIDTH_NARROW;
|
|
1301
|
+
if (windowWidth < 1180) return SIDEBAR_WIDTH_MEDIUM;
|
|
1302
|
+
return SIDEBAR_WIDTH_WIDE;
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
// src/main/macos-platform.ts
|
|
1306
|
+
import { app, Menu } from "electron";
|
|
1307
|
+
function initMacOS() {
|
|
1308
|
+
if (process.platform !== "darwin") return;
|
|
1309
|
+
app.setActivationPolicy("regular");
|
|
1310
|
+
app.on("open-file", (_event, filePath) => {
|
|
1311
|
+
if (app.isReady()) {
|
|
1312
|
+
handleFileOpen(filePath);
|
|
1319
1313
|
}
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
throw new Error(`Desktop runtime stop denied: ${authorization.reason}`);
|
|
1314
|
+
});
|
|
1315
|
+
app.dock?.setMenu(
|
|
1316
|
+
Menu.buildFromTemplate([
|
|
1317
|
+
{
|
|
1318
|
+
label: "New Window",
|
|
1319
|
+
click: () => {
|
|
1320
|
+
}
|
|
1328
1321
|
}
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1322
|
+
])
|
|
1323
|
+
);
|
|
1324
|
+
app.setAboutPanelOptions({
|
|
1325
|
+
applicationName: "WrongStack Desktop",
|
|
1326
|
+
applicationVersion: app.getVersion(),
|
|
1327
|
+
version: process.versions.electron ? `Electron ${process.versions.electron}` : ""
|
|
1328
|
+
});
|
|
1329
|
+
}
|
|
1330
|
+
function handleFileOpen(filePath) {
|
|
1331
|
+
pendingOpenFilePath = filePath;
|
|
1332
|
+
}
|
|
1333
|
+
var pendingOpenFilePath = null;
|
|
1334
|
+
function drainPendingOpenFilePath() {
|
|
1335
|
+
const path7 = pendingOpenFilePath;
|
|
1336
|
+
pendingOpenFilePath = null;
|
|
1337
|
+
return path7;
|
|
1338
|
+
}
|
|
1339
|
+
function firstOpenFileArg(argv) {
|
|
1340
|
+
if (process.platform !== "darwin") return null;
|
|
1341
|
+
for (let index = 1; index < argv.length; index++) {
|
|
1342
|
+
const arg = argv[index];
|
|
1343
|
+
if (arg == null) continue;
|
|
1344
|
+
if (arg.startsWith("-")) continue;
|
|
1345
|
+
if (arg === "." || arg === "--") continue;
|
|
1346
|
+
return arg;
|
|
1354
1347
|
}
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1348
|
+
return null;
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
// src/main/menu/index.ts
|
|
1352
|
+
import { Menu as Menu2 } from "electron";
|
|
1353
|
+
|
|
1354
|
+
// src/main/menu/projects-menu.ts
|
|
1355
|
+
import path3 from "node:path";
|
|
1356
|
+
function buildProjectsMenu(runtimes, actions, t) {
|
|
1357
|
+
const projectGroups = groupProjectRuntimesForMenu(runtimes);
|
|
1358
|
+
const menu = [
|
|
1359
|
+
{
|
|
1360
|
+
label: t("openProjectEllipsis"),
|
|
1361
|
+
accelerator: "CmdOrCtrl+O",
|
|
1362
|
+
click: () => actions.newSession("")
|
|
1363
|
+
},
|
|
1364
|
+
{
|
|
1365
|
+
label: t("registerProjectEllipsis"),
|
|
1366
|
+
click: () => actions.registerProject?.()
|
|
1367
|
+
},
|
|
1368
|
+
{ type: "separator" }
|
|
1369
|
+
];
|
|
1370
|
+
if (projectGroups.length === 0) {
|
|
1371
|
+
menu.push({ label: t("noOpenProjectSessions"), enabled: false });
|
|
1372
|
+
return menu;
|
|
1360
1373
|
}
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
const child = runtime.child;
|
|
1366
|
-
runtime.child = null;
|
|
1367
|
-
await terminateProcessTree(child);
|
|
1368
|
-
if (runtime.logNotifyTimer) {
|
|
1369
|
-
clearTimeout(runtime.logNotifyTimer);
|
|
1370
|
-
runtime.logNotifyTimer = null;
|
|
1371
|
-
}
|
|
1372
|
-
this.runtimes.delete(id);
|
|
1373
|
-
if (this.activeRuntimeId === id) this.activeRuntimeId = firstRunningRuntimeId(this.runtimes);
|
|
1374
|
-
if (this.lastActiveProjectRoot && samePath(this.lastActiveProjectRoot, runtime.root)) {
|
|
1375
|
-
this.lastActiveProjectRoot = firstProjectRuntimeRoot(this.runtimes);
|
|
1376
|
-
}
|
|
1377
|
-
if (options.persistWorkspace) {
|
|
1378
|
-
await this.persistWorkspaceState();
|
|
1379
|
-
}
|
|
1380
|
-
this.emitChanged();
|
|
1381
|
-
}
|
|
1382
|
-
async touchProject(projectRoot) {
|
|
1383
|
-
const resolved = path3.resolve(projectRoot);
|
|
1384
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1385
|
-
const entry = {
|
|
1386
|
-
name: path3.basename(resolved) || resolved,
|
|
1387
|
-
root: resolved,
|
|
1388
|
-
slug: projectSlug(resolved),
|
|
1389
|
-
lastSeen: now,
|
|
1390
|
-
lastWorkingDir: resolved
|
|
1391
|
-
};
|
|
1392
|
-
this.recentProjects = [
|
|
1393
|
-
entry,
|
|
1394
|
-
...this.recentProjects.filter((p) => !samePath(p.root, resolved))
|
|
1395
|
-
].slice(0, 24);
|
|
1396
|
-
const [, registeredProjects] = await Promise.all([
|
|
1397
|
-
this.saveDesktopState(),
|
|
1398
|
-
touchGlobalProjectManifest(entry)
|
|
1399
|
-
]);
|
|
1400
|
-
this.registeredProjects = registeredProjects;
|
|
1401
|
-
}
|
|
1402
|
-
async persistWorkspaceState() {
|
|
1403
|
-
await this.saveDesktopState();
|
|
1404
|
-
}
|
|
1405
|
-
async loadDesktopState() {
|
|
1406
|
-
try {
|
|
1407
|
-
const raw = await fs2.readFile(this.stateFile, "utf8");
|
|
1408
|
-
const parsed = JSON.parse(raw);
|
|
1409
|
-
const openProjects = normalizePathList(parsed.openProjects);
|
|
1410
|
-
const openProjectSessions = normalizeSessionStateList(
|
|
1411
|
-
parsed.openProjectSessions,
|
|
1412
|
-
openProjects
|
|
1413
|
-
);
|
|
1414
|
-
return {
|
|
1415
|
-
recentProjects: normalizeProjectEntries(parsed.recentProjects),
|
|
1416
|
-
openProjects,
|
|
1417
|
-
openProjectSessions,
|
|
1418
|
-
activeRuntimeId: normalizeRuntimeId(parsed.activeRuntimeId) ?? null,
|
|
1419
|
-
activeProjectRoot: typeof parsed.activeProjectRoot === "string" && parsed.activeProjectRoot.trim() ? path3.resolve(parsed.activeProjectRoot) : null,
|
|
1420
|
-
window: normalizeWindowState(parsed.window)
|
|
1421
|
-
};
|
|
1422
|
-
} catch {
|
|
1423
|
-
return {
|
|
1424
|
-
recentProjects: [],
|
|
1425
|
-
openProjects: [],
|
|
1426
|
-
openProjectSessions: [],
|
|
1427
|
-
activeRuntimeId: null,
|
|
1428
|
-
activeProjectRoot: null,
|
|
1429
|
-
window: null
|
|
1430
|
-
};
|
|
1431
|
-
}
|
|
1432
|
-
}
|
|
1433
|
-
async saveDesktopState() {
|
|
1434
|
-
await fs2.mkdir(path3.dirname(this.stateFile), { recursive: true });
|
|
1435
|
-
const liveProjectSessions = Array.from(this.runtimes.values()).filter((runtime) => runtime.status !== "stopped" && runtime.kind === "project").map((runtime) => runtimeToSessionState(runtime));
|
|
1436
|
-
const openProjectSessions = liveProjectSessions.length === 0 && !this.workspaceRestoreCompleted ? [...this.restoreProjectSessions] : liveProjectSessions;
|
|
1437
|
-
const openProjects = openProjectSessions.map((session) => session.root);
|
|
1438
|
-
const activeRuntime = this.activeRuntimeId ? this.runtimes.get(this.activeRuntimeId) : null;
|
|
1439
|
-
const lastActiveProjectRoot = this.lastActiveProjectRoot;
|
|
1440
|
-
const fallbackSession = lastActiveProjectRoot ? openProjectSessions.find((session) => samePath(session.root, lastActiveProjectRoot)) : void 0;
|
|
1441
|
-
const activeSession = activeRuntime?.kind === "project" ? runtimeToSessionState(activeRuntime) : fallbackSession ?? openProjectSessions[0];
|
|
1442
|
-
const activeRoot = activeSession?.root;
|
|
1443
|
-
const activeRuntimeId = activeSession?.runtimeId ?? null;
|
|
1444
|
-
await atomicWrite2(
|
|
1445
|
-
this.stateFile,
|
|
1446
|
-
`${JSON.stringify(
|
|
1374
|
+
for (const group of projectGroups) {
|
|
1375
|
+
menu.push({
|
|
1376
|
+
label: group.name,
|
|
1377
|
+
submenu: [
|
|
1447
1378
|
{
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
activeRuntimeId,
|
|
1452
|
-
activeProjectRoot: activeRoot ?? null,
|
|
1453
|
-
window: this.windowState
|
|
1379
|
+
label: t("newSession"),
|
|
1380
|
+
click: () => actions.newSession(group.sessions[0]?.id ?? ""),
|
|
1381
|
+
enabled: Boolean(group.sessions[0])
|
|
1454
1382
|
},
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
this.emit("changed");
|
|
1464
|
-
}
|
|
1465
|
-
/**
|
|
1466
|
-
* Notify the shell that a runtime produced output.
|
|
1467
|
-
*
|
|
1468
|
-
* Only the ACTIVE runtime's logs reach the renderer (see `publicRuntime`), so
|
|
1469
|
-
* output from a background project has nothing to show and must not cost a
|
|
1470
|
-
* broadcast. Before this guard, every project writing to stdout scheduled its
|
|
1471
|
-
* own 250 ms timer, and each one fired a FULL snapshot: N chatty projects
|
|
1472
|
-
* produced 4N broadcasts per second carrying N x 40 log lines each, for a
|
|
1473
|
-
* panel that displays one runtime's output.
|
|
1474
|
-
*
|
|
1475
|
-
* The 250 ms debounce is per-runtime by construction (the timer lives on the
|
|
1476
|
-
* runtime record) but only one runtime can be active, so at most one such
|
|
1477
|
-
* timer is ever armed now.
|
|
1478
|
-
*/
|
|
1479
|
-
/**
|
|
1480
|
-
* Begin reclaiming idle project servers.
|
|
1481
|
-
*
|
|
1482
|
-
* Idempotent, and a no-op when the timeout is disabled. The interval is
|
|
1483
|
-
* unref'd so a pending sweep never holds the process open during quit.
|
|
1484
|
-
*/
|
|
1485
|
-
startIdleSweep(options = {}) {
|
|
1486
|
-
if (this.idleSweepTimer) return;
|
|
1487
|
-
this.idleTimeoutMs = options.idleTimeoutMs ?? resolveIdleTimeoutMs();
|
|
1488
|
-
if (this.idleTimeoutMs <= 0) return;
|
|
1489
|
-
this.idleSweepTimer = setInterval(() => {
|
|
1490
|
-
void this.sweepIdleRuntimes();
|
|
1491
|
-
}, IDLE_SWEEP_INTERVAL_MS);
|
|
1492
|
-
this.idleSweepTimer.unref?.();
|
|
1493
|
-
}
|
|
1494
|
-
stopIdleSweep() {
|
|
1495
|
-
if (!this.idleSweepTimer) return;
|
|
1496
|
-
clearInterval(this.idleSweepTimer);
|
|
1497
|
-
this.idleSweepTimer = null;
|
|
1498
|
-
}
|
|
1499
|
-
/** One pass. Exposed so a test can drive it without waiting on the interval. */
|
|
1500
|
-
async sweepIdleRuntimes(now = Date.now()) {
|
|
1501
|
-
const ids = reclaimableRuntimeIds(this.runtimes, {
|
|
1502
|
-
activeRuntimeId: this.activeRuntimeId,
|
|
1503
|
-
idleTimeoutMs: this.idleTimeoutMs,
|
|
1504
|
-
now
|
|
1383
|
+
{
|
|
1384
|
+
label: t("revealProjectFolder"),
|
|
1385
|
+
click: () => actions.reveal(group.sessions[0]?.id ?? ""),
|
|
1386
|
+
enabled: Boolean(group.sessions[0])
|
|
1387
|
+
},
|
|
1388
|
+
{ type: "separator" },
|
|
1389
|
+
...group.sessions.map((runtime, index) => buildSessionMenu(runtime, index + 1, actions, t))
|
|
1390
|
+
]
|
|
1505
1391
|
});
|
|
1506
|
-
for (const id of ids) {
|
|
1507
|
-
await this.closeRuntimeInternal(id, { persistWorkspace: false });
|
|
1508
|
-
}
|
|
1509
|
-
return ids;
|
|
1510
|
-
}
|
|
1511
|
-
scheduleLogChanged(runtime) {
|
|
1512
|
-
if (runtime.id !== this.activeRuntimeId) return;
|
|
1513
|
-
if (runtime.logNotifyTimer) return;
|
|
1514
|
-
runtime.logNotifyTimer = setTimeout(() => {
|
|
1515
|
-
runtime.logNotifyTimer = null;
|
|
1516
|
-
if (this.runtimes.get(runtime.id) === runtime && runtime.id === this.activeRuntimeId) {
|
|
1517
|
-
this.emitChanged();
|
|
1518
|
-
}
|
|
1519
|
-
}, 250);
|
|
1520
|
-
}
|
|
1521
|
-
};
|
|
1522
|
-
function hasChildExited(child) {
|
|
1523
|
-
return child.exitCode !== null || child.signalCode !== null;
|
|
1524
|
-
}
|
|
1525
|
-
function waitForChildExit(child, timeoutMs) {
|
|
1526
|
-
if (hasChildExited(child)) return Promise.resolve(true);
|
|
1527
|
-
return new Promise((resolve5) => {
|
|
1528
|
-
let settled = false;
|
|
1529
|
-
const finish = (exited) => {
|
|
1530
|
-
if (settled) return;
|
|
1531
|
-
settled = true;
|
|
1532
|
-
clearTimeout(timer);
|
|
1533
|
-
child.off("exit", onExit);
|
|
1534
|
-
resolve5(exited);
|
|
1535
|
-
};
|
|
1536
|
-
const onExit = () => finish(true);
|
|
1537
|
-
const timer = setTimeout(() => finish(hasChildExited(child)), timeoutMs);
|
|
1538
|
-
timer.unref?.();
|
|
1539
|
-
child.once("exit", onExit);
|
|
1540
|
-
if (hasChildExited(child)) finish(true);
|
|
1541
|
-
});
|
|
1542
|
-
}
|
|
1543
|
-
async function terminateProcessTree(child) {
|
|
1544
|
-
if (!child?.pid || hasChildExited(child)) return;
|
|
1545
|
-
if (process.platform !== "win32") {
|
|
1546
|
-
const pid = child.pid;
|
|
1547
|
-
const exited = waitForChildExit(child, 5e3);
|
|
1548
|
-
child.kill("SIGTERM");
|
|
1549
|
-
if (await exited) return;
|
|
1550
|
-
if (!hasChildExited(child)) {
|
|
1551
|
-
try {
|
|
1552
|
-
process.kill(-pid, "SIGKILL");
|
|
1553
|
-
} catch {
|
|
1554
|
-
child.kill("SIGKILL");
|
|
1555
|
-
}
|
|
1556
|
-
await waitForChildExit(child, 1e3);
|
|
1557
|
-
}
|
|
1558
|
-
return;
|
|
1559
1392
|
}
|
|
1560
|
-
|
|
1561
|
-
let settled = false;
|
|
1562
|
-
const finish = () => {
|
|
1563
|
-
if (settled) return;
|
|
1564
|
-
settled = true;
|
|
1565
|
-
resolve5();
|
|
1566
|
-
};
|
|
1567
|
-
const timer = setTimeout(finish, 3e3);
|
|
1568
|
-
timer.unref?.();
|
|
1569
|
-
const killer = spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], {
|
|
1570
|
-
stdio: "ignore",
|
|
1571
|
-
windowsHide: true
|
|
1572
|
-
});
|
|
1573
|
-
killer.once("exit", () => {
|
|
1574
|
-
clearTimeout(timer);
|
|
1575
|
-
finish();
|
|
1576
|
-
});
|
|
1577
|
-
killer.once("error", () => {
|
|
1578
|
-
clearTimeout(timer);
|
|
1579
|
-
child.kill();
|
|
1580
|
-
finish();
|
|
1581
|
-
});
|
|
1582
|
-
});
|
|
1583
|
-
}
|
|
1584
|
-
var SNAPSHOT_LOG_LINES = 40;
|
|
1585
|
-
function publicRuntime(runtime, includeLogs) {
|
|
1586
|
-
const {
|
|
1587
|
-
child: _child,
|
|
1588
|
-
token: _token,
|
|
1589
|
-
logs,
|
|
1590
|
-
logNotifyTimer: _logNotifyTimer,
|
|
1591
|
-
...record
|
|
1592
|
-
} = runtime;
|
|
1593
|
-
void _child;
|
|
1594
|
-
void _token;
|
|
1595
|
-
void _logNotifyTimer;
|
|
1596
|
-
if (!includeLogs) return record;
|
|
1597
|
-
return {
|
|
1598
|
-
...record,
|
|
1599
|
-
recentLogs: logs.slice(-SNAPSHOT_LOG_LINES)
|
|
1600
|
-
};
|
|
1393
|
+
return menu;
|
|
1601
1394
|
}
|
|
1602
|
-
function
|
|
1395
|
+
function buildSessionMenu(runtime, index, actions, t) {
|
|
1396
|
+
const running = runtime.status === "running";
|
|
1397
|
+
const label = `${t("session")} ${index} \xB7 ${runtime.status}`;
|
|
1603
1398
|
return {
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
}
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1399
|
+
label,
|
|
1400
|
+
submenu: [
|
|
1401
|
+
{
|
|
1402
|
+
label: t("quickView"),
|
|
1403
|
+
click: () => actions.activate(runtime.id)
|
|
1404
|
+
},
|
|
1405
|
+
{
|
|
1406
|
+
label: "WebUI",
|
|
1407
|
+
enabled: running,
|
|
1408
|
+
submenu: [
|
|
1409
|
+
{
|
|
1410
|
+
label: t("chat"),
|
|
1411
|
+
click: () => actions.activateAndNavigate(runtime.id, { activity: "chat", view: "chat" })
|
|
1412
|
+
},
|
|
1413
|
+
{
|
|
1414
|
+
label: t("focusPrompt"),
|
|
1415
|
+
click: () => actions.activateAndNavigate(runtime.id, { action: "focus-chat" })
|
|
1416
|
+
},
|
|
1417
|
+
{
|
|
1418
|
+
label: t("terminal"),
|
|
1419
|
+
click: () => actions.activateAndNavigate(runtime.id, { terminal: "toggle" })
|
|
1420
|
+
},
|
|
1421
|
+
{
|
|
1422
|
+
label: t("newTerminal"),
|
|
1423
|
+
click: () => actions.activateAndNavigate(runtime.id, { terminal: "new" })
|
|
1424
|
+
},
|
|
1425
|
+
{ type: "separator" },
|
|
1426
|
+
{
|
|
1427
|
+
label: t("files"),
|
|
1428
|
+
click: () => actions.activateAndNavigate(runtime.id, { activity: "files", view: "files" })
|
|
1429
|
+
},
|
|
1430
|
+
{
|
|
1431
|
+
label: t("changes"),
|
|
1432
|
+
click: () => actions.activateAndNavigate(runtime.id, { activity: "changes", view: "changes" })
|
|
1433
|
+
},
|
|
1434
|
+
{
|
|
1435
|
+
label: t("sessions"),
|
|
1436
|
+
click: () => actions.activateAndNavigate(runtime.id, { view: "sessions" })
|
|
1437
|
+
},
|
|
1438
|
+
{
|
|
1439
|
+
label: t("fleetHQ"),
|
|
1440
|
+
click: () => actions.activateAndNavigate(runtime.id, { view: "roster" })
|
|
1441
|
+
},
|
|
1442
|
+
{
|
|
1443
|
+
label: t("settings"),
|
|
1444
|
+
click: () => actions.activateAndNavigate(runtime.id, { view: "settings" })
|
|
1445
|
+
},
|
|
1446
|
+
{ type: "separator" },
|
|
1447
|
+
{
|
|
1448
|
+
label: t("commandPalette"),
|
|
1449
|
+
click: () => actions.activateAndNavigate(runtime.id, { action: "open-command-palette" })
|
|
1450
|
+
},
|
|
1451
|
+
{
|
|
1452
|
+
label: t("modelSwitcher"),
|
|
1453
|
+
click: () => actions.activateAndNavigate(runtime.id, { action: "open-model-switcher" })
|
|
1454
|
+
}
|
|
1455
|
+
]
|
|
1456
|
+
},
|
|
1457
|
+
{ type: "separator" },
|
|
1458
|
+
{
|
|
1459
|
+
label: t("openInBrowser"),
|
|
1460
|
+
enabled: running,
|
|
1461
|
+
click: () => actions.openBrowser(runtime.id)
|
|
1462
|
+
},
|
|
1463
|
+
{
|
|
1464
|
+
label: t("reloadWebui"),
|
|
1465
|
+
enabled: running,
|
|
1466
|
+
click: () => actions.reload(runtime.id)
|
|
1467
|
+
},
|
|
1468
|
+
{
|
|
1469
|
+
label: t("closeSession"),
|
|
1470
|
+
click: () => actions.close(runtime.id)
|
|
1471
|
+
}
|
|
1472
|
+
]
|
|
1473
|
+
};
|
|
1629
1474
|
}
|
|
1630
|
-
function
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
if (
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
const session = {
|
|
1640
|
-
root: path3.resolve(candidate.root)
|
|
1641
|
-
};
|
|
1642
|
-
const runtimeId = normalizeRuntimeId(candidate.runtimeId);
|
|
1643
|
-
if (runtimeId) session.runtimeId = runtimeId;
|
|
1644
|
-
if (typeof candidate.name === "string" && candidate.name.trim()) {
|
|
1645
|
-
session.name = candidate.name.trim().slice(0, 120);
|
|
1646
|
-
}
|
|
1647
|
-
if (typeof candidate.startedAt === "string" && candidate.startedAt.trim()) {
|
|
1648
|
-
session.startedAt = candidate.startedAt.trim();
|
|
1475
|
+
function groupProjectRuntimesForMenu(runtimes) {
|
|
1476
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1477
|
+
for (const runtime of runtimes) {
|
|
1478
|
+
if (runtime.kind !== "project") continue;
|
|
1479
|
+
const key = normalizeMenuRoot(runtime.root);
|
|
1480
|
+
const existing = groups.get(key);
|
|
1481
|
+
if (existing) {
|
|
1482
|
+
existing.sessions.push(runtime);
|
|
1483
|
+
continue;
|
|
1649
1484
|
}
|
|
1650
|
-
|
|
1485
|
+
groups.set(key, {
|
|
1486
|
+
key,
|
|
1487
|
+
name: path3.basename(runtime.root) || runtime.name,
|
|
1488
|
+
root: runtime.root,
|
|
1489
|
+
sessions: [runtime]
|
|
1490
|
+
});
|
|
1651
1491
|
}
|
|
1652
|
-
return
|
|
1492
|
+
return [...groups.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
1653
1493
|
}
|
|
1654
|
-
function
|
|
1655
|
-
|
|
1656
|
-
const trimmed = value.trim();
|
|
1657
|
-
if (!/^[a-zA-Z0-9._:-]{3,120}$/.test(trimmed)) return void 0;
|
|
1658
|
-
return trimmed;
|
|
1494
|
+
function normalizeMenuRoot(root) {
|
|
1495
|
+
return root.replace(/\\/g, "/").replace(/\/+$/g, "").toLowerCase();
|
|
1659
1496
|
}
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1497
|
+
|
|
1498
|
+
// src/main/menu/sections.ts
|
|
1499
|
+
function buildFileMenu(ctx, _actions, hasActiveRuntime, hasActiveProjectWebui, active, navigate, getActiveRuntimeId) {
|
|
1500
|
+
return {
|
|
1501
|
+
label: ctx.t("file"),
|
|
1502
|
+
submenu: [
|
|
1503
|
+
{
|
|
1504
|
+
label: ctx.t("openProjectEllipsis"),
|
|
1505
|
+
accelerator: "CmdOrCtrl+O",
|
|
1506
|
+
click: () => void ctx.openProject()
|
|
1507
|
+
},
|
|
1508
|
+
{ label: ctx.t("registerProjectEllipsis"), click: () => void ctx.registerProject() },
|
|
1509
|
+
{
|
|
1510
|
+
label: ctx.t("removeActiveFromRegistry"),
|
|
1511
|
+
enabled: hasActiveProjectWebui,
|
|
1512
|
+
click: () => {
|
|
1513
|
+
if (active?.kind === "project") void ctx.unregisterProject(active.root);
|
|
1514
|
+
}
|
|
1515
|
+
},
|
|
1516
|
+
{ type: "separator" },
|
|
1517
|
+
{
|
|
1518
|
+
label: ctx.t("newSessionForActive"),
|
|
1519
|
+
accelerator: "CmdOrCtrl+N",
|
|
1520
|
+
enabled: hasActiveProjectWebui,
|
|
1521
|
+
click: () => void ctx.openProjectSession(active?.id)
|
|
1522
|
+
},
|
|
1523
|
+
{
|
|
1524
|
+
label: ctx.t("settings"),
|
|
1525
|
+
accelerator: "CmdOrCtrl+,",
|
|
1526
|
+
click: () => {
|
|
1527
|
+
if (hasActiveRuntime) navigate({ view: "settings" });
|
|
1528
|
+
else void ctx.openSettings();
|
|
1529
|
+
}
|
|
1530
|
+
},
|
|
1531
|
+
{ type: "separator" },
|
|
1532
|
+
{
|
|
1533
|
+
label: ctx.t("closeActiveRuntime"),
|
|
1534
|
+
accelerator: "CmdOrCtrl+W",
|
|
1535
|
+
enabled: hasActiveRuntime,
|
|
1536
|
+
click: () => {
|
|
1537
|
+
const id = getActiveRuntimeId();
|
|
1538
|
+
if (id) void ctx.closeRuntime(id);
|
|
1539
|
+
}
|
|
1540
|
+
},
|
|
1541
|
+
{ type: "separator" },
|
|
1542
|
+
{
|
|
1543
|
+
role: process.platform === "darwin" ? "close" : "quit"
|
|
1544
|
+
}
|
|
1545
|
+
]
|
|
1671
1546
|
};
|
|
1672
|
-
if (Number.isFinite(Number(candidate.x))) state.x = Math.round(Number(candidate.x));
|
|
1673
|
-
if (Number.isFinite(Number(candidate.y))) state.y = Math.round(Number(candidate.y));
|
|
1674
|
-
return state;
|
|
1675
|
-
}
|
|
1676
|
-
function firstRunningRuntimeId(runtimes) {
|
|
1677
|
-
return Array.from(runtimes.values()).find((runtime) => runtime.status === "running")?.id ?? null;
|
|
1678
1547
|
}
|
|
1679
|
-
function
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
for (const runtime of runtimes.values()) {
|
|
1687
|
-
ports.add(runtime.httpPort);
|
|
1688
|
-
ports.add(runtime.wsPort);
|
|
1689
|
-
}
|
|
1690
|
-
return ports;
|
|
1691
|
-
}
|
|
1692
|
-
function nextRuntimeName(runtimes, root, kind) {
|
|
1693
|
-
const baseName = path3.basename(root) || root;
|
|
1694
|
-
if (kind !== "project") return baseName;
|
|
1695
|
-
const liveSameRoot = Array.from(runtimes.values()).filter(
|
|
1696
|
-
(runtime) => runtime.kind === "project" && samePath(runtime.root, root) && runtime.status !== "stopped"
|
|
1697
|
-
).length;
|
|
1698
|
-
return liveSameRoot === 0 ? baseName : `${baseName} #${liveSameRoot + 1}`;
|
|
1699
|
-
}
|
|
1700
|
-
function pathKey(value) {
|
|
1701
|
-
const resolved = path3.resolve(value);
|
|
1702
|
-
return os.platform() === "win32" ? resolved.toLowerCase() : resolved;
|
|
1703
|
-
}
|
|
1704
|
-
async function findFreePort(startPort, exclude) {
|
|
1705
|
-
for (let port = startPort; port < startPort + 200; port++) {
|
|
1706
|
-
if (exclude.has(port)) continue;
|
|
1707
|
-
if (await isPortFree(port)) return port;
|
|
1708
|
-
}
|
|
1709
|
-
throw new Error(`No free local port found near ${startPort}`);
|
|
1710
|
-
}
|
|
1711
|
-
function isPortFree(port) {
|
|
1712
|
-
return new Promise((resolve5) => {
|
|
1713
|
-
const server = net.createServer();
|
|
1714
|
-
server.once("error", () => resolve5(false));
|
|
1715
|
-
server.once("listening", () => {
|
|
1716
|
-
server.close(() => resolve5(true));
|
|
1717
|
-
});
|
|
1718
|
-
server.listen(port, "127.0.0.1");
|
|
1719
|
-
});
|
|
1720
|
-
}
|
|
1721
|
-
function waitForHttpReady(baseUrl, token, timeoutMs) {
|
|
1722
|
-
const deadline = Date.now() + timeoutMs;
|
|
1723
|
-
const url = new URL(baseUrl);
|
|
1724
|
-
url.searchParams.set("token", token);
|
|
1725
|
-
url.searchParams.set("shell", "desktop");
|
|
1726
|
-
return new Promise((resolve5, reject) => {
|
|
1727
|
-
let probeTimer;
|
|
1728
|
-
const cleanup = () => {
|
|
1729
|
-
if (probeTimer) {
|
|
1730
|
-
clearTimeout(probeTimer);
|
|
1731
|
-
probeTimer = void 0;
|
|
1732
|
-
}
|
|
1733
|
-
};
|
|
1734
|
-
const probe = () => {
|
|
1735
|
-
let done = false;
|
|
1736
|
-
const triggerRetry = () => {
|
|
1737
|
-
if (done) return;
|
|
1738
|
-
done = true;
|
|
1739
|
-
if (Date.now() >= deadline) {
|
|
1740
|
-
reject(new Error(`WebUI did not become ready at ${baseUrl}`));
|
|
1741
|
-
return;
|
|
1742
|
-
}
|
|
1743
|
-
probeTimer = setTimeout(probe, 250);
|
|
1744
|
-
};
|
|
1745
|
-
const req = http.get(url, (res) => {
|
|
1746
|
-
res.resume();
|
|
1747
|
-
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 500) {
|
|
1748
|
-
if (!done) {
|
|
1749
|
-
done = true;
|
|
1750
|
-
cleanup();
|
|
1751
|
-
resolve5();
|
|
1752
|
-
}
|
|
1753
|
-
return;
|
|
1754
|
-
}
|
|
1755
|
-
triggerRetry();
|
|
1756
|
-
});
|
|
1757
|
-
req.once("error", () => {
|
|
1758
|
-
triggerRetry();
|
|
1759
|
-
});
|
|
1760
|
-
req.setTimeout(1e3, () => {
|
|
1761
|
-
req.destroy();
|
|
1762
|
-
triggerRetry();
|
|
1763
|
-
});
|
|
1764
|
-
};
|
|
1765
|
-
probe();
|
|
1548
|
+
function buildWorkspaceMenu(ctx, _actions, hasActiveWebui, prefs, navigate) {
|
|
1549
|
+
const yoloChecked = prefs?.yolo === true;
|
|
1550
|
+
const nextPredictionChecked = prefs?.nextPrediction === true;
|
|
1551
|
+
const contextAutoCompactChecked = prefs?.contextAutoCompact === true;
|
|
1552
|
+
const webuiItem = (item) => ({
|
|
1553
|
+
...item,
|
|
1554
|
+
enabled: item.enabled ?? hasActiveWebui
|
|
1766
1555
|
});
|
|
1556
|
+
return {
|
|
1557
|
+
label: ctx.t("workspace"),
|
|
1558
|
+
submenu: [
|
|
1559
|
+
webuiItem({
|
|
1560
|
+
label: ctx.t("openChat"),
|
|
1561
|
+
accelerator: "CmdOrCtrl+1",
|
|
1562
|
+
click: () => navigate({ activity: "chat", view: "chat" })
|
|
1563
|
+
}),
|
|
1564
|
+
webuiItem({
|
|
1565
|
+
label: ctx.t("focusPrompt"),
|
|
1566
|
+
accelerator: "CmdOrCtrl+/",
|
|
1567
|
+
click: () => navigate({ action: "focus-chat" })
|
|
1568
|
+
}),
|
|
1569
|
+
webuiItem({
|
|
1570
|
+
label: ctx.t("toggleTerminal"),
|
|
1571
|
+
accelerator: "CmdOrCtrl+`",
|
|
1572
|
+
click: () => navigate({ terminal: "toggle" })
|
|
1573
|
+
}),
|
|
1574
|
+
webuiItem({ label: ctx.t("newTerminal"), click: () => navigate({ terminal: "new" }) }),
|
|
1575
|
+
{ type: "separator" },
|
|
1576
|
+
webuiItem({
|
|
1577
|
+
label: ctx.t("commandPalette"),
|
|
1578
|
+
accelerator: "CmdOrCtrl+K",
|
|
1579
|
+
click: () => navigate({ action: "open-command-palette" })
|
|
1580
|
+
}),
|
|
1581
|
+
webuiItem({
|
|
1582
|
+
label: ctx.t("quickModelSwitcher"),
|
|
1583
|
+
accelerator: "CmdOrCtrl+M",
|
|
1584
|
+
click: () => navigate({ action: "open-model-switcher" })
|
|
1585
|
+
}),
|
|
1586
|
+
webuiItem({
|
|
1587
|
+
type: "checkbox",
|
|
1588
|
+
label: ctx.t("yoloMode"),
|
|
1589
|
+
checked: yoloChecked,
|
|
1590
|
+
accelerator: "CmdOrCtrl+Shift+Y",
|
|
1591
|
+
click: () => navigate({ pref: { key: "yolo", toggle: true } })
|
|
1592
|
+
}),
|
|
1593
|
+
webuiItem({
|
|
1594
|
+
type: "checkbox",
|
|
1595
|
+
label: ctx.t("nextPrediction"),
|
|
1596
|
+
checked: nextPredictionChecked,
|
|
1597
|
+
click: () => navigate({ pref: { key: "nextPrediction", toggle: true } })
|
|
1598
|
+
}),
|
|
1599
|
+
webuiItem({
|
|
1600
|
+
type: "checkbox",
|
|
1601
|
+
label: ctx.t("contextAutoCompact"),
|
|
1602
|
+
checked: contextAutoCompactChecked,
|
|
1603
|
+
click: () => navigate({ pref: { key: "contextAutoCompact", toggle: true } })
|
|
1604
|
+
}),
|
|
1605
|
+
{ type: "separator" },
|
|
1606
|
+
webuiItem({
|
|
1607
|
+
label: ctx.t("reloadActiveWebui"),
|
|
1608
|
+
accelerator: "CmdOrCtrl+Shift+R",
|
|
1609
|
+
click: () => void ctx.reloadActiveWebuiView()
|
|
1610
|
+
})
|
|
1611
|
+
]
|
|
1612
|
+
};
|
|
1767
1613
|
}
|
|
1768
|
-
|
|
1769
|
-
|
|
1614
|
+
function buildViewMenu(ctx) {
|
|
1615
|
+
return {
|
|
1616
|
+
label: ctx.t("view"),
|
|
1617
|
+
submenu: [
|
|
1618
|
+
{
|
|
1619
|
+
type: "checkbox",
|
|
1620
|
+
label: ctx.t("compactDesktopSidebar"),
|
|
1621
|
+
accelerator: "CmdOrCtrl+B",
|
|
1622
|
+
checked: ctx.getShellSidebarCollapsed(),
|
|
1623
|
+
click: () => ctx.setShellSidebarCollapsed(!ctx.getShellSidebarCollapsed())
|
|
1624
|
+
},
|
|
1625
|
+
{ type: "separator" },
|
|
1626
|
+
{ role: "reload" },
|
|
1627
|
+
{ role: "toggleDevTools" },
|
|
1628
|
+
{ type: "separator" },
|
|
1629
|
+
{ role: "resetZoom" },
|
|
1630
|
+
{ role: "zoomIn" },
|
|
1631
|
+
{ role: "zoomOut" },
|
|
1632
|
+
{ type: "separator" },
|
|
1633
|
+
{ role: "togglefullscreen" }
|
|
1634
|
+
]
|
|
1635
|
+
};
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
// src/main/menu/index.ts
|
|
1639
|
+
function configureApplicationMenu(ctx) {
|
|
1640
|
+
const snapshot = ctx.getSnapshot();
|
|
1641
|
+
const active = ctx.getActiveRuntime();
|
|
1642
|
+
const hasActiveRuntime = Boolean(active);
|
|
1643
|
+
const hasActiveWebui = active?.status === "running";
|
|
1644
|
+
const hasActiveProjectWebui = hasActiveWebui && active?.kind === "project";
|
|
1645
|
+
const activeWebuiPrefs = ctx.getActiveWebuiPrefs();
|
|
1646
|
+
const navigate = (command) => {
|
|
1647
|
+
void ctx.dispatchWebuiCommand(command);
|
|
1648
|
+
};
|
|
1649
|
+
const activateAndNavigate = (runtimeId, command) => {
|
|
1650
|
+
void ctx.activateRuntime(runtimeId).then(() => ctx.dispatchWebuiCommand(command));
|
|
1651
|
+
};
|
|
1652
|
+
const reloadRuntimeWebui = (runtimeId) => {
|
|
1653
|
+
void ctx.activateRuntime(runtimeId).then(() => ctx.reloadActiveWebuiView());
|
|
1654
|
+
};
|
|
1655
|
+
const actions = {
|
|
1656
|
+
activate: (runtimeId) => void ctx.activateRuntime(runtimeId),
|
|
1657
|
+
activateAndNavigate,
|
|
1658
|
+
registerProject: () => void ctx.registerProject(),
|
|
1659
|
+
newSession: (runtimeId) => {
|
|
1660
|
+
if (runtimeId) void ctx.openProjectSession(runtimeId);
|
|
1661
|
+
else void ctx.openProject();
|
|
1662
|
+
},
|
|
1663
|
+
openBrowser: (runtimeId) => {
|
|
1664
|
+
const url = ctx.getRuntimeManager().getRuntimeUrlWithToken(runtimeId);
|
|
1665
|
+
if (url) ctx.openExternal(url);
|
|
1666
|
+
},
|
|
1667
|
+
reload: reloadRuntimeWebui,
|
|
1668
|
+
close: (runtimeId) => void ctx.closeRuntime(runtimeId),
|
|
1669
|
+
reveal: (runtimeId) => {
|
|
1670
|
+
const runtime = ctx.getRuntimeManager().getRuntime(runtimeId);
|
|
1671
|
+
if (runtime) ctx.revealInExplorer(runtime.root);
|
|
1672
|
+
}
|
|
1673
|
+
};
|
|
1674
|
+
const template = [
|
|
1675
|
+
buildFileMenu(
|
|
1676
|
+
ctx,
|
|
1677
|
+
actions,
|
|
1678
|
+
hasActiveRuntime,
|
|
1679
|
+
hasActiveProjectWebui,
|
|
1680
|
+
active,
|
|
1681
|
+
navigate,
|
|
1682
|
+
ctx.getActiveRuntimeId
|
|
1683
|
+
),
|
|
1684
|
+
{
|
|
1685
|
+
label: ctx.t("projects"),
|
|
1686
|
+
submenu: buildProjectsMenu(snapshot.runtimes, actions, ctx.t)
|
|
1687
|
+
},
|
|
1688
|
+
buildWorkspaceMenu(ctx, actions, hasActiveWebui, activeWebuiPrefs, navigate),
|
|
1689
|
+
buildViewMenu(ctx)
|
|
1690
|
+
];
|
|
1691
|
+
Menu2.setApplicationMenu(Menu2.buildFromTemplate(template));
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
// src/main/runtime/operations.ts
|
|
1695
|
+
import * as fs4 from "node:fs/promises";
|
|
1696
|
+
|
|
1697
|
+
// src/main/runtime-manager.ts
|
|
1698
|
+
import { spawn } from "node:child_process";
|
|
1699
|
+
import { randomBytes } from "node:crypto";
|
|
1700
|
+
import { EventEmitter as EventEmitter2 } from "node:events";
|
|
1701
|
+
import * as fs3 from "node:fs/promises";
|
|
1702
|
+
import * as http from "node:http";
|
|
1703
|
+
import * as net from "node:net";
|
|
1704
|
+
import * as os from "node:os";
|
|
1705
|
+
import * as path5 from "node:path";
|
|
1706
|
+
import {
|
|
1707
|
+
atomicWrite as atomicWrite2,
|
|
1708
|
+
buildChildEnv,
|
|
1709
|
+
projectSlug,
|
|
1710
|
+
resolveWstackPaths as resolveWstackPaths2,
|
|
1711
|
+
toErrorMessage,
|
|
1712
|
+
wstackGlobalRoot as wstackGlobalRoot2
|
|
1713
|
+
} from "@wrongstack/core/utils";
|
|
1714
|
+
|
|
1715
|
+
// src/main/runtime-manager-paths.ts
|
|
1716
|
+
import { existsSync } from "node:fs";
|
|
1717
|
+
import { createRequire } from "node:module";
|
|
1718
|
+
import * as path4 from "node:path";
|
|
1719
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
1720
|
+
import { resolveWstackPaths } from "@wrongstack/core/utils";
|
|
1721
|
+
function resolveWebUiEntry() {
|
|
1722
|
+
if (process.env["WRONGSTACK_WEBUI_ENTRY"]) {
|
|
1723
|
+
return path4.resolve(process.env["WRONGSTACK_WEBUI_ENTRY"]);
|
|
1724
|
+
}
|
|
1725
|
+
const require2 = createRequire(import.meta.url);
|
|
1770
1726
|
try {
|
|
1771
|
-
const
|
|
1772
|
-
|
|
1727
|
+
const serverPkgPath = require2.resolve("@wrongstack/webui-server/package.json");
|
|
1728
|
+
const candidate = path4.join(path4.dirname(serverPkgPath), "dist", "server", "entry.js");
|
|
1729
|
+
if (existsSync(candidate)) return candidate;
|
|
1773
1730
|
} catch {
|
|
1774
|
-
return [];
|
|
1775
1731
|
}
|
|
1732
|
+
const serverIndex = require2.resolve("@wrongstack/webui-server");
|
|
1733
|
+
return path4.join(path4.dirname(serverIndex), "server", "entry.js");
|
|
1776
1734
|
}
|
|
1777
|
-
function
|
|
1778
|
-
if (
|
|
1779
|
-
|
|
1780
|
-
const manifest = value;
|
|
1781
|
-
const source = Array.isArray(manifest.projects) ? manifest.projects : Array.isArray(manifest.recentProjects) ? manifest.recentProjects : Array.isArray(manifest.recents) ? manifest.recents : [];
|
|
1782
|
-
return normalizeProjectEntries(source).slice(0, 80);
|
|
1783
|
-
}
|
|
1784
|
-
function normalizeProjectEntries(value) {
|
|
1785
|
-
if (!Array.isArray(value)) return [];
|
|
1786
|
-
const seen = /* @__PURE__ */ new Set();
|
|
1787
|
-
const projects = [];
|
|
1788
|
-
for (const item of value) {
|
|
1789
|
-
const project = normalizeProjectEntry(item);
|
|
1790
|
-
if (!project) continue;
|
|
1791
|
-
const key = pathKey(project.root);
|
|
1792
|
-
if (seen.has(key)) continue;
|
|
1793
|
-
seen.add(key);
|
|
1794
|
-
projects.push(project);
|
|
1735
|
+
function resolveWebUiDistDir() {
|
|
1736
|
+
if (process.env["WRONGSTACK_WEBUI_DIST"]) {
|
|
1737
|
+
return path4.resolve(process.env["WRONGSTACK_WEBUI_DIST"]);
|
|
1795
1738
|
}
|
|
1796
|
-
|
|
1797
|
-
|
|
1739
|
+
const require2 = createRequire(import.meta.url);
|
|
1740
|
+
const serverEntry = require2.resolve("@wrongstack/webui");
|
|
1741
|
+
const candidate = path4.dirname(serverEntry);
|
|
1742
|
+
if (existsSync(candidate)) return candidate;
|
|
1743
|
+
throw new Error(
|
|
1744
|
+
`WebUI frontend assets not found at ${candidate}. Build @wrongstack/webui or set WRONGSTACK_WEBUI_DIST.`
|
|
1798
1745
|
);
|
|
1799
1746
|
}
|
|
1800
|
-
function
|
|
1801
|
-
|
|
1802
|
-
const candidate = value;
|
|
1803
|
-
if (typeof candidate.root !== "string" || !candidate.root.trim()) return null;
|
|
1804
|
-
const root = path3.resolve(candidate.root);
|
|
1805
|
-
const name = typeof candidate.name === "string" && candidate.name.trim() ? candidate.name.trim() : path3.basename(root) || root;
|
|
1806
|
-
const entry = {
|
|
1807
|
-
name,
|
|
1808
|
-
root,
|
|
1809
|
-
slug: typeof candidate.slug === "string" && candidate.slug.trim() ? candidate.slug.trim() : projectSlug(root)
|
|
1810
|
-
};
|
|
1811
|
-
if (typeof candidate.lastSeen === "string" && candidate.lastSeen.trim()) {
|
|
1812
|
-
entry.lastSeen = candidate.lastSeen.trim();
|
|
1813
|
-
}
|
|
1814
|
-
if (typeof candidate.createdAt === "string" && candidate.createdAt.trim()) {
|
|
1815
|
-
entry.createdAt = candidate.createdAt.trim();
|
|
1816
|
-
}
|
|
1817
|
-
if (typeof candidate.lastWorkingDir === "string" && candidate.lastWorkingDir.trim()) {
|
|
1818
|
-
entry.lastWorkingDir = path3.resolve(candidate.lastWorkingDir);
|
|
1819
|
-
}
|
|
1820
|
-
return entry;
|
|
1747
|
+
function rendererIndexPath() {
|
|
1748
|
+
return new URL("../renderer/index.html", import.meta.url).href;
|
|
1821
1749
|
}
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
const projects = await readGlobalProjectManifest();
|
|
1825
|
-
const existing = projects.find((p) => samePath(p.root, entry.root));
|
|
1826
|
-
if (existing) {
|
|
1827
|
-
existing.name = entry.name;
|
|
1828
|
-
existing.slug = entry.slug;
|
|
1829
|
-
existing.lastSeen = entry.lastSeen;
|
|
1830
|
-
existing.lastWorkingDir = entry.lastWorkingDir;
|
|
1831
|
-
} else {
|
|
1832
|
-
projects.push({ ...entry, createdAt: entry.lastSeen });
|
|
1833
|
-
}
|
|
1834
|
-
const sorted = projects.sort(
|
|
1835
|
-
(a, b) => (b.lastSeen ?? b.createdAt ?? "").localeCompare(a.lastSeen ?? a.createdAt ?? "")
|
|
1836
|
-
).slice(0, 80);
|
|
1837
|
-
await fs2.mkdir(path3.dirname(manifestFile), { recursive: true });
|
|
1838
|
-
await atomicWrite2(manifestFile, `${JSON.stringify({ projects: sorted }, null, 2)}
|
|
1839
|
-
`, {
|
|
1840
|
-
mode: 384
|
|
1841
|
-
});
|
|
1842
|
-
return sorted;
|
|
1750
|
+
function preloadPath() {
|
|
1751
|
+
return fileURLToPath2(new URL("../preload/preload.cjs", import.meta.url));
|
|
1843
1752
|
}
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
const resolved = path3.resolve(projectRoot);
|
|
1847
|
-
const projects = (await readGlobalProjectManifest()).filter(
|
|
1848
|
-
(project) => !samePath(project.root, resolved)
|
|
1849
|
-
);
|
|
1850
|
-
await fs2.mkdir(path3.dirname(manifestFile), { recursive: true });
|
|
1851
|
-
await atomicWrite2(manifestFile, `${JSON.stringify({ projects }, null, 2)}
|
|
1852
|
-
`, { mode: 384 });
|
|
1853
|
-
return projects;
|
|
1753
|
+
function webuiPreloadPath() {
|
|
1754
|
+
return fileURLToPath2(new URL("../preload/webui-preload.cjs", import.meta.url));
|
|
1854
1755
|
}
|
|
1855
|
-
function
|
|
1856
|
-
|
|
1857
|
-
const b = path3.resolve(right);
|
|
1858
|
-
return os.platform() === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
1756
|
+
function desktopSettingsWorkspaceRoot() {
|
|
1757
|
+
return path4.join(resolveWstackPaths({ projectRoot: process.cwd() }).configDir, "settings");
|
|
1859
1758
|
}
|
|
1860
1759
|
|
|
1861
|
-
// src/main/
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
import { shell, WebContentsView } from "electron";
|
|
1866
|
-
|
|
1867
|
-
// src/main/state/constants.ts
|
|
1868
|
-
var OPEN_EXTERNAL_ALLOWED_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:", "mailto:"]);
|
|
1869
|
-
var SIDEBAR_WIDTH_WIDE = 292;
|
|
1870
|
-
var SIDEBAR_WIDTH_MEDIUM = 276;
|
|
1871
|
-
var SIDEBAR_WIDTH_NARROW = 252;
|
|
1872
|
-
var SIDEBAR_WIDTH_COLLAPSED = 56;
|
|
1760
|
+
// src/main/runtime-manager.ts
|
|
1761
|
+
var HTTP_PORT_START = 34560;
|
|
1762
|
+
var WS_PORT_START = 34660;
|
|
1763
|
+
var START_TIMEOUT_MS = 3e4;
|
|
1873
1764
|
var MIN_WINDOW_WIDTH2 = 760;
|
|
1874
1765
|
var MIN_WINDOW_HEIGHT2 = 520;
|
|
1875
|
-
var
|
|
1876
|
-
var
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
"search-chat",
|
|
1891
|
-
"open-model-switcher",
|
|
1892
|
-
"open-prompt-library"
|
|
1893
|
-
]);
|
|
1894
|
-
var DESKTOP_WEBUI_VIEWS = /* @__PURE__ */ new Set([
|
|
1895
|
-
"chat",
|
|
1896
|
-
"settings",
|
|
1897
|
-
"goal",
|
|
1898
|
-
"sddhub",
|
|
1899
|
-
"files",
|
|
1900
|
-
"changes",
|
|
1901
|
-
"sessions",
|
|
1902
|
-
"setup",
|
|
1903
|
-
"skill",
|
|
1904
|
-
"roster",
|
|
1905
|
-
"mailbox",
|
|
1906
|
-
"debug",
|
|
1907
|
-
"design-gallery",
|
|
1908
|
-
"refresh-debug",
|
|
1909
|
-
"analytics"
|
|
1910
|
-
]);
|
|
1911
|
-
var DESKTOP_WEBUI_ACTIVITIES = /* @__PURE__ */ new Set([
|
|
1912
|
-
"chat",
|
|
1913
|
-
"agents",
|
|
1914
|
-
"history",
|
|
1915
|
-
"files",
|
|
1916
|
-
"changes",
|
|
1917
|
-
"mailbox",
|
|
1918
|
-
"skills",
|
|
1919
|
-
"design"
|
|
1920
|
-
]);
|
|
1921
|
-
var DESKTOP_WEBUI_OVERLAYS = /* @__PURE__ */ new Set([
|
|
1922
|
-
"fleet",
|
|
1923
|
-
"agents-monitor",
|
|
1924
|
-
"processes",
|
|
1925
|
-
"queue"
|
|
1926
|
-
]);
|
|
1927
|
-
var DESKTOP_WEBUI_DOCKS = /* @__PURE__ */ new Set([
|
|
1928
|
-
"goal",
|
|
1929
|
-
"fleet",
|
|
1930
|
-
"work",
|
|
1931
|
-
"worktrees",
|
|
1932
|
-
"collab"
|
|
1933
|
-
]);
|
|
1934
|
-
var DESKTOP_WEBUI_WORK_TABS = /* @__PURE__ */ new Set(["todos", "tasks", "plan"]);
|
|
1935
|
-
var DESKTOP_WEBUI_PREF_KEYS = /* @__PURE__ */ new Set([
|
|
1936
|
-
"yolo",
|
|
1937
|
-
"nextPrediction",
|
|
1938
|
-
"contextAutoCompact"
|
|
1939
|
-
]);
|
|
1940
|
-
function normalizeDesktopWebuiCommand(value) {
|
|
1941
|
-
if (!isRecord(value)) return null;
|
|
1942
|
-
const command = {};
|
|
1943
|
-
let hasCommand = false;
|
|
1944
|
-
const action = value["action"];
|
|
1945
|
-
if (action !== void 0) {
|
|
1946
|
-
if (typeof action !== "string" || !DESKTOP_WEBUI_ACTIONS.has(action)) {
|
|
1947
|
-
return null;
|
|
1948
|
-
}
|
|
1949
|
-
command.action = action;
|
|
1950
|
-
hasCommand = true;
|
|
1951
|
-
}
|
|
1952
|
-
const view = value["view"];
|
|
1953
|
-
if (view !== void 0) {
|
|
1954
|
-
if (typeof view !== "string" || !DESKTOP_WEBUI_VIEWS.has(view)) {
|
|
1955
|
-
return null;
|
|
1956
|
-
}
|
|
1957
|
-
command.view = view;
|
|
1958
|
-
hasCommand = true;
|
|
1766
|
+
var DEFAULT_IDLE_MINUTES = 15;
|
|
1767
|
+
var IDLE_SWEEP_INTERVAL_MS = 6e4;
|
|
1768
|
+
function resolveIdleTimeoutMs(env = process.env) {
|
|
1769
|
+
const raw = Number.parseFloat(env.WRONGSTACK_DESKTOP_IDLE_MINUTES ?? "");
|
|
1770
|
+
if (Number.isFinite(raw)) return raw > 0 ? raw * 6e4 : 0;
|
|
1771
|
+
return DEFAULT_IDLE_MINUTES * 6e4;
|
|
1772
|
+
}
|
|
1773
|
+
function reclaimableRuntimeIds(runtimes, options) {
|
|
1774
|
+
if (options.idleTimeoutMs <= 0) return [];
|
|
1775
|
+
const out = [];
|
|
1776
|
+
for (const [id, runtime] of runtimes) {
|
|
1777
|
+
if (id === options.activeRuntimeId) continue;
|
|
1778
|
+
if (runtime.status !== "running") continue;
|
|
1779
|
+
if (options.now - runtime.lastActivityAt < options.idleTimeoutMs) continue;
|
|
1780
|
+
out.push(id);
|
|
1959
1781
|
}
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
hasCommand = true;
|
|
1782
|
+
return out;
|
|
1783
|
+
}
|
|
1784
|
+
var DesktopRuntimeManager = class extends EventEmitter2 {
|
|
1785
|
+
constructor(trustBoundary = desktopCompatibilityTrustBoundary) {
|
|
1786
|
+
super();
|
|
1787
|
+
this.trustBoundary = trustBoundary;
|
|
1967
1788
|
}
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1789
|
+
trustBoundary;
|
|
1790
|
+
runtimes = /* @__PURE__ */ new Map();
|
|
1791
|
+
stateFile = path5.join(
|
|
1792
|
+
resolveWstackPaths2({ projectRoot: process.cwd() }).configDir,
|
|
1793
|
+
"desktop.json"
|
|
1794
|
+
);
|
|
1795
|
+
recentProjects = [];
|
|
1796
|
+
registeredProjects = [];
|
|
1797
|
+
restoreProjectSessions = [];
|
|
1798
|
+
restoreActiveRuntimeId = null;
|
|
1799
|
+
restoreActiveProjectRoot = null;
|
|
1800
|
+
lastActiveProjectRoot = null;
|
|
1801
|
+
windowState = null;
|
|
1802
|
+
activeRuntimeId = null;
|
|
1803
|
+
restoring = false;
|
|
1804
|
+
workspaceRestoreCompleted = false;
|
|
1805
|
+
idleSweepTimer = null;
|
|
1806
|
+
idleTimeoutMs = 0;
|
|
1807
|
+
async init() {
|
|
1808
|
+
const state = await this.loadDesktopState();
|
|
1809
|
+
this.recentProjects = state.recentProjects;
|
|
1810
|
+
this.registeredProjects = await readGlobalProjectManifest();
|
|
1811
|
+
this.restoreProjectSessions = state.openProjectSessions;
|
|
1812
|
+
this.restoreActiveRuntimeId = state.activeRuntimeId;
|
|
1813
|
+
this.restoreActiveProjectRoot = state.activeProjectRoot;
|
|
1814
|
+
this.lastActiveProjectRoot = state.activeProjectRoot;
|
|
1815
|
+
this.windowState = state.window;
|
|
1975
1816
|
}
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1817
|
+
snapshot() {
|
|
1818
|
+
const activeId = this.activeRuntimeId;
|
|
1819
|
+
return {
|
|
1820
|
+
activeRuntimeId: activeId,
|
|
1821
|
+
runtimes: Array.from(this.runtimes.values()).map(
|
|
1822
|
+
(runtime) => publicRuntime(runtime, runtime.id === activeId)
|
|
1823
|
+
),
|
|
1824
|
+
recentProjects: [...this.recentProjects],
|
|
1825
|
+
registeredProjects: [...this.registeredProjects],
|
|
1826
|
+
restoring: this.restoring
|
|
1827
|
+
};
|
|
1983
1828
|
}
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
if (typeof workTab !== "string" || !DESKTOP_WEBUI_WORK_TABS.has(workTab)) {
|
|
1987
|
-
return null;
|
|
1988
|
-
}
|
|
1989
|
-
command.workTab = workTab;
|
|
1990
|
-
hasCommand = true;
|
|
1829
|
+
getWindowState() {
|
|
1830
|
+
return this.windowState ? { ...this.windowState } : null;
|
|
1991
1831
|
}
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
return null;
|
|
1996
|
-
}
|
|
1997
|
-
command.terminal = terminal;
|
|
1998
|
-
hasCommand = true;
|
|
1832
|
+
async saveWindowState(window) {
|
|
1833
|
+
this.windowState = { ...window };
|
|
1834
|
+
await this.saveDesktopState();
|
|
1999
1835
|
}
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
if (
|
|
2005
|
-
|
|
1836
|
+
async restoreLastWorkspace() {
|
|
1837
|
+
const sessions = this.restoreProjectSessions.filter(
|
|
1838
|
+
(session) => typeof session.root === "string" && session.root.trim()
|
|
1839
|
+
);
|
|
1840
|
+
if (sessions.length === 0 || this.restoring || this.runtimes.size > 0) {
|
|
1841
|
+
this.workspaceRestoreCompleted = true;
|
|
1842
|
+
return;
|
|
2006
1843
|
}
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
}
|
|
2025
|
-
function isRecord(value) {
|
|
2026
|
-
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
2027
|
-
}
|
|
2028
|
-
|
|
2029
|
-
// src/main/webui/navigation.ts
|
|
2030
|
-
function allowedExternalProtocol(target) {
|
|
2031
|
-
try {
|
|
2032
|
-
const protocol = new URL(target).protocol;
|
|
2033
|
-
return OPEN_EXTERNAL_ALLOWED_PROTOCOLS.has(protocol) ? protocol : void 0;
|
|
2034
|
-
} catch {
|
|
2035
|
-
return void 0;
|
|
2036
|
-
}
|
|
2037
|
-
}
|
|
2038
|
-
function sameOrigin(candidate, base) {
|
|
2039
|
-
if (!base) return false;
|
|
2040
|
-
try {
|
|
2041
|
-
return new URL(candidate).origin === new URL(base).origin;
|
|
2042
|
-
} catch {
|
|
2043
|
-
return false;
|
|
2044
|
-
}
|
|
2045
|
-
}
|
|
2046
|
-
|
|
2047
|
-
// src/main/webui/controller.ts
|
|
2048
|
-
var DesktopWebuiController = class {
|
|
2049
|
-
constructor(ctx) {
|
|
2050
|
-
this.ctx = ctx;
|
|
2051
|
-
}
|
|
2052
|
-
ctx;
|
|
2053
|
-
views = /* @__PURE__ */ new Map();
|
|
2054
|
-
pendingAcks = /* @__PURE__ */ new Map();
|
|
2055
|
-
activeRuntimeId = null;
|
|
2056
|
-
status = { runtimeId: null, status: "idle" };
|
|
2057
|
-
commandSequence = 0;
|
|
2058
|
-
openExternal(target) {
|
|
2059
|
-
const protocol = allowedExternalProtocol(target);
|
|
2060
|
-
if (!protocol) return;
|
|
2061
|
-
void authorizeDesktopAction(this.ctx.trustBoundary, {
|
|
2062
|
-
capability: "url.open-external",
|
|
2063
|
-
subject: { kind: "url", id: target, attributes: { protocol } },
|
|
2064
|
-
risk: "elevated",
|
|
2065
|
-
metadata: { operation: "webui-navigation" }
|
|
2066
|
-
}).then((decision) => decision.allowed ? void shell.openExternal(target) : void 0).catch(() => void 0);
|
|
2067
|
-
}
|
|
2068
|
-
publishStatus(next) {
|
|
2069
|
-
this.status = next;
|
|
2070
|
-
const shellView2 = this.ctx.getShellView();
|
|
2071
|
-
if (!shellView2 || shellView2.webContents.isDestroyed()) return;
|
|
2072
|
-
shellView2.webContents.send(IPC.webuiStatusChanged, next);
|
|
2073
|
-
}
|
|
2074
|
-
setEntryStatus(entry, next) {
|
|
2075
|
-
const previousPrefs = entry.status.prefs;
|
|
2076
|
-
entry.status = {
|
|
2077
|
-
...next,
|
|
2078
|
-
...next.prefs === void 0 && previousPrefs !== void 0 ? { prefs: previousPrefs } : {},
|
|
2079
|
-
// `pendingCommands` is always derived from the entry's own array (source of truth).
|
|
2080
|
-
// Any `next.pendingCommands` value is intentionally overwritten.
|
|
2081
|
-
pendingCommands: entry.pendingCommands.length
|
|
2082
|
-
};
|
|
2083
|
-
if (this.activeRuntimeId === entry.runtimeId) {
|
|
2084
|
-
this.publishStatus(entry.status);
|
|
2085
|
-
this.ctx.onPrefsChanged?.(previousPrefs, entry.status.prefs);
|
|
2086
|
-
}
|
|
2087
|
-
}
|
|
2088
|
-
ensure(runtimeId) {
|
|
2089
|
-
const mainWindow2 = this.ctx.getMainWindow();
|
|
2090
|
-
if (!mainWindow2) return null;
|
|
2091
|
-
const existing = this.views.get(runtimeId);
|
|
2092
|
-
if (existing) return existing;
|
|
2093
|
-
const view = new WebContentsView({
|
|
2094
|
-
webPreferences: {
|
|
2095
|
-
preload: webuiPreloadPath(),
|
|
2096
|
-
contextIsolation: true,
|
|
2097
|
-
nodeIntegration: false,
|
|
2098
|
-
// This is the view that renders agent output, tool results, file
|
|
2099
|
-
// contents, and fetched pages — i.e. the most attacker-influenceable
|
|
2100
|
-
// surface in the app, and the one that most needs process-level
|
|
2101
|
-
// containment rather than only bridge-level. webui-preload.ts imports
|
|
2102
|
-
// just electron's contextBridge/ipcRenderer and a constants map, so it
|
|
2103
|
-
// is already within the sandboxed preload subset (WS-093).
|
|
2104
|
-
sandbox: true
|
|
1844
|
+
this.restoring = true;
|
|
1845
|
+
this.emitChanged();
|
|
1846
|
+
try {
|
|
1847
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1848
|
+
for (const session of sessions) {
|
|
1849
|
+
const key = pathKey(session.root);
|
|
1850
|
+
const seenCount = seen.get(key) ?? 0;
|
|
1851
|
+
seen.set(key, seenCount + 1);
|
|
1852
|
+
await this.openProject(session.root, {
|
|
1853
|
+
forceNew: seenCount > 0,
|
|
1854
|
+
name: session.name,
|
|
1855
|
+
runtimeId: session.runtimeId
|
|
1856
|
+
}).catch((err) => {
|
|
1857
|
+
process.stderr.write(
|
|
1858
|
+
`[desktop:restore] Failed to restore ${session.root}: ${toErrorMessage(err)}
|
|
1859
|
+
`
|
|
1860
|
+
);
|
|
1861
|
+
});
|
|
2105
1862
|
}
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
attached: false,
|
|
2114
|
-
pendingCommands: [],
|
|
2115
|
-
pendingFlushTimer: null,
|
|
2116
|
-
pendingFlushAttempts: 0
|
|
2117
|
-
};
|
|
2118
|
-
view.webContents.setWindowOpenHandler(({ url }) => {
|
|
2119
|
-
this.openExternal(url);
|
|
2120
|
-
return { action: "deny" };
|
|
2121
|
-
});
|
|
2122
|
-
view.webContents.on("will-navigate", (event, url) => {
|
|
2123
|
-
if (sameOrigin(url, entry.url)) return;
|
|
2124
|
-
event.preventDefault();
|
|
2125
|
-
this.openExternal(url);
|
|
2126
|
-
});
|
|
2127
|
-
view.webContents.on("did-start-loading", () => {
|
|
2128
|
-
if (this.views.get(runtimeId) !== entry) return;
|
|
2129
|
-
entry.bridgeReady = false;
|
|
2130
|
-
this.setEntryStatus(entry, { runtimeId, status: "loading" });
|
|
2131
|
-
});
|
|
2132
|
-
view.webContents.on("did-finish-load", () => {
|
|
2133
|
-
if (this.views.get(runtimeId) !== entry) return;
|
|
2134
|
-
this.scheduleFlush(entry);
|
|
2135
|
-
try {
|
|
2136
|
-
entry.view.webContents.send(IPC.webuiLocaleChanged, this.ctx.getLocale());
|
|
2137
|
-
} catch {
|
|
1863
|
+
let restoredActive = false;
|
|
1864
|
+
if (this.restoreActiveRuntimeId) {
|
|
1865
|
+
const active = this.runtimes.get(this.restoreActiveRuntimeId);
|
|
1866
|
+
if (active) {
|
|
1867
|
+
await this.activateRuntime(active.id);
|
|
1868
|
+
restoredActive = true;
|
|
1869
|
+
}
|
|
2138
1870
|
}
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
this.
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
});
|
|
2152
|
-
this.views.set(runtimeId, entry);
|
|
2153
|
-
return entry;
|
|
2154
|
-
}
|
|
2155
|
-
attach(entry) {
|
|
2156
|
-
const mainWindow2 = this.ctx.getMainWindow();
|
|
2157
|
-
if (!mainWindow2 || entry.attached) return;
|
|
2158
|
-
mainWindow2.contentView.addChildView(entry.view);
|
|
2159
|
-
entry.attached = true;
|
|
1871
|
+
if (!restoredActive && this.restoreActiveProjectRoot) {
|
|
1872
|
+
const active = Array.from(this.runtimes.values()).find(
|
|
1873
|
+
(runtime) => samePath(runtime.root, this.restoreActiveProjectRoot ?? "")
|
|
1874
|
+
);
|
|
1875
|
+
if (active) await this.activateRuntime(active.id);
|
|
1876
|
+
}
|
|
1877
|
+
} finally {
|
|
1878
|
+
this.restoring = false;
|
|
1879
|
+
this.workspaceRestoreCompleted = true;
|
|
1880
|
+
this.emitChanged();
|
|
1881
|
+
await this.saveDesktopState();
|
|
1882
|
+
}
|
|
2160
1883
|
}
|
|
2161
|
-
|
|
2162
|
-
this.
|
|
2163
|
-
|
|
2164
|
-
this.settleRuntimeAcks(entry.runtimeId, false);
|
|
2165
|
-
if (entry.pendingFlushTimer) clearTimeout(entry.pendingFlushTimer);
|
|
2166
|
-
entry.pendingFlushTimer = null;
|
|
2167
|
-
const mainWindow2 = this.ctx.getMainWindow();
|
|
2168
|
-
if (mainWindow2 && entry.attached) mainWindow2.contentView.removeChildView(entry.view);
|
|
2169
|
-
entry.attached = false;
|
|
2170
|
-
if (!entry.view.webContents.isDestroyed()) entry.view.webContents.close();
|
|
2171
|
-
if (this.activeRuntimeId === entry.runtimeId) this.activeRuntimeId = null;
|
|
1884
|
+
getRuntime(id) {
|
|
1885
|
+
const runtime = this.runtimes.get(id);
|
|
1886
|
+
return runtime ? publicRuntime(runtime, true) : void 0;
|
|
2172
1887
|
}
|
|
2173
|
-
|
|
2174
|
-
|
|
1888
|
+
getRuntimeUrlWithToken(id) {
|
|
1889
|
+
const runtime = this.runtimes.get(id);
|
|
1890
|
+
if (!runtime) return void 0;
|
|
1891
|
+
const url = new URL(runtime.url);
|
|
1892
|
+
url.searchParams.set("token", runtime.token);
|
|
1893
|
+
url.searchParams.set("shell", "desktop");
|
|
1894
|
+
return url.toString();
|
|
2175
1895
|
}
|
|
2176
|
-
|
|
2177
|
-
|
|
1896
|
+
getRuntimeWsUrlWithToken(id) {
|
|
1897
|
+
const runtime = this.runtimes.get(id);
|
|
1898
|
+
if (!runtime) return void 0;
|
|
1899
|
+
const url = new URL(`ws://127.0.0.1:${runtime.wsPort}`);
|
|
1900
|
+
url.searchParams.set("token", runtime.token);
|
|
1901
|
+
return url.toString();
|
|
2178
1902
|
}
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
* the previous view and loads the next, which costs a page load on the way
|
|
2190
|
-
* back — the deliberate trade for a footprint that does not grow with how
|
|
2191
|
-
* many projects are open. The shell covers that load with its own
|
|
2192
|
-
* `loading` state, so the gap is visible as progress rather than as a blank
|
|
2193
|
-
* window.
|
|
2194
|
-
*
|
|
2195
|
-
* Queued WebUI commands are not at risk: `dispatch` only ever targets
|
|
2196
|
-
* `activeEntry()`, so a disposed background view cannot have had any.
|
|
2197
|
-
*/
|
|
2198
|
-
syncActive() {
|
|
2199
|
-
if (!this.ctx.getMainWindow()) return;
|
|
2200
|
-
const snapshot = this.ctx.manager.snapshot();
|
|
2201
|
-
const active = snapshot.runtimes.find((runtime) => runtime.id === snapshot.activeRuntimeId);
|
|
2202
|
-
const keep = active?.status === "running" ? active.id : null;
|
|
2203
|
-
for (const [id, entry2] of this.views) if (id !== keep) this.dispose(entry2);
|
|
2204
|
-
if (active?.status !== "running") {
|
|
2205
|
-
this.activeRuntimeId = active?.id ?? null;
|
|
2206
|
-
this.publishStatus({ runtimeId: active?.id ?? null, status: "idle" });
|
|
2207
|
-
this.ctx.layoutViews();
|
|
2208
|
-
return;
|
|
1903
|
+
async openProject(projectRoot, options = {}) {
|
|
1904
|
+
const resolved = path5.resolve(projectRoot);
|
|
1905
|
+
const stat3 = await fs3.stat(resolved).catch(() => null);
|
|
1906
|
+
if (!stat3?.isDirectory()) throw new Error(`Not a directory: ${resolved}`);
|
|
1907
|
+
const kind = options.kind ?? "project";
|
|
1908
|
+
const touchRecent = options.touchRecent ?? kind === "project";
|
|
1909
|
+
const forceNew = options.forceNew === true;
|
|
1910
|
+
const authorization = await authorizeDesktopRuntimeStart(this.trustBoundary, resolved, kind);
|
|
1911
|
+
if (!authorization.allowed) {
|
|
1912
|
+
throw new Error(`Desktop runtime start denied: ${authorization.reason}`);
|
|
2209
1913
|
}
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
1914
|
+
if (!forceNew) {
|
|
1915
|
+
const existing = Array.from(this.runtimes.values()).find(
|
|
1916
|
+
(runtime2) => samePath(runtime2.root, resolved) && runtime2.kind === kind && (runtime2.status === "starting" || runtime2.status === "running")
|
|
1917
|
+
);
|
|
1918
|
+
if (existing) {
|
|
1919
|
+
this.activeRuntimeId = existing.id;
|
|
1920
|
+
if (existing.kind === "project") this.lastActiveProjectRoot = existing.root;
|
|
1921
|
+
if (touchRecent) {
|
|
1922
|
+
await this.touchProject(existing.root);
|
|
1923
|
+
} else {
|
|
1924
|
+
await this.persistWorkspaceState();
|
|
1925
|
+
}
|
|
1926
|
+
this.emitChanged();
|
|
1927
|
+
return publicRuntime(existing, true);
|
|
1928
|
+
}
|
|
1929
|
+
const staleSameRoot = Array.from(this.runtimes.values()).filter(
|
|
1930
|
+
(runtime2) => samePath(runtime2.root, resolved) && runtime2.kind === kind
|
|
1931
|
+
);
|
|
1932
|
+
for (const stale of staleSameRoot) {
|
|
1933
|
+
await this.closeRuntimeInternal(stale.id, { persistWorkspace: false });
|
|
1934
|
+
}
|
|
2216
1935
|
}
|
|
2217
|
-
const
|
|
2218
|
-
|
|
2219
|
-
this.
|
|
2220
|
-
this.
|
|
2221
|
-
this.
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
if (
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
if (!entry.view.webContents.isLoading() && entry.status.status !== "error")
|
|
2252
|
-
return this.dispatchNow(entry, command);
|
|
2253
|
-
this.queue(entry, command);
|
|
2254
|
-
this.scheduleFlush(entry);
|
|
2255
|
-
return true;
|
|
1936
|
+
const slug = projectSlug(resolved);
|
|
1937
|
+
const requestedRuntimeId = normalizeRuntimeId(options.runtimeId);
|
|
1938
|
+
const runtimeId = requestedRuntimeId && !this.runtimes.has(requestedRuntimeId) ? requestedRuntimeId : `${slug}-${randomBytes(3).toString("hex")}`;
|
|
1939
|
+
const name = options.name ?? nextRuntimeName(this.runtimes, resolved, kind);
|
|
1940
|
+
const httpPort = await findFreePort(HTTP_PORT_START, usedPorts(this.runtimes));
|
|
1941
|
+
const wsPort = await findFreePort(
|
|
1942
|
+
WS_PORT_START,
|
|
1943
|
+
/* @__PURE__ */ new Set([...usedPorts(this.runtimes), httpPort])
|
|
1944
|
+
);
|
|
1945
|
+
const token = randomBytes(24).toString("hex");
|
|
1946
|
+
const runtime = {
|
|
1947
|
+
id: runtimeId,
|
|
1948
|
+
name,
|
|
1949
|
+
root: resolved,
|
|
1950
|
+
slug,
|
|
1951
|
+
kind,
|
|
1952
|
+
status: "starting",
|
|
1953
|
+
httpPort,
|
|
1954
|
+
wsPort,
|
|
1955
|
+
url: `http://127.0.0.1:${httpPort}`,
|
|
1956
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1957
|
+
token,
|
|
1958
|
+
child: null,
|
|
1959
|
+
logs: [],
|
|
1960
|
+
logNotifyTimer: null,
|
|
1961
|
+
lastActivityAt: Date.now()
|
|
1962
|
+
};
|
|
1963
|
+
this.runtimes.set(runtimeId, runtime);
|
|
1964
|
+
this.activeRuntimeId = runtimeId;
|
|
1965
|
+
if (kind === "project") this.lastActiveProjectRoot = resolved;
|
|
1966
|
+
if (touchRecent) {
|
|
1967
|
+
await this.touchProject(resolved);
|
|
1968
|
+
} else {
|
|
1969
|
+
await this.persistWorkspaceState();
|
|
2256
1970
|
}
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
1971
|
+
this.emitChanged();
|
|
1972
|
+
try {
|
|
1973
|
+
const entry = resolveWebUiEntry();
|
|
1974
|
+
const distDir = resolveWebUiDistDir();
|
|
1975
|
+
const child = spawn(
|
|
1976
|
+
process.execPath,
|
|
1977
|
+
[
|
|
1978
|
+
entry,
|
|
1979
|
+
"--host",
|
|
1980
|
+
"127.0.0.1",
|
|
1981
|
+
"--port",
|
|
1982
|
+
String(httpPort),
|
|
1983
|
+
"--ws-port",
|
|
1984
|
+
String(wsPort),
|
|
1985
|
+
"--dist-dir",
|
|
1986
|
+
distDir,
|
|
1987
|
+
"--require-token"
|
|
1988
|
+
],
|
|
1989
|
+
{
|
|
1990
|
+
cwd: resolved,
|
|
1991
|
+
env: {
|
|
1992
|
+
...buildChildEnv(),
|
|
1993
|
+
ELECTRON_RUN_AS_NODE: "1",
|
|
1994
|
+
WEBUI_STRICT_PORT: "1",
|
|
1995
|
+
WRONGSTACK_DESKTOP: "1",
|
|
1996
|
+
// Passed by environment, not argv. A process command line is
|
|
1997
|
+
// world-readable on every platform this ships to — `ps -ef` on
|
|
1998
|
+
// POSIX, Task Manager's command-line column or a plain WMI query on
|
|
1999
|
+
// Windows — so `--token <secret>` handed the WebUI access token to
|
|
2000
|
+
// any local process that cared to look. The token grants full agent
|
|
2001
|
+
// control, so that is a credential disclosure, not a nuisance.
|
|
2002
|
+
// entry.ts already reads WEBUI_TOKEN; argv only took precedence
|
|
2003
|
+
// over it (WS-087).
|
|
2004
|
+
WEBUI_TOKEN: token
|
|
2005
|
+
},
|
|
2006
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
2007
|
+
windowsHide: true
|
|
2008
|
+
}
|
|
2294
2009
|
);
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2010
|
+
runtime.child = child;
|
|
2011
|
+
runtime.pid = child.pid;
|
|
2012
|
+
child.stdout?.on("data", (chunk) => {
|
|
2013
|
+
const text = chunk.toString();
|
|
2014
|
+
appendRuntimeLog(runtime, "stdout", text);
|
|
2015
|
+
runtime.lastActivityAt = Date.now();
|
|
2016
|
+
this.scheduleLogChanged(runtime);
|
|
2017
|
+
process.stdout.write(`[desktop:${runtime.id}] ${text}`);
|
|
2300
2018
|
});
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
this.
|
|
2019
|
+
child.stderr?.on("data", (chunk) => {
|
|
2020
|
+
const text = chunk.toString();
|
|
2021
|
+
appendRuntimeLog(runtime, "stderr", text);
|
|
2022
|
+
runtime.lastActivityAt = Date.now();
|
|
2023
|
+
this.scheduleLogChanged(runtime);
|
|
2024
|
+
process.stderr.write(`[desktop:${runtime.id}] ${text}`);
|
|
2025
|
+
});
|
|
2026
|
+
child.once("error", (err) => {
|
|
2027
|
+
runtime.status = "error";
|
|
2028
|
+
runtime.error = toErrorMessage(err);
|
|
2029
|
+
runtime.child = null;
|
|
2030
|
+
if (this.activeRuntimeId === runtime.id) {
|
|
2031
|
+
this.activeRuntimeId = firstRunningRuntimeId(this.runtimes);
|
|
2032
|
+
}
|
|
2033
|
+
this.emitChanged();
|
|
2034
|
+
});
|
|
2035
|
+
child.once("exit", (code, signal) => {
|
|
2036
|
+
if (runtime.status === "error") return;
|
|
2037
|
+
runtime.status = "stopped";
|
|
2038
|
+
runtime.error = code === 0 ? void 0 : `Exited with ${signal ?? `code ${code ?? "unknown"}`}`;
|
|
2039
|
+
runtime.child = null;
|
|
2040
|
+
if (this.activeRuntimeId === runtime.id) {
|
|
2041
|
+
this.activeRuntimeId = firstRunningRuntimeId(this.runtimes);
|
|
2042
|
+
}
|
|
2043
|
+
this.emitChanged();
|
|
2044
|
+
});
|
|
2045
|
+
await waitForHttpReady(runtime.url, token, START_TIMEOUT_MS);
|
|
2046
|
+
if (runtime.status !== "starting") {
|
|
2047
|
+
throw new Error(runtime.error ?? "WebUI process exited during startup");
|
|
2306
2048
|
}
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
if (pending.fallbackTimer) clearTimeout(pending.fallbackTimer);
|
|
2315
|
-
if (handled) {
|
|
2316
|
-
const entry = this.views.get(pending.runtimeId);
|
|
2317
|
-
if (entry) {
|
|
2318
|
-
entry.bridgeReady = true;
|
|
2319
|
-
this.setEntryStatus(entry, { ...entry.status, status: "ready" });
|
|
2049
|
+
runtime.status = "running";
|
|
2050
|
+
await this.persistWorkspaceState();
|
|
2051
|
+
this.emitChanged();
|
|
2052
|
+
return publicRuntime(runtime, true);
|
|
2053
|
+
} catch (err) {
|
|
2054
|
+
if (runtime.status !== "stopped" && runtime.status !== "error") {
|
|
2055
|
+
runtime.status = "error";
|
|
2320
2056
|
}
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
}
|
|
2324
|
-
settleRuntimeAcks(runtimeId, handled) {
|
|
2325
|
-
for (const [id, pending] of [...this.pendingAcks])
|
|
2326
|
-
if (pending.runtimeId === runtimeId) this.settleAck(id, handled);
|
|
2327
|
-
}
|
|
2328
|
-
scheduleFlush(entry) {
|
|
2329
|
-
if (entry.pendingFlushTimer) return;
|
|
2330
|
-
entry.pendingFlushTimer = setTimeout(() => {
|
|
2331
|
-
entry.pendingFlushTimer = null;
|
|
2332
|
-
void this.flush(entry);
|
|
2333
|
-
}, 250);
|
|
2334
|
-
}
|
|
2335
|
-
async flush(entry) {
|
|
2336
|
-
if (this.views.get(entry.runtimeId) !== entry || entry.pendingCommands.length === 0) return;
|
|
2337
|
-
if (!entry.bridgeReady) {
|
|
2338
|
-
entry.pendingFlushAttempts += 1;
|
|
2339
|
-
const shouldExecuteFallback = !entry.view.webContents.isLoading() && entry.pendingFlushAttempts >= 4;
|
|
2340
|
-
if (!shouldExecuteFallback && entry.pendingFlushAttempts <= MAX_PENDING_FLUSH_ATTEMPTS) {
|
|
2341
|
-
this.scheduleFlush(entry);
|
|
2342
|
-
this.setEntryStatus(entry, entry.status);
|
|
2343
|
-
return;
|
|
2057
|
+
if (runtime.error === void 0) {
|
|
2058
|
+
runtime.error = toErrorMessage(err);
|
|
2344
2059
|
}
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
status: "error",
|
|
2350
|
-
error: "WebUI command bridge did not become ready."
|
|
2351
|
-
});
|
|
2352
|
-
return;
|
|
2060
|
+
await terminateProcessTree(runtime.child);
|
|
2061
|
+
runtime.child = null;
|
|
2062
|
+
if (this.activeRuntimeId === runtime.id) {
|
|
2063
|
+
this.activeRuntimeId = firstRunningRuntimeId(this.runtimes);
|
|
2353
2064
|
}
|
|
2065
|
+
this.emitChanged();
|
|
2066
|
+
throw err;
|
|
2354
2067
|
}
|
|
2355
|
-
entry.pendingFlushAttempts = 0;
|
|
2356
|
-
const commands = entry.pendingCommands.splice(0);
|
|
2357
|
-
this.setEntryStatus(entry, entry.status);
|
|
2358
|
-
for (const command of commands) await this.dispatchNow(entry, command).catch(() => void 0);
|
|
2359
2068
|
}
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
return icon.isEmpty() ? void 0 : icon;
|
|
2373
|
-
} catch {
|
|
2374
|
-
return void 0;
|
|
2069
|
+
async activateRuntime(id) {
|
|
2070
|
+
const runtime = this.runtimes.get(id);
|
|
2071
|
+
if (!runtime) throw new Error(`Runtime not found: ${id}`);
|
|
2072
|
+
this.activeRuntimeId = id;
|
|
2073
|
+
runtime.lastActivityAt = Date.now();
|
|
2074
|
+
if (runtime.kind === "project") this.lastActiveProjectRoot = runtime.root;
|
|
2075
|
+
if (runtime.kind === "project") {
|
|
2076
|
+
await this.touchProject(runtime.root);
|
|
2077
|
+
} else {
|
|
2078
|
+
await this.persistWorkspaceState();
|
|
2079
|
+
}
|
|
2080
|
+
this.emitChanged();
|
|
2375
2081
|
}
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2082
|
+
async closeRuntime(id) {
|
|
2083
|
+
const runtime = this.runtimes.get(id);
|
|
2084
|
+
if (runtime) {
|
|
2085
|
+
const authorization = await authorizeDesktopRuntimeStop(this.trustBoundary, runtime);
|
|
2086
|
+
if (!authorization.allowed) {
|
|
2087
|
+
throw new Error(`Desktop runtime stop denied: ${authorization.reason}`);
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
await this.closeRuntimeInternal(id, { persistWorkspace: true });
|
|
2381
2091
|
}
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2092
|
+
async closeAll(options = {}) {
|
|
2093
|
+
const persistWorkspace = options.persistWorkspace ?? true;
|
|
2094
|
+
await Promise.all(
|
|
2095
|
+
Array.from(this.runtimes.keys()).map(
|
|
2096
|
+
(id) => this.closeRuntimeInternal(id, { persistWorkspace })
|
|
2097
|
+
)
|
|
2098
|
+
);
|
|
2389
2099
|
}
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
if (
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2100
|
+
async registerProject(projectRoot) {
|
|
2101
|
+
const resolved = path5.resolve(projectRoot);
|
|
2102
|
+
const stat3 = await fs3.stat(resolved).catch(() => null);
|
|
2103
|
+
if (!stat3?.isDirectory()) throw new Error(`Not a directory: ${resolved}`);
|
|
2104
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2105
|
+
const entry = {
|
|
2106
|
+
name: path5.basename(resolved) || resolved,
|
|
2107
|
+
root: resolved,
|
|
2108
|
+
slug: projectSlug(resolved),
|
|
2109
|
+
lastSeen: now,
|
|
2110
|
+
lastWorkingDir: resolved
|
|
2111
|
+
};
|
|
2112
|
+
this.registeredProjects = await touchGlobalProjectManifest(entry);
|
|
2113
|
+
this.emitChanged();
|
|
2398
2114
|
}
|
|
2399
|
-
async
|
|
2400
|
-
const
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2115
|
+
async unregisterProject(projectRoot) {
|
|
2116
|
+
const resolved = path5.resolve(projectRoot);
|
|
2117
|
+
this.registeredProjects = await removeGlobalProjectManifest(resolved);
|
|
2118
|
+
await this.saveDesktopState();
|
|
2119
|
+
this.emitChanged();
|
|
2404
2120
|
}
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2121
|
+
async closeRuntimeInternal(id, options) {
|
|
2122
|
+
const runtime = this.runtimes.get(id);
|
|
2123
|
+
if (!runtime) return;
|
|
2124
|
+
runtime.status = "stopped";
|
|
2125
|
+
const child = runtime.child;
|
|
2126
|
+
runtime.child = null;
|
|
2127
|
+
await terminateProcessTree(child);
|
|
2128
|
+
if (runtime.logNotifyTimer) {
|
|
2129
|
+
clearTimeout(runtime.logNotifyTimer);
|
|
2130
|
+
runtime.logNotifyTimer = null;
|
|
2410
2131
|
}
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
}
|
|
2132
|
+
this.runtimes.delete(id);
|
|
2133
|
+
if (this.activeRuntimeId === id) this.activeRuntimeId = firstRunningRuntimeId(this.runtimes);
|
|
2134
|
+
if (this.lastActiveProjectRoot && samePath(this.lastActiveProjectRoot, runtime.root)) {
|
|
2135
|
+
this.lastActiveProjectRoot = firstProjectRuntimeRoot(this.runtimes);
|
|
2136
|
+
}
|
|
2137
|
+
if (options.persistWorkspace) {
|
|
2138
|
+
await this.persistWorkspaceState();
|
|
2139
|
+
}
|
|
2140
|
+
this.emitChanged();
|
|
2419
2141
|
}
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2142
|
+
async touchProject(projectRoot) {
|
|
2143
|
+
const resolved = path5.resolve(projectRoot);
|
|
2144
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2145
|
+
const entry = {
|
|
2146
|
+
name: path5.basename(resolved) || resolved,
|
|
2147
|
+
root: resolved,
|
|
2148
|
+
slug: projectSlug(resolved),
|
|
2149
|
+
lastSeen: now,
|
|
2150
|
+
lastWorkingDir: resolved
|
|
2151
|
+
};
|
|
2152
|
+
this.recentProjects = [
|
|
2153
|
+
entry,
|
|
2154
|
+
...this.recentProjects.filter((p) => !samePath(p.root, resolved))
|
|
2155
|
+
].slice(0, 24);
|
|
2156
|
+
const [, registeredProjects] = await Promise.all([
|
|
2157
|
+
this.saveDesktopState(),
|
|
2158
|
+
touchGlobalProjectManifest(entry)
|
|
2159
|
+
]);
|
|
2160
|
+
this.registeredProjects = registeredProjects;
|
|
2431
2161
|
}
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
ctx.syncActiveWebuiView();
|
|
2435
|
-
ctx.broadcastState();
|
|
2436
|
-
return ctx.getRuntimeManager().snapshot();
|
|
2437
|
-
}
|
|
2438
|
-
async function registerProject(ctx, requestedRoot) {
|
|
2439
|
-
let projectRoot = requestedRoot;
|
|
2440
|
-
if (!projectRoot) {
|
|
2441
|
-
projectRoot = await ctx.chooseProjectRoot("register");
|
|
2162
|
+
async persistWorkspaceState() {
|
|
2163
|
+
await this.saveDesktopState();
|
|
2442
2164
|
}
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2165
|
+
async loadDesktopState() {
|
|
2166
|
+
try {
|
|
2167
|
+
const raw = await fs3.readFile(this.stateFile, "utf8");
|
|
2168
|
+
const parsed = JSON.parse(raw);
|
|
2169
|
+
const openProjects = normalizePathList(parsed.openProjects);
|
|
2170
|
+
const openProjectSessions = normalizeSessionStateList(
|
|
2171
|
+
parsed.openProjectSessions,
|
|
2172
|
+
openProjects
|
|
2173
|
+
);
|
|
2174
|
+
return {
|
|
2175
|
+
recentProjects: normalizeProjectEntries(parsed.recentProjects),
|
|
2176
|
+
openProjects,
|
|
2177
|
+
openProjectSessions,
|
|
2178
|
+
activeRuntimeId: normalizeRuntimeId(parsed.activeRuntimeId) ?? null,
|
|
2179
|
+
activeProjectRoot: typeof parsed.activeProjectRoot === "string" && parsed.activeProjectRoot.trim() ? path5.resolve(parsed.activeProjectRoot) : null,
|
|
2180
|
+
window: normalizeWindowState(parsed.window)
|
|
2181
|
+
};
|
|
2182
|
+
} catch {
|
|
2183
|
+
return {
|
|
2184
|
+
recentProjects: [],
|
|
2185
|
+
openProjects: [],
|
|
2186
|
+
openProjectSessions: [],
|
|
2187
|
+
activeRuntimeId: null,
|
|
2188
|
+
activeProjectRoot: null,
|
|
2189
|
+
window: null
|
|
2190
|
+
};
|
|
2191
|
+
}
|
|
2459
2192
|
}
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
const
|
|
2470
|
-
|
|
2471
|
-
await
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2193
|
+
async saveDesktopState() {
|
|
2194
|
+
await fs3.mkdir(path5.dirname(this.stateFile), { recursive: true });
|
|
2195
|
+
const liveProjectSessions = Array.from(this.runtimes.values()).filter((runtime) => runtime.status !== "stopped" && runtime.kind === "project").map((runtime) => runtimeToSessionState(runtime));
|
|
2196
|
+
const openProjectSessions = liveProjectSessions.length === 0 && !this.workspaceRestoreCompleted ? [...this.restoreProjectSessions] : liveProjectSessions;
|
|
2197
|
+
const openProjects = openProjectSessions.map((session) => session.root);
|
|
2198
|
+
const activeRuntime = this.activeRuntimeId ? this.runtimes.get(this.activeRuntimeId) : null;
|
|
2199
|
+
const lastActiveProjectRoot = this.lastActiveProjectRoot;
|
|
2200
|
+
const fallbackSession = lastActiveProjectRoot ? openProjectSessions.find((session) => samePath(session.root, lastActiveProjectRoot)) : void 0;
|
|
2201
|
+
const activeSession = activeRuntime?.kind === "project" ? runtimeToSessionState(activeRuntime) : fallbackSession ?? openProjectSessions[0];
|
|
2202
|
+
const activeRoot = activeSession?.root;
|
|
2203
|
+
const activeRuntimeId = activeSession?.runtimeId ?? null;
|
|
2204
|
+
await atomicWrite2(
|
|
2205
|
+
this.stateFile,
|
|
2206
|
+
`${JSON.stringify(
|
|
2207
|
+
{
|
|
2208
|
+
recentProjects: this.recentProjects,
|
|
2209
|
+
openProjects,
|
|
2210
|
+
openProjectSessions,
|
|
2211
|
+
activeRuntimeId,
|
|
2212
|
+
activeProjectRoot: activeRoot ?? null,
|
|
2213
|
+
window: this.windowState
|
|
2214
|
+
},
|
|
2215
|
+
null,
|
|
2216
|
+
2
|
|
2217
|
+
)}
|
|
2218
|
+
`,
|
|
2219
|
+
{ mode: 384 }
|
|
2220
|
+
);
|
|
2478
2221
|
}
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
}
|
|
2482
|
-
async function activateRuntime(ctx, id) {
|
|
2483
|
-
await ctx.getRuntimeManager().activateRuntime(id);
|
|
2484
|
-
ctx.syncActiveWebuiView();
|
|
2485
|
-
ctx.broadcastState();
|
|
2486
|
-
return ctx.getRuntimeManager().snapshot();
|
|
2487
|
-
}
|
|
2488
|
-
async function closeRuntime(ctx, id) {
|
|
2489
|
-
ctx.getAgentBridge().close(id);
|
|
2490
|
-
await ctx.getRuntimeManager().closeRuntime(id);
|
|
2491
|
-
ctx.syncActiveWebuiView();
|
|
2492
|
-
ctx.broadcastState();
|
|
2493
|
-
return ctx.getRuntimeManager().snapshot();
|
|
2494
|
-
}
|
|
2495
|
-
async function restoreLastWorkspace(ctx) {
|
|
2496
|
-
await ctx.getRuntimeManager().restoreLastWorkspace();
|
|
2497
|
-
ctx.syncActiveWebuiView();
|
|
2498
|
-
ctx.broadcastState();
|
|
2499
|
-
}
|
|
2500
|
-
|
|
2501
|
-
// src/main/layout/sidebar.ts
|
|
2502
|
-
function getSidebarWidth(windowWidth, collapsed) {
|
|
2503
|
-
if (collapsed) return SIDEBAR_WIDTH_COLLAPSED;
|
|
2504
|
-
if (windowWidth < 900) return SIDEBAR_WIDTH_NARROW;
|
|
2505
|
-
if (windowWidth < 1180) return SIDEBAR_WIDTH_MEDIUM;
|
|
2506
|
-
return SIDEBAR_WIDTH_WIDE;
|
|
2507
|
-
}
|
|
2508
|
-
|
|
2509
|
-
// src/main/menu/index.ts
|
|
2510
|
-
import { Menu as Menu2 } from "electron";
|
|
2511
|
-
|
|
2512
|
-
// src/main/menu/projects-menu.ts
|
|
2513
|
-
import path4 from "node:path";
|
|
2514
|
-
function buildProjectsMenu(runtimes, actions, t) {
|
|
2515
|
-
const projectGroups = groupProjectRuntimesForMenu(runtimes);
|
|
2516
|
-
const menu = [
|
|
2517
|
-
{
|
|
2518
|
-
label: t("openProjectEllipsis"),
|
|
2519
|
-
accelerator: "CmdOrCtrl+O",
|
|
2520
|
-
click: () => actions.newSession("")
|
|
2521
|
-
},
|
|
2522
|
-
{
|
|
2523
|
-
label: t("registerProjectEllipsis"),
|
|
2524
|
-
click: () => actions.registerProject?.()
|
|
2525
|
-
},
|
|
2526
|
-
{ type: "separator" }
|
|
2527
|
-
];
|
|
2528
|
-
if (projectGroups.length === 0) {
|
|
2529
|
-
menu.push({ label: t("noOpenProjectSessions"), enabled: false });
|
|
2530
|
-
return menu;
|
|
2222
|
+
emitChanged() {
|
|
2223
|
+
this.emit("changed");
|
|
2531
2224
|
}
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2225
|
+
/**
|
|
2226
|
+
* Notify the shell that a runtime produced output.
|
|
2227
|
+
*
|
|
2228
|
+
* Only the ACTIVE runtime's logs reach the renderer (see `publicRuntime`), so
|
|
2229
|
+
* output from a background project has nothing to show and must not cost a
|
|
2230
|
+
* broadcast. Before this guard, every project writing to stdout scheduled its
|
|
2231
|
+
* own 250 ms timer, and each one fired a FULL snapshot: N chatty projects
|
|
2232
|
+
* produced 4N broadcasts per second carrying N x 40 log lines each, for a
|
|
2233
|
+
* panel that displays one runtime's output.
|
|
2234
|
+
*
|
|
2235
|
+
* The 250 ms debounce is per-runtime by construction (the timer lives on the
|
|
2236
|
+
* runtime record) but only one runtime can be active, so at most one such
|
|
2237
|
+
* timer is ever armed now.
|
|
2238
|
+
*/
|
|
2239
|
+
/**
|
|
2240
|
+
* Begin reclaiming idle project servers.
|
|
2241
|
+
*
|
|
2242
|
+
* Idempotent, and a no-op when the timeout is disabled. The interval is
|
|
2243
|
+
* unref'd so a pending sweep never holds the process open during quit.
|
|
2244
|
+
*/
|
|
2245
|
+
startIdleSweep(options = {}) {
|
|
2246
|
+
if (this.idleSweepTimer) return;
|
|
2247
|
+
this.idleTimeoutMs = options.idleTimeoutMs ?? resolveIdleTimeoutMs();
|
|
2248
|
+
if (this.idleTimeoutMs <= 0) return;
|
|
2249
|
+
this.idleSweepTimer = setInterval(() => {
|
|
2250
|
+
void this.sweepIdleRuntimes();
|
|
2251
|
+
}, IDLE_SWEEP_INTERVAL_MS);
|
|
2252
|
+
this.idleSweepTimer.unref?.();
|
|
2253
|
+
}
|
|
2254
|
+
stopIdleSweep() {
|
|
2255
|
+
if (!this.idleSweepTimer) return;
|
|
2256
|
+
clearInterval(this.idleSweepTimer);
|
|
2257
|
+
this.idleSweepTimer = null;
|
|
2258
|
+
}
|
|
2259
|
+
/** One pass. Exposed so a test can drive it without waiting on the interval. */
|
|
2260
|
+
async sweepIdleRuntimes(now = Date.now()) {
|
|
2261
|
+
const ids = reclaimableRuntimeIds(this.runtimes, {
|
|
2262
|
+
activeRuntimeId: this.activeRuntimeId,
|
|
2263
|
+
idleTimeoutMs: this.idleTimeoutMs,
|
|
2264
|
+
now
|
|
2549
2265
|
});
|
|
2266
|
+
for (const id of ids) {
|
|
2267
|
+
await this.closeRuntimeInternal(id, { persistWorkspace: false });
|
|
2268
|
+
}
|
|
2269
|
+
return ids;
|
|
2550
2270
|
}
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
submenu: [
|
|
2559
|
-
{
|
|
2560
|
-
label: t("quickView"),
|
|
2561
|
-
click: () => actions.activate(runtime.id)
|
|
2562
|
-
},
|
|
2563
|
-
{
|
|
2564
|
-
label: "WebUI",
|
|
2565
|
-
enabled: running,
|
|
2566
|
-
submenu: [
|
|
2567
|
-
{
|
|
2568
|
-
label: t("chat"),
|
|
2569
|
-
click: () => actions.activateAndNavigate(runtime.id, { activity: "chat", view: "chat" })
|
|
2570
|
-
},
|
|
2571
|
-
{
|
|
2572
|
-
label: t("focusPrompt"),
|
|
2573
|
-
click: () => actions.activateAndNavigate(runtime.id, { action: "focus-chat" })
|
|
2574
|
-
},
|
|
2575
|
-
{
|
|
2576
|
-
label: t("terminal"),
|
|
2577
|
-
click: () => actions.activateAndNavigate(runtime.id, { terminal: "toggle" })
|
|
2578
|
-
},
|
|
2579
|
-
{
|
|
2580
|
-
label: t("newTerminal"),
|
|
2581
|
-
click: () => actions.activateAndNavigate(runtime.id, { terminal: "new" })
|
|
2582
|
-
},
|
|
2583
|
-
{ type: "separator" },
|
|
2584
|
-
{
|
|
2585
|
-
label: t("files"),
|
|
2586
|
-
click: () => actions.activateAndNavigate(runtime.id, { activity: "files", view: "files" })
|
|
2587
|
-
},
|
|
2588
|
-
{
|
|
2589
|
-
label: t("changes"),
|
|
2590
|
-
click: () => actions.activateAndNavigate(runtime.id, { activity: "changes", view: "changes" })
|
|
2591
|
-
},
|
|
2592
|
-
{
|
|
2593
|
-
label: t("sessions"),
|
|
2594
|
-
click: () => actions.activateAndNavigate(runtime.id, { view: "sessions" })
|
|
2595
|
-
},
|
|
2596
|
-
{
|
|
2597
|
-
label: t("fleetHQ"),
|
|
2598
|
-
click: () => actions.activateAndNavigate(runtime.id, { view: "roster" })
|
|
2599
|
-
},
|
|
2600
|
-
{
|
|
2601
|
-
label: t("settings"),
|
|
2602
|
-
click: () => actions.activateAndNavigate(runtime.id, { view: "settings" })
|
|
2603
|
-
},
|
|
2604
|
-
{ type: "separator" },
|
|
2605
|
-
{
|
|
2606
|
-
label: t("commandPalette"),
|
|
2607
|
-
click: () => actions.activateAndNavigate(runtime.id, { action: "open-command-palette" })
|
|
2608
|
-
},
|
|
2609
|
-
{
|
|
2610
|
-
label: t("modelSwitcher"),
|
|
2611
|
-
click: () => actions.activateAndNavigate(runtime.id, { action: "open-model-switcher" })
|
|
2612
|
-
}
|
|
2613
|
-
]
|
|
2614
|
-
},
|
|
2615
|
-
{ type: "separator" },
|
|
2616
|
-
{
|
|
2617
|
-
label: t("openInBrowser"),
|
|
2618
|
-
enabled: running,
|
|
2619
|
-
click: () => actions.openBrowser(runtime.id)
|
|
2620
|
-
},
|
|
2621
|
-
{
|
|
2622
|
-
label: t("reloadWebui"),
|
|
2623
|
-
enabled: running,
|
|
2624
|
-
click: () => actions.reload(runtime.id)
|
|
2625
|
-
},
|
|
2626
|
-
{
|
|
2627
|
-
label: t("closeSession"),
|
|
2628
|
-
click: () => actions.close(runtime.id)
|
|
2271
|
+
scheduleLogChanged(runtime) {
|
|
2272
|
+
if (runtime.id !== this.activeRuntimeId) return;
|
|
2273
|
+
if (runtime.logNotifyTimer) return;
|
|
2274
|
+
runtime.logNotifyTimer = setTimeout(() => {
|
|
2275
|
+
runtime.logNotifyTimer = null;
|
|
2276
|
+
if (this.runtimes.get(runtime.id) === runtime && runtime.id === this.activeRuntimeId) {
|
|
2277
|
+
this.emitChanged();
|
|
2629
2278
|
}
|
|
2630
|
-
|
|
2631
|
-
}
|
|
2279
|
+
}, 250);
|
|
2280
|
+
}
|
|
2281
|
+
};
|
|
2282
|
+
function hasChildExited(child) {
|
|
2283
|
+
return child.exitCode !== null || child.signalCode !== null;
|
|
2632
2284
|
}
|
|
2633
|
-
function
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
const
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2285
|
+
function waitForChildExit(child, timeoutMs) {
|
|
2286
|
+
if (hasChildExited(child)) return Promise.resolve(true);
|
|
2287
|
+
return new Promise((resolve4) => {
|
|
2288
|
+
let settled = false;
|
|
2289
|
+
const finish = (exited) => {
|
|
2290
|
+
if (settled) return;
|
|
2291
|
+
settled = true;
|
|
2292
|
+
clearTimeout(timer);
|
|
2293
|
+
child.off("exit", onExit);
|
|
2294
|
+
resolve4(exited);
|
|
2295
|
+
};
|
|
2296
|
+
const onExit = () => finish(true);
|
|
2297
|
+
const timer = setTimeout(() => finish(hasChildExited(child)), timeoutMs);
|
|
2298
|
+
timer.unref?.();
|
|
2299
|
+
child.once("exit", onExit);
|
|
2300
|
+
if (hasChildExited(child)) finish(true);
|
|
2301
|
+
});
|
|
2302
|
+
}
|
|
2303
|
+
async function terminateProcessTree(child) {
|
|
2304
|
+
if (!child?.pid || hasChildExited(child)) return;
|
|
2305
|
+
if (process.platform !== "win32") {
|
|
2306
|
+
const pid = child.pid;
|
|
2307
|
+
const exited = waitForChildExit(child, 5e3);
|
|
2308
|
+
child.kill("SIGTERM");
|
|
2309
|
+
if (await exited) return;
|
|
2310
|
+
if (!hasChildExited(child)) {
|
|
2311
|
+
try {
|
|
2312
|
+
process.kill(-pid, "SIGKILL");
|
|
2313
|
+
} catch {
|
|
2314
|
+
child.kill("SIGKILL");
|
|
2315
|
+
}
|
|
2316
|
+
await waitForChildExit(child, 1e3);
|
|
2642
2317
|
}
|
|
2643
|
-
|
|
2644
|
-
key,
|
|
2645
|
-
name: path4.basename(runtime.root) || runtime.name,
|
|
2646
|
-
root: runtime.root,
|
|
2647
|
-
sessions: [runtime]
|
|
2648
|
-
});
|
|
2318
|
+
return;
|
|
2649
2319
|
}
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2320
|
+
await new Promise((resolve4) => {
|
|
2321
|
+
let settled = false;
|
|
2322
|
+
const finish = () => {
|
|
2323
|
+
if (settled) return;
|
|
2324
|
+
settled = true;
|
|
2325
|
+
resolve4();
|
|
2326
|
+
};
|
|
2327
|
+
const timer = setTimeout(finish, 3e3);
|
|
2328
|
+
timer.unref?.();
|
|
2329
|
+
const killer = spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], {
|
|
2330
|
+
stdio: "ignore",
|
|
2331
|
+
windowsHide: true
|
|
2332
|
+
});
|
|
2333
|
+
killer.once("exit", () => {
|
|
2334
|
+
clearTimeout(timer);
|
|
2335
|
+
finish();
|
|
2336
|
+
});
|
|
2337
|
+
killer.once("error", () => {
|
|
2338
|
+
clearTimeout(timer);
|
|
2339
|
+
child.kill();
|
|
2340
|
+
finish();
|
|
2341
|
+
});
|
|
2342
|
+
});
|
|
2654
2343
|
}
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
label: ctx.t("removeActiveFromRegistry"),
|
|
2669
|
-
enabled: hasActiveProjectWebui,
|
|
2670
|
-
click: () => {
|
|
2671
|
-
if (active?.kind === "project") void ctx.unregisterProject(active.root);
|
|
2672
|
-
}
|
|
2673
|
-
},
|
|
2674
|
-
{ type: "separator" },
|
|
2675
|
-
{
|
|
2676
|
-
label: ctx.t("newSessionForActive"),
|
|
2677
|
-
accelerator: "CmdOrCtrl+N",
|
|
2678
|
-
enabled: hasActiveProjectWebui,
|
|
2679
|
-
click: () => void ctx.openProjectSession(active?.id)
|
|
2680
|
-
},
|
|
2681
|
-
{
|
|
2682
|
-
label: ctx.t("settings"),
|
|
2683
|
-
accelerator: "CmdOrCtrl+,",
|
|
2684
|
-
click: () => {
|
|
2685
|
-
if (hasActiveRuntime) navigate({ view: "settings" });
|
|
2686
|
-
else void ctx.openSettings();
|
|
2687
|
-
}
|
|
2688
|
-
},
|
|
2689
|
-
{ type: "separator" },
|
|
2690
|
-
{
|
|
2691
|
-
label: ctx.t("closeActiveRuntime"),
|
|
2692
|
-
accelerator: "CmdOrCtrl+W",
|
|
2693
|
-
enabled: hasActiveRuntime,
|
|
2694
|
-
click: () => {
|
|
2695
|
-
const id = getActiveRuntimeId();
|
|
2696
|
-
if (id) void ctx.closeRuntime(id);
|
|
2697
|
-
}
|
|
2698
|
-
},
|
|
2699
|
-
{ type: "separator" },
|
|
2700
|
-
{
|
|
2701
|
-
role: process.platform === "darwin" ? "close" : "quit"
|
|
2702
|
-
}
|
|
2703
|
-
]
|
|
2704
|
-
};
|
|
2705
|
-
}
|
|
2706
|
-
function buildWorkspaceMenu(ctx, _actions, hasActiveWebui, prefs, navigate) {
|
|
2707
|
-
const yoloChecked = prefs?.yolo === true;
|
|
2708
|
-
const nextPredictionChecked = prefs?.nextPrediction === true;
|
|
2709
|
-
const contextAutoCompactChecked = prefs?.contextAutoCompact === true;
|
|
2710
|
-
const webuiItem = (item) => ({
|
|
2711
|
-
...item,
|
|
2712
|
-
enabled: item.enabled ?? hasActiveWebui
|
|
2713
|
-
});
|
|
2344
|
+
var SNAPSHOT_LOG_LINES = 40;
|
|
2345
|
+
function publicRuntime(runtime, includeLogs) {
|
|
2346
|
+
const {
|
|
2347
|
+
child: _child,
|
|
2348
|
+
token: _token,
|
|
2349
|
+
logs,
|
|
2350
|
+
logNotifyTimer: _logNotifyTimer,
|
|
2351
|
+
...record
|
|
2352
|
+
} = runtime;
|
|
2353
|
+
void _child;
|
|
2354
|
+
void _token;
|
|
2355
|
+
void _logNotifyTimer;
|
|
2356
|
+
if (!includeLogs) return record;
|
|
2714
2357
|
return {
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
webuiItem({
|
|
2718
|
-
label: ctx.t("openChat"),
|
|
2719
|
-
accelerator: "CmdOrCtrl+1",
|
|
2720
|
-
click: () => navigate({ activity: "chat", view: "chat" })
|
|
2721
|
-
}),
|
|
2722
|
-
webuiItem({
|
|
2723
|
-
label: ctx.t("focusPrompt"),
|
|
2724
|
-
accelerator: "CmdOrCtrl+/",
|
|
2725
|
-
click: () => navigate({ action: "focus-chat" })
|
|
2726
|
-
}),
|
|
2727
|
-
webuiItem({
|
|
2728
|
-
label: ctx.t("toggleTerminal"),
|
|
2729
|
-
accelerator: "CmdOrCtrl+`",
|
|
2730
|
-
click: () => navigate({ terminal: "toggle" })
|
|
2731
|
-
}),
|
|
2732
|
-
webuiItem({ label: ctx.t("newTerminal"), click: () => navigate({ terminal: "new" }) }),
|
|
2733
|
-
{ type: "separator" },
|
|
2734
|
-
webuiItem({
|
|
2735
|
-
label: ctx.t("commandPalette"),
|
|
2736
|
-
accelerator: "CmdOrCtrl+K",
|
|
2737
|
-
click: () => navigate({ action: "open-command-palette" })
|
|
2738
|
-
}),
|
|
2739
|
-
webuiItem({
|
|
2740
|
-
label: ctx.t("quickModelSwitcher"),
|
|
2741
|
-
accelerator: "CmdOrCtrl+M",
|
|
2742
|
-
click: () => navigate({ action: "open-model-switcher" })
|
|
2743
|
-
}),
|
|
2744
|
-
webuiItem({
|
|
2745
|
-
type: "checkbox",
|
|
2746
|
-
label: ctx.t("yoloMode"),
|
|
2747
|
-
checked: yoloChecked,
|
|
2748
|
-
accelerator: "CmdOrCtrl+Shift+Y",
|
|
2749
|
-
click: () => navigate({ pref: { key: "yolo", toggle: true } })
|
|
2750
|
-
}),
|
|
2751
|
-
webuiItem({
|
|
2752
|
-
type: "checkbox",
|
|
2753
|
-
label: ctx.t("nextPrediction"),
|
|
2754
|
-
checked: nextPredictionChecked,
|
|
2755
|
-
click: () => navigate({ pref: { key: "nextPrediction", toggle: true } })
|
|
2756
|
-
}),
|
|
2757
|
-
webuiItem({
|
|
2758
|
-
type: "checkbox",
|
|
2759
|
-
label: ctx.t("contextAutoCompact"),
|
|
2760
|
-
checked: contextAutoCompactChecked,
|
|
2761
|
-
click: () => navigate({ pref: { key: "contextAutoCompact", toggle: true } })
|
|
2762
|
-
}),
|
|
2763
|
-
{ type: "separator" },
|
|
2764
|
-
webuiItem({
|
|
2765
|
-
label: ctx.t("reloadActiveWebui"),
|
|
2766
|
-
accelerator: "CmdOrCtrl+Shift+R",
|
|
2767
|
-
click: () => void ctx.reloadActiveWebuiView()
|
|
2768
|
-
})
|
|
2769
|
-
]
|
|
2358
|
+
...record,
|
|
2359
|
+
recentLogs: logs.slice(-SNAPSHOT_LOG_LINES)
|
|
2770
2360
|
};
|
|
2771
2361
|
}
|
|
2772
|
-
function
|
|
2362
|
+
function runtimeToSessionState(runtime) {
|
|
2773
2363
|
return {
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
label: ctx.t("compactDesktopSidebar"),
|
|
2779
|
-
accelerator: "CmdOrCtrl+B",
|
|
2780
|
-
checked: ctx.getShellSidebarCollapsed(),
|
|
2781
|
-
click: () => ctx.setShellSidebarCollapsed(!ctx.getShellSidebarCollapsed())
|
|
2782
|
-
},
|
|
2783
|
-
{ type: "separator" },
|
|
2784
|
-
{ role: "reload" },
|
|
2785
|
-
{ role: "toggleDevTools" },
|
|
2786
|
-
{ type: "separator" },
|
|
2787
|
-
{ role: "resetZoom" },
|
|
2788
|
-
{ role: "zoomIn" },
|
|
2789
|
-
{ role: "zoomOut" },
|
|
2790
|
-
{ type: "separator" },
|
|
2791
|
-
{ role: "togglefullscreen" }
|
|
2792
|
-
]
|
|
2364
|
+
runtimeId: runtime.id,
|
|
2365
|
+
name: runtime.name,
|
|
2366
|
+
root: runtime.root,
|
|
2367
|
+
startedAt: runtime.startedAt
|
|
2793
2368
|
};
|
|
2794
2369
|
}
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
const navigate = (command) => {
|
|
2805
|
-
void ctx.dispatchWebuiCommand(command);
|
|
2806
|
-
};
|
|
2807
|
-
const activateAndNavigate = (runtimeId, command) => {
|
|
2808
|
-
void ctx.activateRuntime(runtimeId).then(() => ctx.dispatchWebuiCommand(command));
|
|
2809
|
-
};
|
|
2810
|
-
const reloadRuntimeWebui = (runtimeId) => {
|
|
2811
|
-
void ctx.activateRuntime(runtimeId).then(() => ctx.reloadActiveWebuiView());
|
|
2812
|
-
};
|
|
2813
|
-
const actions = {
|
|
2814
|
-
activate: (runtimeId) => void ctx.activateRuntime(runtimeId),
|
|
2815
|
-
activateAndNavigate,
|
|
2816
|
-
registerProject: () => void ctx.registerProject(),
|
|
2817
|
-
newSession: (runtimeId) => {
|
|
2818
|
-
if (runtimeId) void ctx.openProjectSession(runtimeId);
|
|
2819
|
-
else void ctx.openProject();
|
|
2820
|
-
},
|
|
2821
|
-
openBrowser: (runtimeId) => {
|
|
2822
|
-
const url = ctx.getRuntimeManager().getRuntimeUrlWithToken(runtimeId);
|
|
2823
|
-
if (url) ctx.openExternal(url);
|
|
2824
|
-
},
|
|
2825
|
-
reload: reloadRuntimeWebui,
|
|
2826
|
-
close: (runtimeId) => void ctx.closeRuntime(runtimeId),
|
|
2827
|
-
reveal: (runtimeId) => {
|
|
2828
|
-
const runtime = ctx.getRuntimeManager().getRuntime(runtimeId);
|
|
2829
|
-
if (runtime) ctx.revealInExplorer(runtime.root);
|
|
2830
|
-
}
|
|
2831
|
-
};
|
|
2832
|
-
const template = [
|
|
2833
|
-
buildFileMenu(
|
|
2834
|
-
ctx,
|
|
2835
|
-
actions,
|
|
2836
|
-
hasActiveRuntime,
|
|
2837
|
-
hasActiveProjectWebui,
|
|
2838
|
-
active,
|
|
2839
|
-
navigate,
|
|
2840
|
-
ctx.getActiveRuntimeId
|
|
2841
|
-
),
|
|
2842
|
-
{
|
|
2843
|
-
label: ctx.t("projects"),
|
|
2844
|
-
submenu: buildProjectsMenu(snapshot.runtimes, actions, ctx.t)
|
|
2845
|
-
},
|
|
2846
|
-
buildWorkspaceMenu(ctx, actions, hasActiveWebui, activeWebuiPrefs, navigate),
|
|
2847
|
-
buildViewMenu(ctx)
|
|
2848
|
-
];
|
|
2849
|
-
Menu2.setApplicationMenu(Menu2.buildFromTemplate(template));
|
|
2370
|
+
function appendRuntimeLog(runtime, stream, text) {
|
|
2371
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
2372
|
+
const line = rawLine.trimEnd();
|
|
2373
|
+
if (!line) continue;
|
|
2374
|
+
runtime.logs.push(`[${stream}] ${line}`);
|
|
2375
|
+
}
|
|
2376
|
+
if (runtime.logs.length > 120) {
|
|
2377
|
+
runtime.logs.splice(0, runtime.logs.length - 120);
|
|
2378
|
+
}
|
|
2850
2379
|
}
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
var SUMMARY_SUFFIX = ".summary.json";
|
|
2861
|
-
var DEFAULT_SESSION_LIMIT = 25;
|
|
2862
|
-
var CACHE_TTL_MS = 4e3;
|
|
2863
|
-
var cache = /* @__PURE__ */ new Map();
|
|
2864
|
-
function projectStoreDir(projectRoot) {
|
|
2865
|
-
return path5.join(wstackGlobalRoot3(), "projects", projectSlug2(path5.resolve(projectRoot)));
|
|
2866
|
-
}
|
|
2867
|
-
function toEntry(raw, fallbackId, mtimeMs) {
|
|
2868
|
-
if (typeof raw !== "object" || raw === null) return null;
|
|
2869
|
-
const r = raw;
|
|
2870
|
-
const id = typeof r.id === "string" && r.id !== "" ? r.id : fallbackId;
|
|
2871
|
-
if (!id) return null;
|
|
2872
|
-
const title = typeof r.title === "string" ? r.title.trim() : "";
|
|
2873
|
-
const startedAt = typeof r.startedAt === "string" ? r.startedAt : new Date(mtimeMs).toISOString();
|
|
2874
|
-
const lastActivityAt = typeof r.lastActivityAt === "string" ? r.lastActivityAt : typeof r.endedAt === "string" ? r.endedAt : startedAt;
|
|
2875
|
-
return {
|
|
2876
|
-
id,
|
|
2877
|
-
// A session that never got a first user message has no title. Showing the
|
|
2878
|
-
// id is better than an empty row the user cannot identify.
|
|
2879
|
-
title: title || id.split("/").pop() || id,
|
|
2880
|
-
startedAt,
|
|
2881
|
-
lastActivityAt,
|
|
2882
|
-
...typeof r.messageCount === "number" ? { messageCount: r.messageCount } : {},
|
|
2883
|
-
...typeof r.model === "string" ? { model: r.model } : {},
|
|
2884
|
-
...typeof r.provider === "string" ? { provider: r.provider } : {}
|
|
2885
|
-
};
|
|
2380
|
+
function normalizePathList(value) {
|
|
2381
|
+
if (!Array.isArray(value)) return [];
|
|
2382
|
+
const roots = [];
|
|
2383
|
+
for (const item of value) {
|
|
2384
|
+
if (typeof item !== "string" || !item.trim()) continue;
|
|
2385
|
+
const resolved = path5.resolve(item);
|
|
2386
|
+
roots.push(resolved);
|
|
2387
|
+
}
|
|
2388
|
+
return roots.slice(0, 12);
|
|
2886
2389
|
}
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
const sessionsDir = path5.join(projectStoreDir(projectRoot), "sessions");
|
|
2891
|
-
const dirStat = await fs5.stat(sessionsDir).catch(() => null);
|
|
2892
|
-
if (!dirStat?.isDirectory()) return [];
|
|
2893
|
-
const cached = cache.get(sessionsDir);
|
|
2894
|
-
if (cached && Date.now() - cached.readAt < CACHE_TTL_MS && cached.limit >= limit) {
|
|
2895
|
-
return cached.sessions.slice(0, limit);
|
|
2896
|
-
}
|
|
2897
|
-
let shards;
|
|
2898
|
-
try {
|
|
2899
|
-
const entries = await fs5.readdir(sessionsDir, { withFileTypes: true });
|
|
2900
|
-
shards = entries.filter((entry) => entry.isDirectory() && SHARD_RE.test(entry.name)).map((entry) => entry.name).sort((a, b) => b.localeCompare(a));
|
|
2901
|
-
} catch {
|
|
2902
|
-
return [];
|
|
2390
|
+
function normalizeSessionStateList(value, fallbackRoots) {
|
|
2391
|
+
if (!Array.isArray(value)) {
|
|
2392
|
+
return fallbackRoots.map((root) => ({ root })).slice(0, 12);
|
|
2903
2393
|
}
|
|
2904
2394
|
const sessions = [];
|
|
2905
|
-
for (const
|
|
2906
|
-
if (
|
|
2907
|
-
const
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
}
|
|
2912
|
-
|
|
2395
|
+
for (const item of value) {
|
|
2396
|
+
if (!item || typeof item !== "object") continue;
|
|
2397
|
+
const candidate = item;
|
|
2398
|
+
if (typeof candidate.root !== "string" || !candidate.root.trim()) continue;
|
|
2399
|
+
const session = {
|
|
2400
|
+
root: path5.resolve(candidate.root)
|
|
2401
|
+
};
|
|
2402
|
+
const runtimeId = normalizeRuntimeId(candidate.runtimeId);
|
|
2403
|
+
if (runtimeId) session.runtimeId = runtimeId;
|
|
2404
|
+
if (typeof candidate.name === "string" && candidate.name.trim()) {
|
|
2405
|
+
session.name = candidate.name.trim().slice(0, 120);
|
|
2913
2406
|
}
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
if (sessions.length >= limit) break;
|
|
2917
|
-
const full = path5.join(shardDir, file);
|
|
2918
|
-
let text;
|
|
2919
|
-
let mtimeMs;
|
|
2920
|
-
try {
|
|
2921
|
-
const [content, stat4] = await Promise.all([fs5.readFile(full, "utf8"), fs5.stat(full)]);
|
|
2922
|
-
text = content;
|
|
2923
|
-
mtimeMs = stat4.mtimeMs;
|
|
2924
|
-
} catch {
|
|
2925
|
-
continue;
|
|
2926
|
-
}
|
|
2927
|
-
let parsed;
|
|
2928
|
-
try {
|
|
2929
|
-
parsed = JSON.parse(text);
|
|
2930
|
-
} catch {
|
|
2931
|
-
continue;
|
|
2932
|
-
}
|
|
2933
|
-
const fallbackId = `${shard}/${file.slice(0, -SUMMARY_SUFFIX.length)}`;
|
|
2934
|
-
const entry = toEntry(parsed, fallbackId, mtimeMs);
|
|
2935
|
-
if (entry) sessions.push(entry);
|
|
2407
|
+
if (typeof candidate.startedAt === "string" && candidate.startedAt.trim()) {
|
|
2408
|
+
session.startedAt = candidate.startedAt.trim();
|
|
2936
2409
|
}
|
|
2410
|
+
sessions.push(session);
|
|
2937
2411
|
}
|
|
2938
|
-
sessions.
|
|
2939
|
-
cache.set(sessionsDir, { readAt: Date.now(), limit, sessions });
|
|
2940
|
-
return sessions;
|
|
2412
|
+
return sessions.slice(0, 12);
|
|
2941
2413
|
}
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
|
|
2414
|
+
function normalizeRuntimeId(value) {
|
|
2415
|
+
if (typeof value !== "string") return void 0;
|
|
2416
|
+
const trimmed = value.trim();
|
|
2417
|
+
if (!/^[a-zA-Z0-9._:-]{3,120}$/.test(trimmed)) return void 0;
|
|
2418
|
+
return trimmed;
|
|
2419
|
+
}
|
|
2420
|
+
function normalizeWindowState(value) {
|
|
2421
|
+
if (!value || typeof value !== "object") return null;
|
|
2422
|
+
const candidate = value;
|
|
2423
|
+
const width = Number(candidate.width);
|
|
2424
|
+
const height = Number(candidate.height);
|
|
2425
|
+
if (!Number.isFinite(width) || !Number.isFinite(height)) return null;
|
|
2426
|
+
if (width < MIN_WINDOW_WIDTH2 || height < MIN_WINDOW_HEIGHT2) return null;
|
|
2427
|
+
const state = {
|
|
2428
|
+
width: Math.round(width),
|
|
2429
|
+
height: Math.round(height),
|
|
2430
|
+
maximized: Boolean(candidate.maximized)
|
|
2431
|
+
};
|
|
2432
|
+
if (Number.isFinite(Number(candidate.x))) state.x = Math.round(Number(candidate.x));
|
|
2433
|
+
if (Number.isFinite(Number(candidate.y))) state.y = Math.round(Number(candidate.y));
|
|
2434
|
+
return state;
|
|
2435
|
+
}
|
|
2436
|
+
function firstRunningRuntimeId(runtimes) {
|
|
2437
|
+
return Array.from(runtimes.values()).find((runtime) => runtime.status === "running")?.id ?? null;
|
|
2438
|
+
}
|
|
2439
|
+
function firstProjectRuntimeRoot(runtimes) {
|
|
2440
|
+
return Array.from(runtimes.values()).find(
|
|
2441
|
+
(runtime) => runtime.status === "running" && runtime.kind === "project"
|
|
2442
|
+
)?.root ?? null;
|
|
2443
|
+
}
|
|
2444
|
+
function usedPorts(runtimes) {
|
|
2445
|
+
const ports = /* @__PURE__ */ new Set();
|
|
2446
|
+
for (const runtime of runtimes.values()) {
|
|
2447
|
+
ports.add(runtime.httpPort);
|
|
2448
|
+
ports.add(runtime.wsPort);
|
|
2953
2449
|
}
|
|
2450
|
+
return ports;
|
|
2954
2451
|
}
|
|
2955
|
-
function
|
|
2956
|
-
const
|
|
2957
|
-
|
|
2452
|
+
function nextRuntimeName(runtimes, root, kind) {
|
|
2453
|
+
const baseName = path5.basename(root) || root;
|
|
2454
|
+
if (kind !== "project") return baseName;
|
|
2455
|
+
const liveSameRoot = Array.from(runtimes.values()).filter(
|
|
2456
|
+
(runtime) => runtime.kind === "project" && samePath(runtime.root, root) && runtime.status !== "stopped"
|
|
2457
|
+
).length;
|
|
2458
|
+
return liveSameRoot === 0 ? baseName : `${baseName} #${liveSameRoot + 1}`;
|
|
2958
2459
|
}
|
|
2959
|
-
function
|
|
2960
|
-
|
|
2460
|
+
function pathKey(value) {
|
|
2461
|
+
const resolved = path5.resolve(value);
|
|
2462
|
+
return os.platform() === "win32" ? resolved.toLowerCase() : resolved;
|
|
2961
2463
|
}
|
|
2962
|
-
function
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
2464
|
+
async function findFreePort(startPort, exclude) {
|
|
2465
|
+
for (let port = startPort; port < startPort + 200; port++) {
|
|
2466
|
+
if (exclude.has(port)) continue;
|
|
2467
|
+
if (await isPortFree(port)) return port;
|
|
2468
|
+
}
|
|
2469
|
+
throw new Error(`No free local port found near ${startPort}`);
|
|
2470
|
+
}
|
|
2471
|
+
function isPortFree(port) {
|
|
2472
|
+
return new Promise((resolve4) => {
|
|
2473
|
+
const server = net.createServer();
|
|
2474
|
+
server.once("error", () => resolve4(false));
|
|
2475
|
+
server.once("listening", () => {
|
|
2476
|
+
server.close(() => resolve4(true));
|
|
2477
|
+
});
|
|
2478
|
+
server.listen(port, "127.0.0.1");
|
|
2479
|
+
});
|
|
2480
|
+
}
|
|
2481
|
+
function waitForHttpReady(baseUrl, token, timeoutMs) {
|
|
2482
|
+
const deadline = Date.now() + timeoutMs;
|
|
2483
|
+
const url = new URL(baseUrl);
|
|
2484
|
+
url.searchParams.set("token", token);
|
|
2485
|
+
url.searchParams.set("shell", "desktop");
|
|
2486
|
+
return new Promise((resolve4, reject) => {
|
|
2487
|
+
let probeTimer;
|
|
2488
|
+
const cleanup = () => {
|
|
2489
|
+
if (probeTimer) {
|
|
2490
|
+
clearTimeout(probeTimer);
|
|
2491
|
+
probeTimer = void 0;
|
|
2980
2492
|
}
|
|
2981
|
-
}
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2493
|
+
};
|
|
2494
|
+
const probe = () => {
|
|
2495
|
+
let done = false;
|
|
2496
|
+
const triggerRetry = () => {
|
|
2497
|
+
if (done) return;
|
|
2498
|
+
done = true;
|
|
2499
|
+
if (Date.now() >= deadline) {
|
|
2500
|
+
reject(new Error(`WebUI did not become ready at ${baseUrl}`));
|
|
2501
|
+
return;
|
|
2502
|
+
}
|
|
2503
|
+
probeTimer = setTimeout(probe, 250);
|
|
2504
|
+
};
|
|
2505
|
+
const req = http.get(url, (res) => {
|
|
2506
|
+
res.resume();
|
|
2507
|
+
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 500) {
|
|
2508
|
+
if (!done) {
|
|
2509
|
+
done = true;
|
|
2510
|
+
cleanup();
|
|
2511
|
+
resolve4();
|
|
2512
|
+
}
|
|
2513
|
+
return;
|
|
2514
|
+
}
|
|
2515
|
+
triggerRetry();
|
|
2516
|
+
});
|
|
2517
|
+
req.once("error", () => {
|
|
2518
|
+
triggerRetry();
|
|
2519
|
+
});
|
|
2520
|
+
req.setTimeout(1e3, () => {
|
|
2521
|
+
req.destroy();
|
|
2522
|
+
triggerRetry();
|
|
2523
|
+
});
|
|
2524
|
+
};
|
|
2525
|
+
probe();
|
|
2526
|
+
});
|
|
2527
|
+
}
|
|
2528
|
+
async function readGlobalProjectManifest() {
|
|
2529
|
+
const manifestFile = path5.join(wstackGlobalRoot2(), "projects.json");
|
|
2530
|
+
try {
|
|
2531
|
+
const raw = await fs3.readFile(manifestFile, "utf8");
|
|
2532
|
+
return normalizeProjectManifest(JSON.parse(raw));
|
|
2533
|
+
} catch {
|
|
2534
|
+
return [];
|
|
2535
|
+
}
|
|
2536
|
+
}
|
|
2537
|
+
function normalizeProjectManifest(value) {
|
|
2538
|
+
if (Array.isArray(value)) return normalizeProjectEntries(value).slice(0, 80);
|
|
2539
|
+
if (!value || typeof value !== "object") return [];
|
|
2540
|
+
const manifest = value;
|
|
2541
|
+
const source = Array.isArray(manifest.projects) ? manifest.projects : Array.isArray(manifest.recentProjects) ? manifest.recentProjects : Array.isArray(manifest.recents) ? manifest.recents : [];
|
|
2542
|
+
return normalizeProjectEntries(source).slice(0, 80);
|
|
2543
|
+
}
|
|
2544
|
+
function normalizeProjectEntries(value) {
|
|
2545
|
+
if (!Array.isArray(value)) return [];
|
|
2546
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2547
|
+
const projects = [];
|
|
2548
|
+
for (const item of value) {
|
|
2549
|
+
const project = normalizeProjectEntry(item);
|
|
2550
|
+
if (!project) continue;
|
|
2551
|
+
const key = pathKey(project.root);
|
|
2552
|
+
if (seen.has(key)) continue;
|
|
2553
|
+
seen.add(key);
|
|
2554
|
+
projects.push(project);
|
|
2555
|
+
}
|
|
2556
|
+
return projects.sort(
|
|
2557
|
+
(a, b) => (b.lastSeen ?? b.createdAt ?? "").localeCompare(a.lastSeen ?? a.createdAt ?? "")
|
|
2558
|
+
);
|
|
2559
|
+
}
|
|
2560
|
+
function normalizeProjectEntry(value) {
|
|
2561
|
+
if (!value || typeof value !== "object") return null;
|
|
2562
|
+
const candidate = value;
|
|
2563
|
+
if (typeof candidate.root !== "string" || !candidate.root.trim()) return null;
|
|
2564
|
+
const root = path5.resolve(candidate.root);
|
|
2565
|
+
const name = typeof candidate.name === "string" && candidate.name.trim() ? candidate.name.trim() : path5.basename(root) || root;
|
|
2566
|
+
const entry = {
|
|
2567
|
+
name,
|
|
2568
|
+
root,
|
|
2569
|
+
slug: typeof candidate.slug === "string" && candidate.slug.trim() ? candidate.slug.trim() : projectSlug(root)
|
|
2985
2570
|
};
|
|
2571
|
+
if (typeof candidate.lastSeen === "string" && candidate.lastSeen.trim()) {
|
|
2572
|
+
entry.lastSeen = candidate.lastSeen.trim();
|
|
2573
|
+
}
|
|
2574
|
+
if (typeof candidate.createdAt === "string" && candidate.createdAt.trim()) {
|
|
2575
|
+
entry.createdAt = candidate.createdAt.trim();
|
|
2576
|
+
}
|
|
2577
|
+
if (typeof candidate.lastWorkingDir === "string" && candidate.lastWorkingDir.trim()) {
|
|
2578
|
+
entry.lastWorkingDir = path5.resolve(candidate.lastWorkingDir);
|
|
2579
|
+
}
|
|
2580
|
+
return entry;
|
|
2581
|
+
}
|
|
2582
|
+
async function touchGlobalProjectManifest(entry) {
|
|
2583
|
+
const manifestFile = path5.join(wstackGlobalRoot2(), "projects.json");
|
|
2584
|
+
const projects = await readGlobalProjectManifest();
|
|
2585
|
+
const existing = projects.find((p) => samePath(p.root, entry.root));
|
|
2586
|
+
if (existing) {
|
|
2587
|
+
existing.name = entry.name;
|
|
2588
|
+
existing.slug = entry.slug;
|
|
2589
|
+
existing.lastSeen = entry.lastSeen;
|
|
2590
|
+
existing.lastWorkingDir = entry.lastWorkingDir;
|
|
2591
|
+
} else {
|
|
2592
|
+
projects.push({ ...entry, createdAt: entry.lastSeen });
|
|
2593
|
+
}
|
|
2594
|
+
const sorted = projects.sort(
|
|
2595
|
+
(a, b) => (b.lastSeen ?? b.createdAt ?? "").localeCompare(a.lastSeen ?? a.createdAt ?? "")
|
|
2596
|
+
).slice(0, 80);
|
|
2597
|
+
await fs3.mkdir(path5.dirname(manifestFile), { recursive: true });
|
|
2598
|
+
await atomicWrite2(manifestFile, `${JSON.stringify({ projects: sorted }, null, 2)}
|
|
2599
|
+
`, {
|
|
2600
|
+
mode: 384
|
|
2601
|
+
});
|
|
2602
|
+
return sorted;
|
|
2603
|
+
}
|
|
2604
|
+
async function removeGlobalProjectManifest(projectRoot) {
|
|
2605
|
+
const manifestFile = path5.join(wstackGlobalRoot2(), "projects.json");
|
|
2606
|
+
const resolved = path5.resolve(projectRoot);
|
|
2607
|
+
const projects = (await readGlobalProjectManifest()).filter(
|
|
2608
|
+
(project) => !samePath(project.root, resolved)
|
|
2609
|
+
);
|
|
2610
|
+
await fs3.mkdir(path5.dirname(manifestFile), { recursive: true });
|
|
2611
|
+
await atomicWrite2(manifestFile, `${JSON.stringify({ projects }, null, 2)}
|
|
2612
|
+
`, { mode: 384 });
|
|
2613
|
+
return projects;
|
|
2614
|
+
}
|
|
2615
|
+
function samePath(left, right) {
|
|
2616
|
+
const a = path5.resolve(left);
|
|
2617
|
+
const b = path5.resolve(right);
|
|
2618
|
+
return os.platform() === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
2986
2619
|
}
|
|
2987
2620
|
|
|
2988
|
-
// src/main/
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
(
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
}
|
|
3005
|
-
|
|
3006
|
-
);
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
}
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
});
|
|
2621
|
+
// src/main/runtime/operations.ts
|
|
2622
|
+
async function openProject(ctx, requestedRoot) {
|
|
2623
|
+
let projectRoot = requestedRoot;
|
|
2624
|
+
if (!projectRoot) {
|
|
2625
|
+
projectRoot = await ctx.chooseProjectRoot("open");
|
|
2626
|
+
}
|
|
2627
|
+
if (!projectRoot) return ctx.getRuntimeManager().snapshot();
|
|
2628
|
+
await ctx.getRuntimeManager().openProject(projectRoot);
|
|
2629
|
+
ctx.syncActiveWebuiView();
|
|
2630
|
+
ctx.broadcastState();
|
|
2631
|
+
return ctx.getRuntimeManager().snapshot();
|
|
2632
|
+
}
|
|
2633
|
+
async function registerProject(ctx, requestedRoot) {
|
|
2634
|
+
let projectRoot = requestedRoot;
|
|
2635
|
+
if (!projectRoot) {
|
|
2636
|
+
projectRoot = await ctx.chooseProjectRoot("register");
|
|
2637
|
+
}
|
|
2638
|
+
if (!projectRoot) return ctx.getRuntimeManager().snapshot();
|
|
2639
|
+
await ctx.getRuntimeManager().registerProject(projectRoot);
|
|
2640
|
+
ctx.broadcastState();
|
|
2641
|
+
return ctx.getRuntimeManager().snapshot();
|
|
2642
|
+
}
|
|
2643
|
+
async function unregisterProject(ctx, root) {
|
|
2644
|
+
if (!root || typeof root !== "string") return ctx.getRuntimeManager().snapshot();
|
|
2645
|
+
await ctx.getRuntimeManager().unregisterProject(root);
|
|
2646
|
+
ctx.broadcastState();
|
|
2647
|
+
return ctx.getRuntimeManager().snapshot();
|
|
2648
|
+
}
|
|
2649
|
+
async function openProjectSession(ctx, runtimeId) {
|
|
2650
|
+
const snapshot = ctx.getRuntimeManager().snapshot();
|
|
2651
|
+
const runtime = (runtimeId ? snapshot.runtimes.find((candidate) => candidate.id === runtimeId) : void 0) ?? snapshot.runtimes.find((candidate) => candidate.id === snapshot.activeRuntimeId);
|
|
2652
|
+
if (runtime?.kind !== "project") {
|
|
2653
|
+
return openProject(ctx);
|
|
2654
|
+
}
|
|
2655
|
+
await ctx.getRuntimeManager().openProject(runtime.root, { forceNew: true });
|
|
2656
|
+
ctx.syncActiveWebuiView();
|
|
2657
|
+
ctx.broadcastState();
|
|
2658
|
+
return ctx.getRuntimeManager().snapshot();
|
|
2659
|
+
}
|
|
2660
|
+
async function openSettings(ctx) {
|
|
2661
|
+
const snapshot = ctx.getRuntimeManager().snapshot();
|
|
2662
|
+
const active = snapshot.runtimes.find((runtime) => runtime.id === snapshot.activeRuntimeId);
|
|
2663
|
+
if (!active || active.kind === "global-settings" || active.status !== "running") {
|
|
2664
|
+
const root = desktopSettingsWorkspaceRoot();
|
|
2665
|
+
await fs4.mkdir(root, { recursive: true });
|
|
2666
|
+
await ctx.getRuntimeManager().openProject(root, {
|
|
2667
|
+
name: "Global Settings",
|
|
2668
|
+
kind: "global-settings",
|
|
2669
|
+
touchRecent: false
|
|
2670
|
+
});
|
|
2671
|
+
ctx.syncActiveWebuiView();
|
|
2672
|
+
ctx.broadcastState();
|
|
2673
|
+
}
|
|
2674
|
+
await ctx.dispatchWebuiCommand({ view: "settings" });
|
|
2675
|
+
return ctx.getRuntimeManager().snapshot();
|
|
2676
|
+
}
|
|
2677
|
+
async function activateRuntime(ctx, id) {
|
|
2678
|
+
await ctx.getRuntimeManager().activateRuntime(id);
|
|
2679
|
+
ctx.syncActiveWebuiView();
|
|
2680
|
+
ctx.broadcastState();
|
|
2681
|
+
return ctx.getRuntimeManager().snapshot();
|
|
2682
|
+
}
|
|
2683
|
+
async function closeRuntime(ctx, id) {
|
|
2684
|
+
ctx.getAgentBridge().close(id);
|
|
2685
|
+
await ctx.getRuntimeManager().closeRuntime(id);
|
|
2686
|
+
ctx.syncActiveWebuiView();
|
|
2687
|
+
ctx.broadcastState();
|
|
2688
|
+
return ctx.getRuntimeManager().snapshot();
|
|
2689
|
+
}
|
|
2690
|
+
async function restoreLastWorkspace(ctx) {
|
|
2691
|
+
await ctx.getRuntimeManager().restoreLastWorkspace();
|
|
2692
|
+
ctx.syncActiveWebuiView();
|
|
2693
|
+
ctx.broadcastState();
|
|
2694
|
+
}
|
|
3063
2695
|
|
|
3064
|
-
// src/main/
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
2696
|
+
// src/main/webui/controller.ts
|
|
2697
|
+
import { shell, WebContentsView } from "electron";
|
|
2698
|
+
|
|
2699
|
+
// src/main/webui-command-bridge.ts
|
|
2700
|
+
var DESKTOP_WEBUI_ACTIONS = /* @__PURE__ */ new Set([
|
|
2701
|
+
"new-session",
|
|
2702
|
+
"clear-context",
|
|
2703
|
+
"compact-context",
|
|
2704
|
+
"repair-context",
|
|
2705
|
+
"download-chat",
|
|
2706
|
+
"focus-chat",
|
|
2707
|
+
"open-command-palette",
|
|
2708
|
+
"open-shortcuts",
|
|
2709
|
+
"search-chat",
|
|
2710
|
+
"open-model-switcher",
|
|
2711
|
+
"open-prompt-library"
|
|
2712
|
+
]);
|
|
2713
|
+
var DESKTOP_WEBUI_VIEWS = /* @__PURE__ */ new Set([
|
|
2714
|
+
"chat",
|
|
2715
|
+
"settings",
|
|
2716
|
+
"goal",
|
|
2717
|
+
"sddhub",
|
|
2718
|
+
"files",
|
|
2719
|
+
"changes",
|
|
2720
|
+
"sessions",
|
|
2721
|
+
"setup",
|
|
2722
|
+
"skill",
|
|
2723
|
+
"roster",
|
|
2724
|
+
"mailbox",
|
|
2725
|
+
"debug",
|
|
2726
|
+
"design-gallery",
|
|
2727
|
+
"refresh-debug",
|
|
2728
|
+
"analytics"
|
|
2729
|
+
]);
|
|
2730
|
+
var DESKTOP_WEBUI_ACTIVITIES = /* @__PURE__ */ new Set([
|
|
2731
|
+
"chat",
|
|
2732
|
+
"agents",
|
|
2733
|
+
"history",
|
|
2734
|
+
"files",
|
|
2735
|
+
"changes",
|
|
2736
|
+
"mailbox",
|
|
2737
|
+
"skills",
|
|
2738
|
+
"design"
|
|
2739
|
+
]);
|
|
2740
|
+
var DESKTOP_WEBUI_OVERLAYS = /* @__PURE__ */ new Set([
|
|
2741
|
+
"fleet",
|
|
2742
|
+
"agents-monitor",
|
|
2743
|
+
"processes",
|
|
2744
|
+
"queue"
|
|
2745
|
+
]);
|
|
2746
|
+
var DESKTOP_WEBUI_DOCKS = /* @__PURE__ */ new Set([
|
|
2747
|
+
"goal",
|
|
2748
|
+
"fleet",
|
|
2749
|
+
"work",
|
|
2750
|
+
"worktrees",
|
|
2751
|
+
"collab"
|
|
2752
|
+
]);
|
|
2753
|
+
var DESKTOP_WEBUI_WORK_TABS = /* @__PURE__ */ new Set(["todos", "tasks", "plan"]);
|
|
2754
|
+
var DESKTOP_WEBUI_PREF_KEYS = /* @__PURE__ */ new Set([
|
|
2755
|
+
"yolo",
|
|
2756
|
+
"nextPrediction",
|
|
2757
|
+
"contextAutoCompact"
|
|
2758
|
+
]);
|
|
2759
|
+
function normalizeDesktopWebuiCommand(value) {
|
|
2760
|
+
if (!isRecord3(value)) return null;
|
|
2761
|
+
const command = {};
|
|
2762
|
+
let hasCommand = false;
|
|
2763
|
+
const sessionId = value["sessionId"];
|
|
2764
|
+
if (sessionId !== void 0) {
|
|
2765
|
+
if (typeof sessionId !== "string" || sessionId.length === 0 || sessionId.length > 512) {
|
|
2766
|
+
return null;
|
|
3073
2767
|
}
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
if (!
|
|
3080
|
-
|
|
3081
|
-
return [];
|
|
2768
|
+
command.sessionId = sessionId;
|
|
2769
|
+
hasCommand = true;
|
|
2770
|
+
}
|
|
2771
|
+
const action = value["action"];
|
|
2772
|
+
if (action !== void 0) {
|
|
2773
|
+
if (typeof action !== "string" || !DESKTOP_WEBUI_ACTIONS.has(action)) {
|
|
2774
|
+
return null;
|
|
3082
2775
|
}
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
const result = validateOrDefault(booleanSchema, collapsed, true);
|
|
3091
|
-
ctx.setShellSidebarCollapsed(result);
|
|
3092
|
-
return true;
|
|
3093
|
-
});
|
|
3094
|
-
ipcMain.handle(IPC.openSettings, async () => ctx.openSettings());
|
|
3095
|
-
ipcMain.handle(IPC.openProjectSession, async (_event, runtimeId) => {
|
|
3096
|
-
const validated = validateOptional(runtimeIdSchema, runtimeId);
|
|
3097
|
-
return ctx.openProjectSession(validated);
|
|
3098
|
-
});
|
|
3099
|
-
ipcMain.handle(IPC.openProject, async (_event, requestedRoot) => {
|
|
3100
|
-
const validated = validateOptional(projectRootSchema, requestedRoot);
|
|
3101
|
-
return ctx.openProject(validated);
|
|
3102
|
-
});
|
|
3103
|
-
ipcMain.handle(IPC.registerProject, async (_event, requestedRoot) => {
|
|
3104
|
-
const validated = validateOptional(projectRootSchema, requestedRoot);
|
|
3105
|
-
return ctx.registerProject(validated);
|
|
3106
|
-
});
|
|
3107
|
-
ipcMain.handle(IPC.unregisterProject, async (_event, root) => {
|
|
3108
|
-
const result = validate(pathSchema, root);
|
|
3109
|
-
if (!result.success) {
|
|
3110
|
-
validationLogger.log(`unregisterProject: ${result.error}`);
|
|
3111
|
-
return ctx.getRuntimeManager().snapshot();
|
|
2776
|
+
command.action = action;
|
|
2777
|
+
hasCommand = true;
|
|
2778
|
+
}
|
|
2779
|
+
const view = value["view"];
|
|
2780
|
+
if (view !== void 0) {
|
|
2781
|
+
if (typeof view !== "string" || !DESKTOP_WEBUI_VIEWS.has(view)) {
|
|
2782
|
+
return null;
|
|
3112
2783
|
}
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
return
|
|
2784
|
+
command.view = view;
|
|
2785
|
+
hasCommand = true;
|
|
2786
|
+
}
|
|
2787
|
+
const activity = value["activity"];
|
|
2788
|
+
if (activity !== void 0) {
|
|
2789
|
+
if (typeof activity !== "string" || !DESKTOP_WEBUI_ACTIVITIES.has(activity)) {
|
|
2790
|
+
return null;
|
|
3120
2791
|
}
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
return
|
|
2792
|
+
command.activity = activity;
|
|
2793
|
+
hasCommand = true;
|
|
2794
|
+
}
|
|
2795
|
+
const overlay = value["overlay"];
|
|
2796
|
+
if (overlay !== void 0) {
|
|
2797
|
+
if (typeof overlay !== "string" || !DESKTOP_WEBUI_OVERLAYS.has(overlay)) {
|
|
2798
|
+
return null;
|
|
3128
2799
|
}
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
return
|
|
2800
|
+
command.overlay = overlay;
|
|
2801
|
+
hasCommand = true;
|
|
2802
|
+
}
|
|
2803
|
+
const dockSection = value["dockSection"];
|
|
2804
|
+
if (dockSection !== void 0) {
|
|
2805
|
+
if (typeof dockSection !== "string" || !DESKTOP_WEBUI_DOCKS.has(dockSection)) {
|
|
2806
|
+
return null;
|
|
3136
2807
|
}
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
ctx.getRuntimeManager().getRuntimeWsUrlWithToken(idResult.data) ?? "",
|
|
3145
|
-
safeContent
|
|
3146
|
-
);
|
|
3147
|
-
});
|
|
3148
|
-
ipcMain.handle(IPC.abortRuntime, async (_event, id) => {
|
|
3149
|
-
const result = validate(runtimeIdSchema, id);
|
|
3150
|
-
if (!result.success) {
|
|
3151
|
-
validationLogger.log(`abortRuntime: ${result.error}`);
|
|
3152
|
-
return ctx.abortRuntime("", "");
|
|
2808
|
+
command.dockSection = dockSection;
|
|
2809
|
+
hasCommand = true;
|
|
2810
|
+
}
|
|
2811
|
+
const workTab = value["workTab"];
|
|
2812
|
+
if (workTab !== void 0) {
|
|
2813
|
+
if (typeof workTab !== "string" || !DESKTOP_WEBUI_WORK_TABS.has(workTab)) {
|
|
2814
|
+
return null;
|
|
3153
2815
|
}
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
2816
|
+
command.workTab = workTab;
|
|
2817
|
+
hasCommand = true;
|
|
2818
|
+
}
|
|
2819
|
+
const terminal = value["terminal"];
|
|
2820
|
+
if (terminal !== void 0) {
|
|
2821
|
+
if (terminal !== true && terminal !== false && terminal !== "toggle" && terminal !== "new") {
|
|
2822
|
+
return null;
|
|
2823
|
+
}
|
|
2824
|
+
command.terminal = terminal;
|
|
2825
|
+
hasCommand = true;
|
|
2826
|
+
}
|
|
2827
|
+
const pref = value["pref"];
|
|
2828
|
+
if (pref !== void 0) {
|
|
2829
|
+
if (!isRecord3(pref)) return null;
|
|
2830
|
+
const key = pref["key"];
|
|
2831
|
+
if (typeof key !== "string" || !DESKTOP_WEBUI_PREF_KEYS.has(key)) {
|
|
2832
|
+
return null;
|
|
2833
|
+
}
|
|
2834
|
+
const toggle = pref["toggle"];
|
|
2835
|
+
const prefValue = pref["value"];
|
|
2836
|
+
if (toggle !== void 0 && typeof toggle !== "boolean") return null;
|
|
2837
|
+
if (prefValue !== void 0 && typeof prefValue !== "boolean") return null;
|
|
2838
|
+
if (toggle === void 0 && prefValue === void 0) return null;
|
|
2839
|
+
command.pref = {
|
|
2840
|
+
key,
|
|
2841
|
+
...typeof prefValue === "boolean" ? { value: prefValue } : {},
|
|
2842
|
+
...typeof toggle === "boolean" ? { toggle } : {}
|
|
2843
|
+
};
|
|
2844
|
+
hasCommand = true;
|
|
2845
|
+
}
|
|
2846
|
+
return hasCommand ? command : null;
|
|
2847
|
+
}
|
|
2848
|
+
function buildWebuiCommandFallbackScript(command) {
|
|
2849
|
+
const payload = JSON.stringify(command).replace(/</g, "\\u003c");
|
|
2850
|
+
return `window.dispatchEvent(new CustomEvent('wrongstack:desktop-command', { detail: ${payload} })); true;`;
|
|
2851
|
+
}
|
|
2852
|
+
function isRecord3(value) {
|
|
2853
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
2854
|
+
}
|
|
2855
|
+
|
|
2856
|
+
// src/main/webui/navigation.ts
|
|
2857
|
+
function allowedExternalProtocol(target) {
|
|
2858
|
+
try {
|
|
2859
|
+
const protocol = new URL(target).protocol;
|
|
2860
|
+
return OPEN_EXTERNAL_ALLOWED_PROTOCOLS.has(protocol) ? protocol : void 0;
|
|
2861
|
+
} catch {
|
|
2862
|
+
return void 0;
|
|
2863
|
+
}
|
|
2864
|
+
}
|
|
2865
|
+
function sameOrigin(candidate, base) {
|
|
2866
|
+
if (!base) return false;
|
|
2867
|
+
try {
|
|
2868
|
+
return new URL(candidate).origin === new URL(base).origin;
|
|
2869
|
+
} catch {
|
|
2870
|
+
return false;
|
|
2871
|
+
}
|
|
2872
|
+
}
|
|
2873
|
+
|
|
2874
|
+
// src/main/webui/controller.ts
|
|
2875
|
+
var DesktopWebuiController = class {
|
|
2876
|
+
constructor(ctx) {
|
|
2877
|
+
this.ctx = ctx;
|
|
2878
|
+
}
|
|
2879
|
+
ctx;
|
|
2880
|
+
views = /* @__PURE__ */ new Map();
|
|
2881
|
+
/** Last live four-tab declaration from each running project WebUI. */
|
|
2882
|
+
openSessions = /* @__PURE__ */ new Map();
|
|
2883
|
+
pendingAcks = /* @__PURE__ */ new Map();
|
|
2884
|
+
activeRuntimeId = null;
|
|
2885
|
+
status = { runtimeId: null, status: "idle" };
|
|
2886
|
+
commandSequence = 0;
|
|
2887
|
+
openExternal(target) {
|
|
2888
|
+
const protocol = allowedExternalProtocol(target);
|
|
2889
|
+
if (!protocol) return;
|
|
2890
|
+
void authorizeDesktopAction(this.ctx.trustBoundary, {
|
|
2891
|
+
capability: "url.open-external",
|
|
2892
|
+
subject: { kind: "url", id: target, attributes: { protocol } },
|
|
2893
|
+
risk: "elevated",
|
|
2894
|
+
// Reached from the WebUI view, whose content is remote and reflects agent
|
|
2895
|
+
// and tool output (WS-SEC-03).
|
|
2896
|
+
origin: "remote-client",
|
|
2897
|
+
metadata: { operation: "webui-navigation" }
|
|
2898
|
+
}).then((decision) => decision.allowed ? void shell.openExternal(target) : void 0).catch(() => void 0);
|
|
2899
|
+
}
|
|
2900
|
+
publishStatus(next) {
|
|
2901
|
+
this.status = next;
|
|
2902
|
+
const shellView2 = this.ctx.getShellView();
|
|
2903
|
+
if (!shellView2 || shellView2.webContents.isDestroyed()) return;
|
|
2904
|
+
shellView2.webContents.send(IPC.webuiStatusChanged, next);
|
|
2905
|
+
}
|
|
2906
|
+
setEntryStatus(entry, next) {
|
|
2907
|
+
const previousPrefs = entry.status.prefs;
|
|
2908
|
+
entry.status = {
|
|
2909
|
+
...next,
|
|
2910
|
+
...next.prefs === void 0 && previousPrefs !== void 0 ? { prefs: previousPrefs } : {},
|
|
2911
|
+
// `pendingCommands` is always derived from the entry's own array (source of truth).
|
|
2912
|
+
// Any `next.pendingCommands` value is intentionally overwritten.
|
|
2913
|
+
pendingCommands: entry.pendingCommands.length
|
|
2914
|
+
};
|
|
2915
|
+
if (this.activeRuntimeId === entry.runtimeId) {
|
|
2916
|
+
this.publishStatus(entry.status);
|
|
2917
|
+
this.ctx.onPrefsChanged?.(previousPrefs, entry.status.prefs);
|
|
2918
|
+
}
|
|
2919
|
+
}
|
|
2920
|
+
openSessionSnapshots() {
|
|
2921
|
+
return [...this.openSessions].map(([runtimeId, sessions]) => ({ runtimeId, sessions }));
|
|
2922
|
+
}
|
|
2923
|
+
setOpenSessions(runtimeId, sessions) {
|
|
2924
|
+
if (sessions.length === 0) this.openSessions.delete(runtimeId);
|
|
2925
|
+
else this.openSessions.set(runtimeId, sessions);
|
|
2926
|
+
this.ctx.onOpenSessionsChanged?.({ runtimeId, sessions });
|
|
2927
|
+
}
|
|
2928
|
+
pruneOpenSessions(liveRuntimeIds) {
|
|
2929
|
+
for (const runtimeId of [...this.openSessions.keys()]) {
|
|
2930
|
+
if (liveRuntimeIds.has(runtimeId)) continue;
|
|
2931
|
+
this.openSessions.delete(runtimeId);
|
|
2932
|
+
this.ctx.onOpenSessionsChanged?.({ runtimeId, sessions: [] });
|
|
2933
|
+
}
|
|
2934
|
+
}
|
|
2935
|
+
ensure(runtimeId) {
|
|
2936
|
+
const mainWindow2 = this.ctx.getMainWindow();
|
|
2937
|
+
if (!mainWindow2) return null;
|
|
2938
|
+
const existing = this.views.get(runtimeId);
|
|
2939
|
+
if (existing) return existing;
|
|
2940
|
+
const view = new WebContentsView({
|
|
2941
|
+
webPreferences: {
|
|
2942
|
+
preload: webuiPreloadPath(),
|
|
2943
|
+
contextIsolation: true,
|
|
2944
|
+
nodeIntegration: false,
|
|
2945
|
+
// This is the view that renders agent output, tool results, file
|
|
2946
|
+
// contents, and fetched pages — i.e. the most attacker-influenceable
|
|
2947
|
+
// surface in the app, and the one that most needs process-level
|
|
2948
|
+
// containment rather than only bridge-level. webui-preload.ts imports
|
|
2949
|
+
// just electron's contextBridge/ipcRenderer and a constants map, so it
|
|
2950
|
+
// is already within the sandboxed preload subset (WS-093).
|
|
2951
|
+
sandbox: true
|
|
2952
|
+
}
|
|
2953
|
+
});
|
|
2954
|
+
const entry = {
|
|
2955
|
+
runtimeId,
|
|
2956
|
+
view,
|
|
2957
|
+
url: null,
|
|
2958
|
+
status: { runtimeId, status: "idle" },
|
|
2959
|
+
bridgeReady: false,
|
|
2960
|
+
attached: false,
|
|
2961
|
+
pendingCommands: [],
|
|
2962
|
+
pendingFlushTimer: null,
|
|
2963
|
+
pendingFlushAttempts: 0
|
|
2964
|
+
};
|
|
2965
|
+
view.webContents.setWindowOpenHandler(({ url }) => {
|
|
2966
|
+
this.openExternal(url);
|
|
2967
|
+
return { action: "deny" };
|
|
2968
|
+
});
|
|
2969
|
+
view.webContents.on("will-navigate", (event, url) => {
|
|
2970
|
+
if (sameOrigin(url, entry.url)) return;
|
|
2971
|
+
event.preventDefault();
|
|
2972
|
+
this.openExternal(url);
|
|
2973
|
+
});
|
|
2974
|
+
view.webContents.on("did-start-loading", () => {
|
|
2975
|
+
if (this.views.get(runtimeId) !== entry) return;
|
|
2976
|
+
entry.bridgeReady = false;
|
|
2977
|
+
this.setEntryStatus(entry, { runtimeId, status: "loading" });
|
|
2978
|
+
});
|
|
2979
|
+
view.webContents.on("did-finish-load", () => {
|
|
2980
|
+
if (this.views.get(runtimeId) !== entry) return;
|
|
2981
|
+
this.scheduleFlush(entry);
|
|
2982
|
+
try {
|
|
2983
|
+
entry.view.webContents.send(IPC.webuiLocaleChanged, this.ctx.getLocale());
|
|
2984
|
+
} catch {
|
|
2985
|
+
}
|
|
2986
|
+
});
|
|
2987
|
+
view.webContents.on("did-fail-load", (_event, errorCode, errorDescription) => {
|
|
2988
|
+
if (this.views.get(runtimeId) !== entry || errorCode === -3) return;
|
|
2989
|
+
this.setEntryStatus(entry, { runtimeId, status: "error", error: errorDescription });
|
|
2990
|
+
});
|
|
2991
|
+
view.webContents.on("render-process-gone", (_event, details) => {
|
|
2992
|
+
if (this.views.get(runtimeId) !== entry) return;
|
|
2993
|
+
this.setEntryStatus(entry, {
|
|
2994
|
+
runtimeId,
|
|
2995
|
+
status: "error",
|
|
2996
|
+
error: `WebUI renderer exited: ${details.reason}`
|
|
2997
|
+
});
|
|
2998
|
+
});
|
|
2999
|
+
this.views.set(runtimeId, entry);
|
|
3000
|
+
return entry;
|
|
3001
|
+
}
|
|
3002
|
+
attach(entry) {
|
|
3003
|
+
const mainWindow2 = this.ctx.getMainWindow();
|
|
3004
|
+
if (!mainWindow2 || entry.attached) return;
|
|
3005
|
+
mainWindow2.contentView.addChildView(entry.view);
|
|
3006
|
+
entry.attached = true;
|
|
3007
|
+
}
|
|
3008
|
+
dispose(entry) {
|
|
3009
|
+
this.views.delete(entry.runtimeId);
|
|
3010
|
+
entry.pendingCommands.length = 0;
|
|
3011
|
+
this.settleRuntimeAcks(entry.runtimeId, false);
|
|
3012
|
+
if (entry.pendingFlushTimer) clearTimeout(entry.pendingFlushTimer);
|
|
3013
|
+
entry.pendingFlushTimer = null;
|
|
3014
|
+
const mainWindow2 = this.ctx.getMainWindow();
|
|
3015
|
+
if (mainWindow2 && entry.attached) mainWindow2.contentView.removeChildView(entry.view);
|
|
3016
|
+
entry.attached = false;
|
|
3017
|
+
if (!entry.view.webContents.isDestroyed()) entry.view.webContents.close();
|
|
3018
|
+
if (this.activeRuntimeId === entry.runtimeId) this.activeRuntimeId = null;
|
|
3019
|
+
}
|
|
3020
|
+
disposeAll() {
|
|
3021
|
+
for (const entry of [...this.views.values()]) this.dispose(entry);
|
|
3022
|
+
this.openSessions.clear();
|
|
3023
|
+
}
|
|
3024
|
+
findBySenderId(senderId) {
|
|
3025
|
+
return [...this.views.values()].find((entry) => entry.view.webContents.id === senderId);
|
|
3026
|
+
}
|
|
3027
|
+
/**
|
|
3028
|
+
* Point the window at the active runtime's WebUI, and keep no other view.
|
|
3029
|
+
*
|
|
3030
|
+
* Only views whose runtime had STOPPED were released here. A view for a
|
|
3031
|
+
* running-but-background project stayed alive for the life of the app — a
|
|
3032
|
+
* full Chromium renderer process each, hidden purely by setting its width to
|
|
3033
|
+
* zero in `layoutViews`. Ten open projects meant ten renderer processes to
|
|
3034
|
+
* show one, and memory grew with every project ever visited.
|
|
3035
|
+
*
|
|
3036
|
+
* Now exactly one view exists: the active one. Switching projects releases
|
|
3037
|
+
* the previous view and loads the next, which costs a page load on the way
|
|
3038
|
+
* back — the deliberate trade for a footprint that does not grow with how
|
|
3039
|
+
* many projects are open. The shell covers that load with its own
|
|
3040
|
+
* `loading` state, so the gap is visible as progress rather than as a blank
|
|
3041
|
+
* window.
|
|
3042
|
+
*
|
|
3043
|
+
* Queued WebUI commands are not at risk: `dispatch` only ever targets
|
|
3044
|
+
* `activeEntry()`, so a disposed background view cannot have had any.
|
|
3045
|
+
*/
|
|
3046
|
+
syncActive() {
|
|
3047
|
+
if (!this.ctx.getMainWindow()) return;
|
|
3048
|
+
const snapshot = this.ctx.manager.snapshot();
|
|
3049
|
+
this.pruneOpenSessions(
|
|
3050
|
+
new Set(
|
|
3051
|
+
snapshot.runtimes.filter((runtime) => runtime.status === "running" || runtime.status === "starting").map((runtime) => runtime.id)
|
|
3052
|
+
)
|
|
3053
|
+
);
|
|
3054
|
+
const active = snapshot.runtimes.find((runtime) => runtime.id === snapshot.activeRuntimeId);
|
|
3055
|
+
const keep = active?.status === "running" ? active.id : null;
|
|
3056
|
+
for (const [id, entry2] of this.views) if (id !== keep) this.dispose(entry2);
|
|
3057
|
+
if (active?.status !== "running") {
|
|
3058
|
+
this.activeRuntimeId = active?.id ?? null;
|
|
3059
|
+
this.publishStatus({ runtimeId: active?.id ?? null, status: "idle" });
|
|
3060
|
+
this.ctx.layoutViews();
|
|
3163
3061
|
return;
|
|
3164
3062
|
}
|
|
3165
|
-
const url = ctx.
|
|
3166
|
-
if (url)
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
if (!result.success) {
|
|
3171
|
-
validationLogger.log(`revealRuntimeRoot: ${result.error}`);
|
|
3063
|
+
const url = this.ctx.manager.getRuntimeUrlWithToken(active.id);
|
|
3064
|
+
if (!url) {
|
|
3065
|
+
this.activeRuntimeId = active.id;
|
|
3066
|
+
this.publishStatus({ runtimeId: active.id, status: "idle" });
|
|
3067
|
+
this.ctx.layoutViews();
|
|
3172
3068
|
return;
|
|
3173
3069
|
}
|
|
3174
|
-
const
|
|
3175
|
-
if (runtime) ctx.revealInExplorer(runtime.root);
|
|
3176
|
-
});
|
|
3177
|
-
ipcMain.on(IPC.webuiReadyChanged, (event, ready) => {
|
|
3178
|
-
const entry = ctx.findWebuiEntryBySenderId(event.sender.id);
|
|
3070
|
+
const entry = this.ensure(active.id);
|
|
3179
3071
|
if (!entry) return;
|
|
3180
|
-
|
|
3181
|
-
entry
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3072
|
+
this.activeRuntimeId = active.id;
|
|
3073
|
+
this.attach(entry);
|
|
3074
|
+
this.ctx.layoutViews();
|
|
3075
|
+
this.publishStatus(entry.status);
|
|
3076
|
+
if (entry.url === url) return;
|
|
3077
|
+
entry.url = url;
|
|
3078
|
+
entry.bridgeReady = false;
|
|
3079
|
+
this.setEntryStatus(entry, { runtimeId: active.id, status: "loading" });
|
|
3080
|
+
void entry.view.webContents.loadURL(url).catch((error) => {
|
|
3081
|
+
this.setEntryStatus(entry, {
|
|
3082
|
+
runtimeId: active.id,
|
|
3083
|
+
status: "error",
|
|
3084
|
+
error: error instanceof Error ? error.message : String(error)
|
|
3085
|
+
});
|
|
3086
|
+
});
|
|
3087
|
+
}
|
|
3088
|
+
broadcastLocale(locale) {
|
|
3089
|
+
for (const entry of this.views.values()) {
|
|
3090
|
+
if (!entry.view.webContents.isDestroyed())
|
|
3091
|
+
entry.view.webContents.send(IPC.webuiLocaleChanged, locale);
|
|
3187
3092
|
}
|
|
3188
|
-
}
|
|
3189
|
-
|
|
3190
|
-
const
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3093
|
+
}
|
|
3094
|
+
activeEntry() {
|
|
3095
|
+
const id = this.ctx.manager.snapshot().activeRuntimeId;
|
|
3096
|
+
return id ? this.views.get(id) : void 0;
|
|
3097
|
+
}
|
|
3098
|
+
async dispatch(commandInput) {
|
|
3099
|
+
const command = normalizeDesktopWebuiCommand(commandInput);
|
|
3100
|
+
if (!command) return false;
|
|
3101
|
+
const entry = this.activeEntry();
|
|
3102
|
+
if (!entry?.url) return false;
|
|
3103
|
+
if (entry.status.status !== "ready" || !entry.bridgeReady) {
|
|
3104
|
+
if (!entry.view.webContents.isLoading() && entry.status.status !== "error")
|
|
3105
|
+
return this.dispatchNow(entry, command);
|
|
3106
|
+
this.queue(entry, command);
|
|
3107
|
+
this.scheduleFlush(entry);
|
|
3108
|
+
return true;
|
|
3109
|
+
}
|
|
3110
|
+
return this.dispatchNow(entry, command);
|
|
3111
|
+
}
|
|
3112
|
+
async reload() {
|
|
3113
|
+
const entry = this.activeEntry();
|
|
3114
|
+
if (!entry?.url) return false;
|
|
3115
|
+
entry.bridgeReady = false;
|
|
3116
|
+
this.setEntryStatus(entry, { runtimeId: entry.runtimeId, status: "loading" });
|
|
3117
|
+
return entry.view.webContents.loadURL(entry.url).then(() => true).catch((error) => {
|
|
3118
|
+
this.setEntryStatus(entry, {
|
|
3119
|
+
runtimeId: entry.runtimeId,
|
|
3120
|
+
status: "error",
|
|
3121
|
+
error: error instanceof Error ? error.message : String(error)
|
|
3122
|
+
});
|
|
3123
|
+
return false;
|
|
3197
3124
|
});
|
|
3198
|
-
}
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
(
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3125
|
+
}
|
|
3126
|
+
queue(entry, command) {
|
|
3127
|
+
entry.pendingCommands.push(command);
|
|
3128
|
+
if (entry.pendingCommands.length > MAX_PENDING_WEBUI_COMMANDS)
|
|
3129
|
+
entry.pendingCommands.splice(0, entry.pendingCommands.length - MAX_PENDING_WEBUI_COMMANDS);
|
|
3130
|
+
entry.pendingFlushAttempts = 0;
|
|
3131
|
+
this.setEntryStatus(entry, entry.status);
|
|
3132
|
+
}
|
|
3133
|
+
dispatchNow(entry, command) {
|
|
3134
|
+
if (this.views.get(entry.runtimeId) !== entry || !entry.url) return Promise.resolve(false);
|
|
3135
|
+
const requestId = `${entry.runtimeId}:${Date.now()}:${++this.commandSequence}`;
|
|
3136
|
+
const outbound = { ...command, requestId };
|
|
3137
|
+
return new Promise((resolve4) => {
|
|
3138
|
+
const fallbackTimer = setTimeout(() => {
|
|
3139
|
+
if (!this.pendingAcks.has(requestId)) return;
|
|
3140
|
+
if (this.views.get(entry.runtimeId) !== entry || entry.view.webContents.isDestroyed())
|
|
3141
|
+
return;
|
|
3142
|
+
void entry.view.webContents.executeJavaScript(buildWebuiCommandFallbackScript(outbound), true).catch(() => void 0);
|
|
3143
|
+
}, WEBUI_COMMAND_FALLBACK_MS);
|
|
3144
|
+
const timer = setTimeout(
|
|
3145
|
+
() => this.settleAck(requestId, false),
|
|
3146
|
+
WEBUI_COMMAND_ACK_TIMEOUT_MS
|
|
3147
|
+
);
|
|
3148
|
+
this.pendingAcks.set(requestId, {
|
|
3149
|
+
runtimeId: entry.runtimeId,
|
|
3150
|
+
timer,
|
|
3151
|
+
fallbackTimer,
|
|
3152
|
+
resolve: resolve4
|
|
3208
3153
|
});
|
|
3209
|
-
|
|
3210
|
-
|
|
3154
|
+
try {
|
|
3155
|
+
entry.view.webContents.send(IPC.webuiCommand, outbound);
|
|
3156
|
+
if (this.activeRuntimeId === entry.runtimeId) entry.view.webContents.focus();
|
|
3157
|
+
} catch {
|
|
3158
|
+
this.settleAck(requestId, false);
|
|
3159
|
+
}
|
|
3160
|
+
});
|
|
3161
|
+
}
|
|
3162
|
+
settleAck(requestId, handled) {
|
|
3163
|
+
const pending = this.pendingAcks.get(requestId);
|
|
3164
|
+
if (!pending) return;
|
|
3165
|
+
this.pendingAcks.delete(requestId);
|
|
3166
|
+
clearTimeout(pending.timer);
|
|
3167
|
+
if (pending.fallbackTimer) clearTimeout(pending.fallbackTimer);
|
|
3168
|
+
if (handled) {
|
|
3169
|
+
const entry = this.views.get(pending.runtimeId);
|
|
3170
|
+
if (entry) {
|
|
3171
|
+
entry.bridgeReady = true;
|
|
3172
|
+
this.setEntryStatus(entry, { ...entry.status, status: "ready" });
|
|
3173
|
+
}
|
|
3174
|
+
}
|
|
3175
|
+
pending.resolve(handled);
|
|
3176
|
+
}
|
|
3177
|
+
settleRuntimeAcks(runtimeId, handled) {
|
|
3178
|
+
for (const [id, pending] of [...this.pendingAcks])
|
|
3179
|
+
if (pending.runtimeId === runtimeId) this.settleAck(id, handled);
|
|
3180
|
+
}
|
|
3181
|
+
scheduleFlush(entry) {
|
|
3182
|
+
if (entry.pendingFlushTimer) return;
|
|
3183
|
+
entry.pendingFlushTimer = setTimeout(() => {
|
|
3184
|
+
entry.pendingFlushTimer = null;
|
|
3185
|
+
void this.flush(entry);
|
|
3186
|
+
}, 250);
|
|
3187
|
+
}
|
|
3188
|
+
async flush(entry) {
|
|
3189
|
+
if (this.views.get(entry.runtimeId) !== entry || entry.pendingCommands.length === 0) return;
|
|
3190
|
+
if (!entry.bridgeReady) {
|
|
3191
|
+
entry.pendingFlushAttempts += 1;
|
|
3192
|
+
const shouldExecuteFallback = !entry.view.webContents.isLoading() && entry.pendingFlushAttempts >= 4;
|
|
3193
|
+
if (!shouldExecuteFallback && entry.pendingFlushAttempts <= MAX_PENDING_FLUSH_ATTEMPTS) {
|
|
3194
|
+
this.scheduleFlush(entry);
|
|
3195
|
+
this.setEntryStatus(entry, entry.status);
|
|
3196
|
+
return;
|
|
3197
|
+
}
|
|
3198
|
+
if (!shouldExecuteFallback) {
|
|
3199
|
+
entry.pendingCommands.length = 0;
|
|
3200
|
+
this.setEntryStatus(entry, {
|
|
3201
|
+
runtimeId: entry.runtimeId,
|
|
3202
|
+
status: "error",
|
|
3203
|
+
error: "WebUI command bridge did not become ready."
|
|
3204
|
+
});
|
|
3211
3205
|
return;
|
|
3212
3206
|
}
|
|
3213
|
-
const pending = ctx.getPendingWebuiCommandAcks().get(result.data.requestId);
|
|
3214
|
-
if (!pending || pending.runtimeId !== entry.runtimeId) return;
|
|
3215
|
-
ctx.settlePendingWebuiCommandAck(result.data.requestId, result.data.handled);
|
|
3216
3207
|
}
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3208
|
+
entry.pendingFlushAttempts = 0;
|
|
3209
|
+
const commands = entry.pendingCommands.splice(0);
|
|
3210
|
+
this.setEntryStatus(entry, entry.status);
|
|
3211
|
+
for (const command of commands) await this.dispatchNow(entry, command).catch(() => void 0);
|
|
3212
|
+
}
|
|
3213
|
+
};
|
|
3214
|
+
|
|
3215
|
+
// src/main/window-state-controller.ts
|
|
3216
|
+
var DesktopWindowStateController = class {
|
|
3217
|
+
constructor(ctx) {
|
|
3218
|
+
this.ctx = ctx;
|
|
3219
|
+
}
|
|
3220
|
+
ctx;
|
|
3221
|
+
saveTimer = null;
|
|
3222
|
+
scheduleSave() {
|
|
3223
|
+
if (this.saveTimer) clearTimeout(this.saveTimer);
|
|
3224
|
+
this.saveTimer = setTimeout(() => {
|
|
3225
|
+
this.saveTimer = null;
|
|
3226
|
+
void this.save();
|
|
3227
|
+
}, 350);
|
|
3228
|
+
}
|
|
3229
|
+
async save() {
|
|
3230
|
+
const window = this.ctx.getWindow();
|
|
3231
|
+
if (!window || window.isDestroyed?.()) return;
|
|
3232
|
+
const bounds = window.getNormalBounds();
|
|
3233
|
+
await this.ctx.save({ ...bounds, maximized: window.isMaximized() });
|
|
3234
|
+
}
|
|
3235
|
+
validated(state) {
|
|
3236
|
+
if (!state || !Number.isFinite(state.width) || !Number.isFinite(state.height) || state.width < MIN_WINDOW_WIDTH || state.height < MIN_WINDOW_HEIGHT)
|
|
3237
|
+
return null;
|
|
3238
|
+
if (state.x === void 0 || state.y === void 0) {
|
|
3239
|
+
return { width: state.width, height: state.height, maximized: state.maximized };
|
|
3223
3240
|
}
|
|
3224
|
-
|
|
3225
|
-
ctx.
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
const result = schema.safeParse(value);
|
|
3233
|
-
return result.success ? result.data : void 0;
|
|
3234
|
-
}
|
|
3235
|
-
function sanitizeWebuiPrefs(prefs) {
|
|
3236
|
-
const next = {};
|
|
3237
|
-
if (!isRecord2(prefs)) return next;
|
|
3238
|
-
if (typeof prefs["yolo"] === "boolean") next.yolo = prefs["yolo"];
|
|
3239
|
-
if (typeof prefs["nextPrediction"] === "boolean") next.nextPrediction = prefs["nextPrediction"];
|
|
3240
|
-
if (typeof prefs["contextAutoCompact"] === "boolean") {
|
|
3241
|
-
next.contextAutoCompact = prefs["contextAutoCompact"];
|
|
3241
|
+
const candidate = { x: state.x, y: state.y, width: state.width, height: state.height };
|
|
3242
|
+
return this.ctx.getDisplays().some(({ workArea }) => intersects(candidate, workArea)) ? {
|
|
3243
|
+
x: state.x,
|
|
3244
|
+
y: state.y,
|
|
3245
|
+
width: state.width,
|
|
3246
|
+
height: state.height,
|
|
3247
|
+
maximized: state.maximized
|
|
3248
|
+
} : null;
|
|
3242
3249
|
}
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
3250
|
+
};
|
|
3251
|
+
function intersects(left, right) {
|
|
3252
|
+
return left.x < right.x + right.width && left.x + left.width > right.x && left.y < right.y + right.height && left.y + left.height > right.y;
|
|
3247
3253
|
}
|
|
3248
3254
|
|
|
3249
3255
|
// src/main/main.ts
|
|
@@ -3253,7 +3259,7 @@ if (process.platform === "win32") {
|
|
|
3253
3259
|
}
|
|
3254
3260
|
app2.setPath(
|
|
3255
3261
|
"userData",
|
|
3256
|
-
|
|
3262
|
+
path6.join(
|
|
3257
3263
|
resolveWstackPaths3({ projectRoot: process.cwd() }).configDir,
|
|
3258
3264
|
"desktop",
|
|
3259
3265
|
"electron-profile"
|
|
@@ -3275,6 +3281,10 @@ var webuiController = new DesktopWebuiController({
|
|
|
3275
3281
|
layoutViews: () => layoutWebuiViews(),
|
|
3276
3282
|
onPrefsChanged: (previous, next) => {
|
|
3277
3283
|
if (menuRelevantPrefsChanged(previous, next)) configureApplicationMenu2();
|
|
3284
|
+
},
|
|
3285
|
+
onOpenSessionsChanged: (snapshot) => {
|
|
3286
|
+
if (!shellView || shellView.webContents.isDestroyed()) return;
|
|
3287
|
+
shellView.webContents.send(IPC.openSessionsChanged, snapshot);
|
|
3278
3288
|
}
|
|
3279
3289
|
});
|
|
3280
3290
|
var windowStateController = new DesktopWindowStateController({
|
|
@@ -3289,6 +3299,7 @@ function safeOpenExternal(target) {
|
|
|
3289
3299
|
capability: "url.open-external",
|
|
3290
3300
|
subject: { kind: "url", id: target, attributes: { protocol } },
|
|
3291
3301
|
risk: "elevated",
|
|
3302
|
+
origin: "user",
|
|
3292
3303
|
metadata: { operation: "open-external" }
|
|
3293
3304
|
}).then((authorization) => {
|
|
3294
3305
|
if (authorization.allowed) return shell2.openExternal(target);
|
|
@@ -3301,13 +3312,14 @@ function revealInExplorer(root) {
|
|
|
3301
3312
|
capability: "filesystem.open-native",
|
|
3302
3313
|
subject: { kind: "path", id: root, attributes: { target: "file-manager" } },
|
|
3303
3314
|
risk: "elevated",
|
|
3315
|
+
origin: "user",
|
|
3304
3316
|
cwd: root,
|
|
3305
3317
|
metadata: { operation: "reveal-in-explorer" }
|
|
3306
3318
|
}).then((authorization) => {
|
|
3307
3319
|
if (!authorization.allowed) return;
|
|
3308
3320
|
return shell2.openPath(root).catch((err) => {
|
|
3309
3321
|
if (process.platform === "darwin") {
|
|
3310
|
-
void shell2.openPath(
|
|
3322
|
+
void shell2.openPath(path6.dirname(root)).catch(() => void 0);
|
|
3311
3323
|
}
|
|
3312
3324
|
console.error(
|
|
3313
3325
|
JSON.stringify({
|
|
@@ -3446,6 +3458,7 @@ function buildIpcHandlerContext() {
|
|
|
3446
3458
|
getShellView: () => shellView,
|
|
3447
3459
|
getWebuiViews: () => webuiController.views,
|
|
3448
3460
|
getWebuiStatus: () => webuiController.status,
|
|
3461
|
+
getOpenSessions: () => webuiController.openSessionSnapshots(),
|
|
3449
3462
|
getRuntimeManager: () => manager,
|
|
3450
3463
|
getAgentBridge: () => bridge,
|
|
3451
3464
|
getI18n: () => ({ getMainLocale, setMainLocale, tMain }),
|
|
@@ -3479,7 +3492,8 @@ function buildIpcHandlerContext() {
|
|
|
3479
3492
|
getPendingWebuiCommandAcks: () => webuiController.pendingAcks,
|
|
3480
3493
|
settlePendingWebuiCommandAck: (requestId, handled) => webuiController.settleAck(requestId, handled),
|
|
3481
3494
|
setEntryWebuiStatus: (entry, next) => webuiController.setEntryStatus(entry, next),
|
|
3482
|
-
schedulePendingWebuiFlush: (entry) => webuiController.scheduleFlush(entry)
|
|
3495
|
+
schedulePendingWebuiFlush: (entry) => webuiController.scheduleFlush(entry),
|
|
3496
|
+
setOpenSessions: (runtimeId, sessions) => webuiController.setOpenSessions(runtimeId, sessions)
|
|
3483
3497
|
};
|
|
3484
3498
|
}
|
|
3485
3499
|
async function boot() {
|
|
@@ -3519,8 +3533,8 @@ async function boot() {
|
|
|
3519
3533
|
width: prevState?.width ?? defaultWidth,
|
|
3520
3534
|
height: prevState?.height ?? defaultHeight,
|
|
3521
3535
|
show: false,
|
|
3522
|
-
minWidth:
|
|
3523
|
-
minHeight:
|
|
3536
|
+
minWidth: MIN_WINDOW_WIDTH,
|
|
3537
|
+
minHeight: MIN_WINDOW_HEIGHT,
|
|
3524
3538
|
title: tMain("windowTitle"),
|
|
3525
3539
|
...appIcon ? { icon: appIcon } : {}
|
|
3526
3540
|
};
|