Skip to content
Merged
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
7 changes: 7 additions & 0 deletions apps/desktop/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { DesktopRendererThemeStore } from "./renderer-theme-store.js";
import { sendToLiveDesktopWindow } from "./safe-window-send.js";
import { registerExecutionEngineIpc } from "./execution-engines/ipc.js";
import { createHostExecutionEngineClient } from "./execution-engines/client.js";
import { registerLocalFileIpc } from "./local-files/ipc.js";

const MAX_HOST_OBSTACLES = 128;
const MAX_DESKTOP_COORDINATE = 1_000_000;
Expand Down Expand Up @@ -60,6 +61,11 @@ export function registerDesktopIpc(input: {
getClient: () => createHostExecutionEngineClient(input.executionEngines!.baseUrl, input.executionEngines!.getAuthority),
}) : undefined;
const navigationIntents = input.navigationIntents ?? new DesktopNavigationIntentStore();
const unregisterLocalFiles = input.executionEngines ? registerLocalFileIpc({
ipcMainImpl: desktopIpcMain,
getMainWindow: input.getMainWindow,
trustedDocumentUrl: input.executionEngines.trustedDocumentUrl,
}) : undefined;
const themeStore = input.themeStore ?? new DesktopRendererThemeStore();
let pendingRendererSelectionObserved = false;
let pendingRendererSelection: string | undefined;
Expand Down Expand Up @@ -490,6 +496,7 @@ export function registerDesktopIpc(input: {
);
});
return () => {
unregisterLocalFiles?.();
unregisterExecutionEngines?.();
unsubscribeStatus();
unsubscribeNavigationIntents();
Expand Down
131 changes: 131 additions & 0 deletions apps/desktop/main/local-files/ipc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { execFile } from "node:child_process";
import { copyFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { basename } from "node:path";
import { promisify } from "node:util";
import type { BrowserWindow, IpcMain, IpcMainInvokeEvent, MenuItemConstructorOptions } from "electron";
import type { DesktopLocalFilePreview, DesktopLocalFileRequest } from "../../shared/local-file-capabilities.js";
import { DESKTOP_LOCAL_FILE_IPC_CHANNELS } from "../../shared/ipc-channels.js";
import { inspectLocalFile } from "./preview.js";

const execute = promisify(execFile);
// The path travels as argv, never as executable AppleScript/JavaScript source.
const applicationQuery = `function run(argv) {
ObjC.import('AppKit');
var apps = $.NSWorkspace.sharedWorkspace.URLsForApplicationsToOpenURL($.NSURL.fileURLWithPath(argv[0]));
var result = [];
for (var i = 0; i < Math.min(apps.count, 30); i++) {
var path = ObjC.unwrap(apps.objectAtIndex(i).path);
result.push({path: path, name: ObjC.unwrap($.NSFileManager.defaultManager.displayNameAtPath(path))});
}
return JSON.stringify(result);
}`;

export async function localFileApplications(path: string): Promise<readonly { readonly path: string; readonly name: string }[]> {
if (process.platform !== "darwin") return [];
const { stdout } = await execute("/usr/bin/osascript", ["-l", "JavaScript", "-e", applicationQuery, path], { timeout: 5_000, maxBuffer: 64 * 1024 });
const entries: unknown = JSON.parse(stdout);
if (!Array.isArray(entries)) return [];
return entries.filter((entry): entry is { path: string; name: string } => entry && typeof entry.path === "string" && entry.path.startsWith("/") && entry.path.endsWith(".app") && typeof entry.name === "string");
}

export function registerLocalFileIpc(input: {
readonly ipcMainImpl: Pick<IpcMain, "handle" | "removeHandler">;
readonly getMainWindow: () => BrowserWindow | undefined;
readonly trustedDocumentUrl: string;
}): () => void {
const assertSender = (event: IpcMainInvokeEvent): BrowserWindow => {
const window = input.getMainWindow();
const owner = window?.webContents;
const actual = owner?.getURL();
const expected = new URL(input.trustedDocumentUrl);
const current = actual ? new URL(actual) : undefined;
expected.hash = expected.search = "";
if (current) current.hash = current.search = "";
if (!window || window.isDestroyed() || !owner || owner.isDestroyed() || event.sender !== owner
|| !event.senderFrame || event.senderFrame !== owner.mainFrame || event.senderFrame.detached
|| event.senderFrame.url !== actual || current?.protocol !== "file:" || current.href !== expected.href) throw new Error("local_file_forbidden");
return window;
};
for (const [operation, channel] of Object.entries(DESKTOP_LOCAL_FILE_IPC_CHANNELS)) {
input.ipcMainImpl.handle(channel, async (event, value: unknown) => {
assertSender(event);
const request = readRequest(value);
const preview = await inspectLocalFile(request);
const window = assertSender(event);
if (operation === "inspect") return preview;
return showLocalFileMenu(window, preview, request, () => { assertSender(event); });
});
}
return () => { for (const channel of Object.values(DESKTOP_LOCAL_FILE_IPC_CHANNELS)) input.ipcMainImpl.removeHandler(channel); };
}

function readRequest(value: unknown): DesktopLocalFileRequest {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("local_file_invalid_request");
const request = value as Record<string, unknown>;
if (typeof request.path !== "string" || (request.cwd !== undefined && typeof request.cwd !== "string")
|| (request.locale !== undefined && typeof request.locale !== "string")
|| (request.line !== undefined && (typeof request.line !== "number" || !Number.isSafeInteger(request.line) || request.line < 1))) throw new Error("local_file_invalid_request");
return { path: request.path, cwd: request.cwd as string | undefined, locale: request.locale as string | undefined, line: request.line as number | undefined };
}

async function showLocalFileMenu(
window: BrowserWindow,
preview: DesktopLocalFilePreview,
request: DesktopLocalFileRequest,
assertCurrent: () => void,
): Promise<DesktopLocalFilePreview | undefined> {
const { Menu, shell, dialog, clipboard } = createRequire(import.meta.url)("electron") as typeof import("electron");
const zh = request.locale?.startsWith("zh") ?? false;
const label = (en: string, chinese: string): string => zh ? chinese : en;
let applications: Awaited<ReturnType<typeof localFileApplications>> = [];
try { applications = await localFileApplications(preview.path); } catch { /* The system picker remains available when Launch Services fails. */ }
assertCurrent();
return new Promise((resolve) => {
let selected = false;
const action = (run: () => Promise<DesktopLocalFilePreview | undefined> | DesktopLocalFilePreview | undefined): (() => void) => () => {
selected = true;
void Promise.resolve().then(() => { assertCurrent(); return run(); }).then(resolve, (error: unknown) => {
dialog.showErrorBox(label("Unable to open file", "无法操作文件"), error instanceof Error ? error.message : String(error));
resolve(undefined);
});
};
const openDefault = async (): Promise<undefined> => {
const failure = await shell.openPath(preview.path);
if (failure) throw new Error(failure);
return undefined;
};
const chooseApplication = async (): Promise<undefined> => {
if (process.platform === "win32") {
await execute("rundll32.exe", ["shell32.dll,OpenAs_RunDLL", preview.path], { timeout: 10_000 });
return undefined;
}
const choice = await dialog.showOpenDialog(window, { title: label("Choose application", "选择打开应用"), defaultPath: process.platform === "darwin" ? "/Applications" : "/usr/bin", properties: ["openFile"] });
if (!choice.canceled && choice.filePaths[0]) {
assertCurrent();
if (process.platform === "darwin") await execute("/usr/bin/open", ["-a", choice.filePaths[0], "--", preview.path]);
else await execute(choice.filePaths[0], [preview.path], { timeout: 10_000 });
}
return undefined;
};
const template: MenuItemConstructorOptions[] = [
{ label: label("Preview in Ling", "在 Ling 中预览"), click: action(() => preview) },
{ label: label("Open in default application", "用默认应用打开"), click: action(openDefault) },
{ label: label("Open with", "打开方式"), submenu: [
...applications.map((application) => ({ label: application.name, click: action(async () => { await execute("/usr/bin/open", ["-a", application.path, "--", preview.path]); return undefined; }) })),
...(applications.length ? [{ type: "separator" as const }] : []),
{ label: label("Choose another application…", "选择其他应用…"), click: action(chooseApplication) },
] },
{ type: "separator" },
{ label: label("Save as…", "另存为…"), enabled: preview.kind !== "directory", click: action(async () => {
const result = await dialog.showSaveDialog(window, { defaultPath: basename(preview.path) });
if (!result.canceled && result.filePath && result.filePath !== preview.path) { assertCurrent(); await copyFile(preview.path, result.filePath); }
return undefined;
}) },
{ label: label("Copy path", "复制路径"), click: action(() => { clipboard.writeText(preview.path); return undefined; }) },
...(preview.kind === "text" && !preview.truncated ? [{ label: label("Copy file contents", "复制文件内容"), click: action(() => { clipboard.writeText(preview.text ?? ""); return undefined; }) }] : []),
{ label: label(process.platform === "darwin" ? "Show in Finder" : "Show in folder", process.platform === "darwin" ? "在 Finder 中显示" : "在文件夹中显示"), click: action(() => { shell.showItemInFolder(preview.path); return undefined; }) },
];
Menu.buildFromTemplate(template).popup({ window, callback: () => { if (!selected) resolve(undefined); } });
});
}
70 changes: 70 additions & 0 deletions apps/desktop/main/local-files/preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { constants } from "node:fs";
import { open, opendir, realpath, stat } from "node:fs/promises";
import { homedir } from "node:os";
import { basename, extname, isAbsolute, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import type { DesktopLocalFilePreview, DesktopLocalFileRequest } from "../../shared/local-file-capabilities.js";

export const LOCAL_FILE_TEXT_LIMIT = 256 * 1024;
const IMAGE_LIMIT = 10 * 1024 * 1024;
const DIRECTORY_LIMIT = 500;
const imageTypes: Readonly<Record<string, string>> = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".avif": "image/avif" };
const languages: Readonly<Record<string, string>> = {
".ts": "typescript", ".tsx": "tsx", ".js": "javascript", ".jsx": "jsx", ".mjs": "javascript", ".cjs": "javascript",
".py": "python", ".java": "java", ".kt": "kotlin", ".rs": "rust", ".go": "go", ".c": "c", ".h": "c", ".cpp": "cpp",
".json": "json", ".md": "markdown", ".css": "css", ".html": "html", ".xml": "xml", ".yaml": "yaml", ".yml": "yaml", ".sh": "bash", ".sql": "sql",
};
const binaryExtensions = new Set([".zip", ".rar", ".7z", ".gz", ".tar", ".bz2", ".xz", ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".exe", ".dll", ".dmg", ".pkg", ".mp3", ".mp4", ".mov", ".woff", ".woff2", ".sqlite", ".db"]);

export async function resolveLocalFilePath(request: DesktopLocalFileRequest): Promise<string> {
if (!request || typeof request.path !== "string" || !request.path.trim() || request.path.length > 32_768 || request.path.includes("\0")) throw new Error("local_file_invalid_path");
let path = request.path;
if (path.startsWith("file:")) path = fileURLToPath(path);
else if (/^[a-z][a-z\d+.-]*:/iu.test(path) && !/^[A-Z]:[\\/]/iu.test(path)) throw new Error("local_file_invalid_path");
// Network shares are not local files and may initiate implicit authentication.
if (path.startsWith("\\\\") || path.startsWith("//") || request.cwd?.startsWith("\\\\") || request.cwd?.startsWith("//")) throw new Error("local_file_invalid_path");
if (path === "~" || path.startsWith("~/")) path = join(homedir(), path.slice(2));
if (!isAbsolute(path) && (!request.cwd || !isAbsolute(request.cwd))) throw new Error("local_file_invalid_path");
const candidate = isAbsolute(path) ? path : resolve(request.cwd!, path);
if (candidate.startsWith("\\\\") || candidate.startsWith("//")) throw new Error("local_file_invalid_path");
return realpath(candidate);
}

export async function inspectLocalFile(request: DesktopLocalFileRequest): Promise<DesktopLocalFilePreview> {
const path = await resolveLocalFilePath(request);
const metadata = await stat(path);
const base = { path, name: basename(path) || path, sizeBytes: metadata.size, line: request.line, truncated: false };
if (metadata.isDirectory()) {
const entries: NonNullable<DesktopLocalFilePreview["entries"]>[number][] = [];
const directory = await opendir(path);
let truncated = false;
for await (const entry of directory) {
if (entries.length === DIRECTORY_LIMIT) { truncated = true; break; }
entries.push({ name: entry.name, path: join(path, entry.name), directory: entry.isDirectory() });
}
entries.sort((a, b) => Number(b.directory) - Number(a.directory) || a.name.localeCompare(b.name));
return { ...base, kind: "directory", entries, truncated };
}
if (!metadata.isFile()) throw new Error("local_file_not_regular");
const extension = extname(path).toLowerCase();
if (binaryExtensions.has(extension)) return { ...base, kind: "unsupported" };
const imageType = imageTypes[extension];
if (imageType && metadata.size > IMAGE_LIMIT) return { ...base, kind: "unsupported" };
const file = await open(path, constants.O_RDONLY | constants.O_NONBLOCK | (constants.O_NOFOLLOW ?? 0));
try {
const current = await file.stat();
if (!current.isFile() || current.dev !== metadata.dev || current.ino !== metadata.ino) throw new Error("local_file_changed");
const limit = imageType ? IMAGE_LIMIT : LOCAL_FILE_TEXT_LIMIT;
const buffer = Buffer.alloc(Math.min(current.size, limit) + 1);
const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
const truncated = current.size > limit || bytesRead > limit;
const bytes = buffer.subarray(0, Math.min(bytesRead, limit));
if (imageType) return truncated ? { ...base, kind: "unsupported" } : { ...base, kind: "image", imageUrl: `data:${imageType};base64,${bytes.toString("base64")}` };
if (bytes.includes(0)) return { ...base, kind: "unsupported" };
try {
// stream leaves a split final UTF-8 character buffered on a truncated read.
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes, { stream: truncated });
return { ...base, kind: "text", text, language: languages[extension] ?? "text", truncated };
} catch { return { ...base, kind: "unsupported" }; }
} finally { await file.close(); }
}
6 changes: 5 additions & 1 deletion apps/desktop/preload/main-window.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { contextBridge, ipcRenderer } from "electron";
import { createLocalFileCapabilities } from "../shared/local-file-activation.js";
import type {
ComputerUseHostLayout,
ComputerUsePipDeckOwner,
Expand Down Expand Up @@ -223,7 +224,10 @@ const capabilities: LingDesktopCapabilities = {
},
};

contextBridge.exposeInMainWorld("lingDesktop", capabilities);
contextBridge.exposeInMainWorld("lingDesktop", {
...capabilities,
localFiles: createLocalFileCapabilities((channel, request) => ipcRenderer.invoke(channel, request), globalThis.document),
});

function cloneArchiveRequest(
request: DesktopThemePackageArchiveRequest,
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/shared/desktop-capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@ export interface ComputerUsePreviewPreferences {
}

export interface LingDesktopCapabilities {
readonly localFiles?: import("./local-file-capabilities.js").DesktopLocalFileCapabilities;
readonly humanEvaluation?: import("./human-evaluation-capabilities.js").DesktopHumanEvaluationCapabilities;
readonly platform: DesktopPlatform;
readonly daemonBaseUrl: string;
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/shared/ipc-channels.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import type { DesktopThemePackageIpcChannelKey } from "./theme-package-capabilities.js";

export const DESKTOP_LOCAL_FILE_IPC_CHANNELS = {
inspect: "ling-desktop:local-file:inspect",
menu: "ling-desktop:local-file:menu",
} as const;

export const DESKTOP_HUMAN_EVALUATION_IPC_CHANNELS = {
submit: "ling-desktop:human-evaluation:submit",
cancel: "ling-desktop:human-evaluation:cancel",
Expand Down
34 changes: 34 additions & 0 deletions apps/desktop/shared/local-file-activation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { DesktopLocalFileCapabilities, DesktopLocalFileRequest } from "./local-file-capabilities.js";
import { DESKTOP_LOCAL_FILE_IPC_CHANNELS } from "./ipc-channels.js";

/** An API call alone cannot read host files: it must consume a real link gesture. */
export function createLocalFileCapabilities(
invoke: (channel: string, request: DesktopLocalFileRequest) => Promise<unknown>,
document: Document | undefined,
): DesktopLocalFileCapabilities {
let activation: { path: string; cwd: string; expires: number } | undefined;
const activate = (event: Event): void => {
if (!event.isTrusted) return;
const target = event.target as Element | null;
const link = typeof target?.closest === "function" ? target.closest<HTMLElement>("[data-local-file-path]") : null;
activation = link ? {
path: link.dataset.localFilePath ?? "",
cwd: link.dataset.localFileCwd ?? "",
expires: Date.now() + 1_000,
} : undefined;
};
document?.addEventListener("click", activate, true);
document?.addEventListener("contextmenu", activate, true);
const consume = (request: DesktopLocalFileRequest): DesktopLocalFileRequest => {
const current = activation;
activation = undefined;
if (!current || current.expires < Date.now() || current.path !== request.path || current.cwd !== (request.cwd ?? "")) {
throw new Error("local_file_user_activation_required");
}
return { path: request.path, cwd: request.cwd, line: request.line, locale: request.locale };
};
return {
inspect: async (request) => invoke(DESKTOP_LOCAL_FILE_IPC_CHANNELS.inspect, consume(request)) as ReturnType<DesktopLocalFileCapabilities["inspect"]>,
showMenu: async (request) => invoke(DESKTOP_LOCAL_FILE_IPC_CHANNELS.menu, consume(request)) as ReturnType<DesktopLocalFileCapabilities["showMenu"]>,
};
}
25 changes: 25 additions & 0 deletions apps/desktop/shared/local-file-capabilities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export interface DesktopLocalFileRequest {
readonly path: string;
readonly cwd?: string;
readonly line?: number;
readonly locale?: string;
}

export interface DesktopLocalFilePreview {
readonly path: string;
readonly name: string;
readonly kind: "text" | "image" | "directory" | "unsupported";
readonly sizeBytes: number;
readonly text?: string;
readonly imageUrl?: string;
readonly language?: string;
readonly line?: number;
readonly truncated: boolean;
readonly entries?: readonly { readonly path: string; readonly name: string; readonly directory: boolean }[];
}

/** User-activated local viewing; independent from model/tool execution grants. */
export interface DesktopLocalFileCapabilities {
inspect(request: DesktopLocalFileRequest): Promise<DesktopLocalFilePreview>;
showMenu(request: DesktopLocalFileRequest): Promise<DesktopLocalFilePreview | undefined>;
}
Loading
Loading