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