| 1 | import { MAC_PERMISSION_SETTINGS_URLS } from "../permissions/MacPermission.ts"; |
| 2 | import { |
| 3 | REMOTE_CAPABLE_EDITOR_IDS, |
| 4 | remoteSchemeForEditor, |
| 5 | type SystemSettingsPane, |
| 6 | } from "@t3tools/contracts"; |
| 7 | import * as Context from "effect/Context"; |
| 8 | import * as Effect from "effect/Effect"; |
| 9 | import * as Layer from "effect/Layer"; |
| 10 | import * as Option from "effect/Option"; |
| 11 | |
| 12 | import * as Electron from "electron"; |
| 13 | |
| 14 | // Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`, |
| 15 | // `zed://ssh/<host>/<path>`, `jetbrains://gateway/ssh/environment?h=…`) must |
| 16 | // reach the OS handler; every other non-web scheme stays blocked. |
| 17 | const SAFE_WEB_PROTOCOLS = new Set(["http:", "https:"]); |
| 18 | const REMOTE_EDITOR_PROTOCOLS = new Set( |
| 19 | REMOTE_CAPABLE_EDITOR_IDS.flatMap((id) => { |
| 20 | const scheme = remoteSchemeForEditor(id); |
| 21 | return scheme === undefined ? [] : [`${scheme}:`]; |
| 22 | }), |
| 23 | ); |
| 24 | |
| 25 | // Zed's host sits in the first path segment, so it needs its own userinfo ban. |
| 26 | const ZED_SSH_PATHNAME = /^\/[^/@:]+\/.*$/; |
| 27 | |
| 28 | const isRemoteEditorUrl = (url: URL) => |
| 29 | REMOTE_EDITOR_PROTOCOLS.has(url.protocol) && |
| 30 | url.username.length === 0 && |
| 31 | url.password.length === 0 && |
| 32 | (url.protocol === "zed:" |
| 33 | ? url.host === "ssh" && ZED_SSH_PATHNAME.test(url.pathname) |
| 34 | : url.protocol === "jetbrains:" |
| 35 | ? url.host === "gateway" && |
| 36 | url.pathname === "/ssh/environment" && |
| 37 | (url.searchParams.get("h") ?? "").length > 0 |
| 38 | : url.host === "vscode-remote" && |
| 39 | url.pathname.startsWith("/ssh-remote+") && |
| 40 | url.pathname.length > "/ssh-remote+".length); |
| 41 | |
| 42 | export function parseSafeExternalUrl(rawUrl: unknown): Option.Option<string> { |
| 43 | if (typeof rawUrl !== "string") { |
| 44 | return Option.none(); |
| 45 | } |
| 46 | |
| 47 | try { |
| 48 | const url = new URL(rawUrl); |
| 49 | return SAFE_WEB_PROTOCOLS.has(url.protocol) || isRemoteEditorUrl(url) |
| 50 | ? Option.some(url.href) |
| 51 | : Option.none(); |
| 52 | } catch { |
| 53 | return Option.none(); |
| 54 | } |
| 55 | } |
| 56 | |
| 57 | export class ElectronShell extends Context.Service< |
| 58 | ElectronShell, |
| 59 | { |
| 60 | readonly openExternal: (rawUrl: unknown) => Effect.Effect<boolean>; |
| 61 | /** Opens a known System Settings pane by identifier, not by URL. */ |
| 62 | readonly openSystemSettings: (pane: SystemSettingsPane) => Effect.Effect<boolean>; |
| 63 | readonly copyText: (text: string) => Effect.Effect<void>; |
| 64 | } |
| 65 | >()("@t3tools/desktop/electron/ElectronShell") {} |
| 66 | |
| 67 | /** @public Service construction is part of the canonical Effect module API. */ |
| 68 | export const make = ElectronShell.of({ |
| 69 | openExternal: (rawUrl) => |
| 70 | Option.match(parseSafeExternalUrl(rawUrl), { |
| 71 | onNone: () => Effect.succeed(false), |
| 72 | onSome: (externalUrl) => |
| 73 | Effect.promise(() => |
| 74 | Electron.shell.openExternal(externalUrl).then( |
| 75 | () => true, |
| 76 | () => false, |
| 77 | ), |
| 78 | ), |
| 79 | }), |
| 80 | openSystemSettings: (pane) => |
| 81 | Effect.promise(() => |
| 82 | Electron.shell.openExternal(MAC_PERMISSION_SETTINGS_URLS[pane]).then( |
| 83 | () => true, |
| 84 | () => false, |
| 85 | ), |
| 86 | ), |
| 87 | copyText: (text) => |
| 88 | Effect.promise(() => Electron.clipboard.writeText(text).catch(() => undefined)), |
| 89 | }); |
| 90 | |
| 91 | export const layer = Layer.succeed(ElectronShell, make); |