1import {
2 ContextMenuItemSchema,
3 DesktopAppBrandingSchema,
4 DesktopEnvironmentBootstrapSchema,
5 DesktopThemeSchema,
6 EDITORS,
7 EditorId,
8 PickedThemeFileSchema,
9 PickFolderOptionsSchema,
10 PRIMARY_LOCAL_ENVIRONMENT_ID,
11 REMOTE_CAPABLE_EDITOR_IDS,
12 SystemSettingsPaneSchema,
13 type DesktopEnvironmentBootstrap,
14 type PickedThemeFile,
15} from "@t3tools/contracts";
16import { PROJECT_FAVICON_EXTENSIONS } from "@t3tools/shared/projectFavicon";
17import { resolveEditorCommand } from "@t3tools/shared/editor";
18import * as HostProcess from "@t3tools/shared/HostProcess";
19import * as FileSystem from "effect/FileSystem";
20import * as Path from "effect/Path";
21import * as Effect from "effect/Effect";
22import * as Option from "effect/Option";
23import * as Schema from "effect/Schema";
24
25import * as DesktopBackendConfiguration from "../../backend/DesktopBackendConfiguration.ts";
26import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts";
27import * as DesktopLocalEnvironmentAuth from "../../backend/DesktopLocalEnvironmentAuth.ts";
28import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts";
29import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts";
30import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts";
31import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts";
32import * as ElectronApp from "../../electron/ElectronApp.ts";
33import * as ElectronDialog from "../../electron/ElectronDialog.ts";
34import * as ElectronMenu from "../../electron/ElectronMenu.ts";
35import * as ElectronShell from "../../electron/ElectronShell.ts";
36import * as ElectronTheme from "../../electron/ElectronTheme.ts";
37import * as ElectronWindow from "../../electron/ElectronWindow.ts";
38import * as Electron from "electron";
39import * as MacPermissions from "../../permissions/MacPermissions.ts";
40import { safariPermissionCheck } from "../../preview/BrowserImport/SafariPermission.ts";
41import * as IpcChannels from "../channels.ts";
42import * as DesktopIpc from "../DesktopIpc.ts";
43import {
44 extractDistroFromUncPath,
45 resolveWslPickFolderDefaultPath,
46 wslUncPathToLinuxPath,
47} from "../../wsl/wslPathParsing.ts";
48
49const ContextMenuPosition = Schema.Struct({
50 x: Schema.Number,
51 y: Schema.Number,
52});
53
54const ContextMenuInput = Schema.Struct({
55 items: Schema.Array(ContextMenuItemSchema),
56 position: Schema.optionalKey(ContextMenuPosition),
57});
58
59function toWebSocketBaseUrl(httpBaseUrl: URL): string {
60 const url = new URL(httpBaseUrl.href);
61 url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
62 return url.href;
63}
64
65export const getAppBranding = DesktopIpc.makeSyncIpcMethod({
66 channel: IpcChannels.GET_APP_BRANDING_CHANNEL,
67 result: Schema.NullOr(DesktopAppBrandingSchema),
68 handler: Effect.fn("desktop.ipc.window.getAppBranding")(function* () {
69 const environment = yield* DesktopEnvironment.DesktopEnvironment;
70 return environment.branding;
71 }),
72});
73
74export const getSystemLocale = DesktopIpc.makeSyncIpcMethod({
75 channel: IpcChannels.GET_SYSTEM_LOCALE_CHANNEL,
76 result: Schema.String,
77 handler: Effect.fn("desktop.ipc.window.getSystemLocale")(function* () {
78 const electronApp = yield* ElectronApp.ElectronApp;
79 return yield* electronApp.systemLocale;
80 }),
81});
82
83export const getWindowFullscreenState = DesktopIpc.makeSyncIpcMethod({
84 channel: IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL,
85 result: Schema.Boolean,
86 handler: Effect.fn("desktop.ipc.window.getWindowFullscreenState")(function* () {
87 const electronWindow = yield* ElectronWindow.ElectronWindow;
88 const window = yield* electronWindow.currentMainOrFirst;
89 return Option.isSome(window) && window.value.isFullScreen();
90 }),
91});
92
93export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({
94 channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL,
95 result: Schema.Array(DesktopEnvironmentBootstrapSchema),
96 handler: Effect.fn("desktop.ipc.window.getLocalEnvironmentBootstraps")(function* () {
97 const pool = yield* DesktopBackendPool.DesktopBackendPool;
98 const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
99 const instances = yield* pool.list;
100 const bootstraps: DesktopEnvironmentBootstrap[] = [];
101 for (const instance of instances) {
102 const isPrimary = instance.id === PRIMARY_LOCAL_ENVIRONMENT_ID;
103 const config = yield* instance.currentConfig;
104 const snapshot = yield* instance.snapshot;
105 // A secondary backend (e.g. a parallel WSL backend) that hasn't produced
106 // a config yet (mid-registration, before its first start cycle) or that
107 // is retrying a *transient* preflight failure (WSL VM still booting, a
108 // not-yet-built linux server entry) is not listening on a port. We
109 // surface it as a *pending* bootstrap (null endpoints, no token) so the
110 // renderer can show a "Connecting…" indicator while it retries — null
111 // endpoints keep the renderer from dialing the dead port, avoiding the
112 // needless /api/auth/bootstrap/bearer error cycles a real endpoint would
113 // trigger.
114 if (Option.isNone(config) || Option.isSome(config.value.preflightFailure)) {
115 // Skip the primary (same-origin, no "connecting" affordance) and skip a
116 // secondary whose preflight failed *fatally* (no node, wrong version,
117 // missing build tools): it has stopped retrying, so an indefinite
118 // "Connecting…" would be misleading — its error is surfaced by the
119 // WSL-state UI instead.
120 const fatalPreflight =
121 Option.isSome(config) &&
122 Option.isSome(config.value.preflightFailure) &&
123 config.value.preflightFailure.value.fatal;
124 const stoppedPreflight =
125 Option.isSome(config) &&
126 Option.isSome(config.value.preflightFailure) &&
127 (!snapshot.desiredRunning || !snapshot.restartScheduled);
128 if (isPrimary || fatalPreflight || stoppedPreflight) continue;
129 bootstraps.push({
130 id: instance.id,
131 label: yield* instance.label,
132 runningDistro: null,
133 httpBaseUrl: null,
134 wsBaseUrl: null,
135 });
136 continue;
137 }
138 const { bootstrap, httpBaseUrl } = config.value;
139 const runningDistro = config.value.runningDistro ?? null;
140 bootstraps.push({
141 id: instance.id,
142 label: runningDistro === null ? yield* instance.label : `WSL (${runningDistro})`,
143 runningDistro,
144 httpBaseUrl: httpBaseUrl.href,
145 wsBaseUrl: toWebSocketBaseUrl(httpBaseUrl),
146 // A backend launched with the desktop secret accepts whichever token
147 // the secret derives for the current window, so hand out that one
148 // rather than the token frozen into its launch config. Every backend
149 // the desktop launches (primary, staged or mounted WSL runtime) is the
150 // server build bundled with this desktop, so it understands the secret.
151 ...(bootstrap.desktopBootstrapSecret
152 ? { bootstrapToken: yield* configuration.currentBootstrapToken }
153 : bootstrap.desktopBootstrapToken
154 ? { bootstrapToken: bootstrap.desktopBootstrapToken }
155 : {}),
156 });
157 }
158 return bootstraps;
159 }),
160});
161
162// Pull the distro selection out of a backend instance id like
163// "wsl:ubuntu". Returns null for "wsl:default", which is the sentinel
164// for "track the user's WSL default distro" and maps to the
165// wslEnv-derived default at picker time.
166function extractWslDistroFromEnvironmentId(envId: string): string | null {
167 if (!envId.startsWith(DesktopWslBackend.WSL_INSTANCE_ID_PREFIX)) {
168 return null;
169 }
170 const suffix = envId.slice(DesktopWslBackend.WSL_INSTANCE_ID_PREFIX.length);
171 return suffix === "default" || suffix.length === 0 ? null : suffix;
172}
173
174export const getLocalEnvironmentBearerToken = DesktopIpc.makeIpcMethod({
175 channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL,
176 payload: Schema.Void,
177 result: Schema.String,
178 handler: Effect.fn("desktop.ipc.window.getLocalEnvironmentBearerToken")(function* () {
179 const localAuth = yield* DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth;
180 return yield* localAuth.getBearerToken;
181 }),
182});
183
184export const pickFolder = DesktopIpc.makeIpcMethod({
185 channel: IpcChannels.PICK_FOLDER_CHANNEL,
186 payload: Schema.UndefinedOr(PickFolderOptionsSchema),
187 result: Schema.NullOr(Schema.String),
188 handler: Effect.fn("desktop.ipc.window.pickFolder")(function* (options) {
189 const dialog = yield* ElectronDialog.ElectronDialog;
190 const electronWindow = yield* ElectronWindow.ElectronWindow;
191 const environment = yield* DesktopEnvironment.DesktopEnvironment;
192 const appSettings = yield* DesktopAppSettings.DesktopAppSettings;
193 const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment;
194 const settings = yield* appSettings.get;
195 // A picked path only means something to a backend on this machine.
196 if (!settings.localEnvironmentEnabled) {
197 return null;
198 }
199 // Three picker modes:
200 // - targetEnvironmentId omitted: default to the primary picker. Keeps
201 // the historical behavior unchanged for users who never enabled the
202 // WSL backend, and is what unfamiliar callers should get out of the
203 // box.
204 // - targetEnvironmentId starts with "wsl:": route to the WSL picker
205 // using the distro encoded in the id (or the user's selected
206 // wslDistro when the id is the "wsl:default" sentinel).
207 // - anything else (incl. PRIMARY_LOCAL_ENVIRONMENT_ID): primary picker.
208 const targetId = options?.targetEnvironmentId;
209 const wslDistroFromTarget =
210 targetId !== undefined && targetId.startsWith(DesktopWslBackend.WSL_INSTANCE_ID_PREFIX)
211 ? extractWslDistroFromEnvironmentId(targetId)
212 : null;
213 const useWsl =
214 targetId !== undefined &&
215 targetId !== PRIMARY_LOCAL_ENVIRONMENT_ID &&
216 targetId.startsWith(DesktopWslBackend.WSL_INSTANCE_ID_PREFIX);
217 // Fall back to the persisted wslDistro when the id is the
218 // "wsl:default" sentinel; the orchestrator uses the same fallback
219 // for the actual backend.
220 const wslDistro = useWsl ? (wslDistroFromTarget ?? settings.wslDistro) : null;
221 const defaultPath = useWsl
222 ? Option.fromNullishOr(
223 resolveWslPickFolderDefaultPath(
224 options,
225 { distro: wslDistro },
226 yield* wslEnvironment.listDistros,
227 Option.getOrNull(yield* wslEnvironment.getUserHome(wslDistro)),
228 ),
229 )
230 : environment.resolvePickFolderDefaultPath(options);
231 const selectedPath = yield* dialog.pickFolder({
232 owner: yield* electronWindow.focusedMainOrFirst,
233 defaultPath,
234 });
235 if (Option.isNone(selectedPath)) {
236 return null;
237 }
238 if (!useWsl) {
239 return selectedPath.value;
240 }
241
242 const linuxUncPath = wslUncPathToLinuxPath(selectedPath.value);
243 if (linuxUncPath !== null) {
244 return linuxUncPath;
245 }
246
247 const converted = yield* wslEnvironment.windowsToWslPath(
248 extractDistroFromUncPath(selectedPath.value) ?? wslDistro,
249 selectedPath.value,
250 );
251 return Option.getOrElse(converted, () => selectedPath.value);
252 }),
253});
254
255export const pickProjectFavicon = DesktopIpc.makeIpcMethod({
256 channel: IpcChannels.PICK_PROJECT_FAVICON_CHANNEL,
257 payload: Schema.UndefinedOr(Schema.String),
258 result: Schema.NullOr(Schema.String),
259 handler: Effect.fn("desktop.ipc.window.pickProjectFavicon")(function* (initialPath) {
260 const dialog = yield* ElectronDialog.ElectronDialog;
261 const electronWindow = yield* ElectronWindow.ElectronWindow;
262 const appSettings = yield* DesktopAppSettings.DesktopAppSettings;
263 if (!(yield* appSettings.get).localEnvironmentEnabled) {
264 return null;
265 }
266 const paths = yield* dialog.pickFiles({
267 owner: yield* electronWindow.focusedMainOrFirst,
268 defaultPath: Option.fromNullishOr(initialPath),
269 multiple: false,
270 filters: [
271 {
272 name: "Images",
273 extensions: PROJECT_FAVICON_EXTENSIONS.map((extension) => extension.slice(1)),
274 },
275 ],
276 });
277 return paths[0] ?? null;
278 }),
279});
280
281export const setTheme = DesktopIpc.makeIpcMethod({
282 channel: IpcChannels.SET_THEME_CHANNEL,
283 payload: DesktopThemeSchema,
284 result: Schema.Void,
285 handler: Effect.fn("desktop.ipc.window.setTheme")(function* (theme) {
286 const electronTheme = yield* ElectronTheme.ElectronTheme;
287 yield* electronTheme.setSource(theme);
288 }),
289});
290
291export const showContextMenu = DesktopIpc.makeIpcMethod({
292 channel: IpcChannels.CONTEXT_MENU_CHANNEL,
293 payload: ContextMenuInput,
294 result: Schema.NullOr(Schema.String),
295 handler: Effect.fn("desktop.ipc.window.showContextMenu")(function* (input) {
296 const electronMenu = yield* ElectronMenu.ElectronMenu;
297 const electronWindow = yield* ElectronWindow.ElectronWindow;
298 const window = yield* electronWindow.focusedMainOrFirst;
299 if (Option.isNone(window)) {
300 return null;
301 }
302
303 const selectedItemId = yield* electronMenu.showContextMenu({
304 window: window.value,
305 items: input.items,
306 position: Option.fromNullishOr(input.position),
307 });
308 return Option.getOrNull(selectedItemId);
309 }),
310});
311
312export const openExternal = DesktopIpc.makeIpcMethod({
313 channel: IpcChannels.OPEN_EXTERNAL_CHANNEL,
314 payload: Schema.String,
315 result: Schema.Boolean,
316 handler: Effect.fn("desktop.ipc.window.openExternal")(function* (url) {
317 const shell = yield* ElectronShell.ElectronShell;
318 return yield* shell.openExternal(url);
319 }),
320});
321
322export const openSystemSettings = DesktopIpc.makeIpcMethod({
323 channel: IpcChannels.OPEN_SYSTEM_SETTINGS_CHANNEL,
324 payload: SystemSettingsPaneSchema,
325 result: Schema.Boolean,
326 handler: Effect.fn("desktop.ipc.window.openSystemSettings")(function* (pane) {
327 const shell = yield* ElectronShell.ElectronShell;
328 const environment = yield* DesktopEnvironment.DesktopEnvironment;
329 if (environment.platform !== "darwin") return false;
330 const owner = Electron.BrowserWindow.getFocusedWindow();
331 const opened = yield* shell.openSystemSettings(pane);
332 if (opened && environment.isPackaged) {
333 const permissions = yield* MacPermissions.MacPermissions;
334 const isGranted = yield* safariPermissionCheck;
335 yield* permissions.showHelper(pane, owner, isGranted);
336 }
337 return opened;
338 }),
339});
340
341export const probeRemoteEditors = DesktopIpc.makeIpcMethod({
342 channel: IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL,
343 payload: Schema.Undefined,
344 result: Schema.Array(EditorId),
345 handler: Effect.fn("desktop.ipc.window.probeRemoteEditors")(function* () {
346 const available: Array<EditorId> = [];
347 const env = yield* HostProcess.Environment;
348 for (const editorId of REMOTE_CAPABLE_EDITOR_IDS) {
349 const editor = EDITORS.find((editor) => editor.id === editorId);
350 if (editor && Option.isSome(yield* resolveEditorCommand(editor, env))) {
351 available.push(editorId);
352 }
353 }
354 return available;
355 }),
356});
357
358export const pasteAsText = DesktopIpc.makeIpcMethod({
359 channel: IpcChannels.PASTE_AS_TEXT_CHANNEL,
360 payload: Schema.Undefined,
361 result: Schema.Void,
362 handler: Effect.fn("desktop.ipc.window.pasteAsText")(function* (_input, event) {
363 const electronWindow = yield* ElectronWindow.ElectronWindow;
364 const window = yield* electronWindow.main;
365 if (
366 event === undefined ||
367 Option.isNone(window) ||
368 window.value.isDestroyed() ||
369 window.value.webContents.id !== event.sender.id
370 ) {
371 return;
372 }
373 const focused = Electron.webContents.getFocusedWebContents();
374 if (
375 focused &&
376 !focused.isDestroyed() &&
377 Electron.BrowserWindow.fromWebContents(focused) === window.value
378 ) {
379 focused.paste();
380 }
381 }),
382});
383
384/** Theme files are a few KB; anything larger returns empty text and lets the
385 * renderer reject it by size without the contents ever crossing the bridge. */
386const PICKED_THEME_FILE_MAX_BYTES = 256 * 1024;
387
388export const pickThemeFiles = DesktopIpc.makeIpcMethod({
389 channel: IpcChannels.PICK_THEME_FILES_CHANNEL,
390 payload: Schema.Undefined,
391 result: Schema.NullOr(Schema.Array(PickedThemeFileSchema)),
392 handler: Effect.fn("desktop.ipc.window.pickThemeFiles")(function* () {
393 const dialog = yield* ElectronDialog.ElectronDialog;
394 const electronWindow = yield* ElectronWindow.ElectronWindow;
395 const fileSystem = yield* FileSystem.FileSystem;
396 const path = yield* Path.Path;
397 // The VS Code extensions directory is the same dotfolder on Windows,
398 // macOS, and Linux; when it is missing the picker opens wherever the
399 // platform would by default.
400 const extensionsDir = path.join(yield* HostProcess.HomeDirectory, ".vscode", "extensions");
401 const defaultPath = yield* fileSystem
402 .exists(extensionsDir)
403 .pipe(Effect.orElseSucceed(() => false));
404 const paths = yield* dialog.pickFiles({
405 owner: yield* electronWindow.focusedMainOrFirst,
406 defaultPath: defaultPath ? Option.some(extensionsDir) : Option.none(),
407 filters: [{ name: "JSON", extensions: ["json"] }],
408 multiple: true,
409 });
410 if (paths.length === 0) {
411 return null;
412 }
413 return yield* Effect.forEach(paths, (filePath) => {
414 const name = path.basename(filePath);
415 return Effect.gen(function* () {
416 const info = yield* fileSystem.stat(filePath);
417 const size = Number(info.size);
418 if (size > PICKED_THEME_FILE_MAX_BYTES) {
419 return { name, size, text: "" } satisfies PickedThemeFile;
420 }
421 const text = yield* fileSystem.readFileString(filePath);
422 return { name, size, text } satisfies PickedThemeFile;
423 }).pipe(
424 // An unreadable file degrades to an entry the renderer reports.
425 Effect.orElseSucceed((): PickedThemeFile => ({ name, size: 0, text: "" })),
426 );
427 });
428 }),
429});
430
431export const checkSystemPermission = DesktopIpc.makeIpcMethod({
432 channel: IpcChannels.CHECK_SYSTEM_PERMISSION_CHANNEL,
433 payload: SystemSettingsPaneSchema,
434 result: Schema.Boolean,
435 handler: Effect.fn("desktop.ipc.window.checkSystemPermission")(function* () {
436 const environment = yield* DesktopEnvironment.DesktopEnvironment;
437 if (environment.platform !== "darwin") return false;
438 const check = yield* safariPermissionCheck;
439 return yield* Effect.promise(check);
440 }),
441});