Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 25 additions & 27 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<boolean> {
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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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" };
});

Expand Down
69 changes: 69 additions & 0 deletions apps/desktop/src/openTarget.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
131 changes: 131 additions & 0 deletions apps/desktop/src/openTarget.ts
Original file line number Diff line number Diff line change
@@ -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(),
};
}
Loading