From c049c6002afae88a1532df9784282c45b9e7f892 Mon Sep 17 00:00:00 2001 From: Harry Date: Thu, 2 Apr 2026 14:00:34 -0700 Subject: [PATCH] Fix desktop markdown link opening --- apps/desktop/src/main.ts | 52 ++++++----- apps/desktop/src/openTarget.test.ts | 69 +++++++++++++++ apps/desktop/src/openTarget.ts | 131 ++++++++++++++++++++++++++++ 3 files changed, 225 insertions(+), 27 deletions(-) create mode 100644 apps/desktop/src/openTarget.test.ts create mode 100644 apps/desktop/src/openTarget.ts diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 34391a5995..1a7bd6e4f5 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -29,6 +29,7 @@ import type { ContextMenuItem } from "@t3tools/contracts"; import { NetService } from "@t3tools/shared/Net"; import { RotatingFileSink } from "@t3tools/shared/logging"; import { showDesktopConfirmDialog } from "./confirmDialog"; +import { getSafeOpenTarget, type DesktopOpenTarget } from "./openTarget"; import { syncShellEnvironment } from "./syncShellEnvironment"; import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState"; import { @@ -152,23 +153,33 @@ function formatErrorMessage(error: unknown): string { return String(error); } -function getSafeExternalUrl(rawUrl: unknown): string | null { - if (typeof rawUrl !== "string" || rawUrl.length === 0) { - return null; +async function openSafeTarget(target: DesktopOpenTarget | null): Promise { + if (!target) { + return false; } - let parsedUrl: URL; try { - parsedUrl = new URL(rawUrl); - } catch { - return null; - } + if (target.kind === "path") { + const result = await shell.openPath(target.value); + if (result.length > 0) { + writeDesktopLogHeader( + `open target path failed path=${sanitizeLogValue(target.value)} message=${sanitizeLogValue(result)}`, + ); + return false; + } + writeDesktopLogHeader(`open target path succeeded path=${sanitizeLogValue(target.value)}`); + return true; + } - if (parsedUrl.protocol !== "https:" && parsedUrl.protocol !== "http:") { - return null; + await shell.openExternal(target.value); + writeDesktopLogHeader(`open target external succeeded url=${sanitizeLogValue(target.value)}`); + return true; + } catch (error) { + writeDesktopLogHeader( + `open target failed target=${sanitizeLogValue(target.value)} message=${sanitizeLogValue(formatErrorMessage(error))}`, + ); + return false; } - - return parsedUrl.toString(); } function getSafeTheme(rawTheme: unknown): DesktopTheme | null { @@ -1238,17 +1249,7 @@ function registerIpcHandlers(): void { ipcMain.removeHandler(OPEN_EXTERNAL_CHANNEL); ipcMain.handle(OPEN_EXTERNAL_CHANNEL, async (_event, rawUrl: unknown) => { - const externalUrl = getSafeExternalUrl(rawUrl); - if (!externalUrl) { - return false; - } - - try { - await shell.openExternal(externalUrl); - return true; - } catch { - return false; - } + return openSafeTarget(getSafeOpenTarget(rawUrl)); }); ipcMain.removeHandler(UPDATE_GET_STATE_CHANNEL); @@ -1353,10 +1354,7 @@ function createWindow(): BrowserWindow { }); window.webContents.setWindowOpenHandler(({ url }) => { - const externalUrl = getSafeExternalUrl(url); - if (externalUrl) { - void shell.openExternal(externalUrl); - } + void openSafeTarget(getSafeOpenTarget(url)); return { action: "deny" }; }); diff --git a/apps/desktop/src/openTarget.test.ts b/apps/desktop/src/openTarget.test.ts new file mode 100644 index 0000000000..2b67a64a8c --- /dev/null +++ b/apps/desktop/src/openTarget.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; + +import { getSafeOpenTarget, stripLocationSuffixFromLocalPath } from "./openTarget"; + +describe("stripLocationSuffixFromLocalPath", () => { + it("expands the home directory prefix", () => { + expect( + stripLocationSuffixFromLocalPath("~/notes/today.md", { + homeDir: "/Users/tester", + }), + ).toBe("/Users/tester/notes/today.md"); + }); + + it("strips line and hash suffixes when the base path exists", () => { + expect( + stripLocationSuffixFromLocalPath("/Users/tester/notes/today.md:14#L14", { + pathExists: (path) => path === "/Users/tester/notes/today.md", + }), + ).toBe("/Users/tester/notes/today.md"); + }); + + it("keeps the original path when the suffixed path exists", () => { + expect( + stripLocationSuffixFromLocalPath("/Users/tester/notes/today.md:14", { + pathExists: (path) => path === "/Users/tester/notes/today.md:14", + }), + ).toBe("/Users/tester/notes/today.md:14"); + }); +}); + +describe("getSafeOpenTarget", () => { + it("accepts absolute local paths", () => { + expect( + getSafeOpenTarget("/Users/tester/notes/today.md:14", { + pathExists: (path) => path === "/Users/tester/notes/today.md", + }), + ).toEqual({ + kind: "path", + value: "/Users/tester/notes/today.md", + }); + }); + + it("accepts file urls that point to local markdown files", () => { + expect( + getSafeOpenTarget("file:///Users/tester/notes/today.md:14", { + pathExists: (path) => path === "/Users/tester/notes/today.md", + }), + ).toEqual({ + kind: "path", + value: "/Users/tester/notes/today.md", + }); + }); + + it("accepts supported editor and app schemes", () => { + expect(getSafeOpenTarget("zed://file/Users/tester/notes/today.md")).toEqual({ + kind: "external", + value: "zed://file/Users/tester/notes/today.md", + }); + expect(getSafeOpenTarget("obsidian://open?vault=notes&file=today")).toEqual({ + kind: "external", + value: "obsidian://open?vault=notes&file=today", + }); + }); + + it("rejects unsupported schemes", () => { + expect(getSafeOpenTarget("javascript:alert(1)")).toBeNull(); + expect(getSafeOpenTarget("ftp://example.com/file.md")).toBeNull(); + }); +}); diff --git a/apps/desktop/src/openTarget.ts b/apps/desktop/src/openTarget.ts new file mode 100644 index 0000000000..4a585c4478 --- /dev/null +++ b/apps/desktop/src/openTarget.ts @@ -0,0 +1,131 @@ +import * as FS from "node:fs"; +import * as OS from "node:os"; +import * as Path from "node:path"; + +const ALLOWED_EXTERNAL_PROTOCOLS = new Set([ + "http:", + "https:", + "zed:", + "obsidian:", + "vscode:", + "vscode-insiders:", + "cursor:", + "windsurf:", +]); + +export type DesktopOpenTarget = + | { + kind: "path"; + value: string; + } + | { + kind: "external"; + value: string; + }; + +type OpenTargetResolutionOptions = { + homeDir?: string; + pathExists?: (path: string) => boolean; +}; + +function getHomeDir(options: OpenTargetResolutionOptions): string { + return options.homeDir ?? OS.homedir(); +} + +function pathExists(path: string, options: OpenTargetResolutionOptions): boolean { + return options.pathExists?.(path) ?? FS.existsSync(path); +} + +export function stripLocationSuffixFromLocalPath( + rawPath: string, + options: OpenTargetResolutionOptions = {}, +): string | null { + const trimmedPath = rawPath.trim(); + if (trimmedPath.length === 0) { + return null; + } + + let normalizedPath = trimmedPath; + if (normalizedPath === "~") { + normalizedPath = getHomeDir(options); + } else if (normalizedPath.startsWith("~/")) { + normalizedPath = Path.join(getHomeDir(options), normalizedPath.slice(2)); + } + + const hashIndex = normalizedPath.indexOf("#"); + if (hashIndex !== -1) { + normalizedPath = normalizedPath.slice(0, hashIndex); + } + + const queryIndex = normalizedPath.indexOf("?"); + if (queryIndex !== -1) { + normalizedPath = normalizedPath.slice(0, queryIndex); + } + + const lineSuffixMatch = normalizedPath.match(/^(.*?)(:\d+(?::\d+)?)$/); + const basePath = lineSuffixMatch?.[1]; + if (basePath && !pathExists(normalizedPath, options) && pathExists(basePath, options)) { + normalizedPath = basePath; + } + + return normalizedPath; +} + +export function getSafeOpenTarget( + rawUrl: unknown, + options: OpenTargetResolutionOptions = {}, +): DesktopOpenTarget | null { + if (typeof rawUrl !== "string") { + return null; + } + + const normalizedUrl = rawUrl.trim(); + if (normalizedUrl.length === 0) { + return null; + } + + if (normalizedUrl.startsWith("/") || normalizedUrl === "~" || normalizedUrl.startsWith("~/")) { + const localPath = stripLocationSuffixFromLocalPath(normalizedUrl, options); + if (!localPath) { + return null; + } + + return { + kind: "path", + value: localPath, + }; + } + + let parsedUrl: URL; + try { + parsedUrl = new URL(normalizedUrl); + } catch { + return null; + } + + if (parsedUrl.protocol === "file:") { + let localPath = decodeURIComponent(parsedUrl.pathname); + if (process.platform === "win32" && /^\/[A-Za-z]:/.test(localPath)) { + localPath = localPath.slice(1); + } + + const normalizedPath = stripLocationSuffixFromLocalPath(localPath, options); + if (!normalizedPath) { + return null; + } + + return { + kind: "path", + value: normalizedPath, + }; + } + + if (!ALLOWED_EXTERNAL_PROTOCOLS.has(parsedUrl.protocol)) { + return null; + } + + return { + kind: "external", + value: parsedUrl.toString(), + }; +}