From 1c26ad36db9ed2f5cd5fe64ba2a6b37b4003bdb7 Mon Sep 17 00:00:00 2001 From: defia Date: Mon, 28 Sep 2026 11:26:18 +0800 Subject: [PATCH 1/2] feat(web-remote): preview local terminal files in browser Add read-only browsing for the selected local pane's current directory, with UTF-8, image, and sandboxed HTML previews. Support touch pinch zoom for images and expose the browser from the terminal corner. --- .../Resources/WebRemote/web-remote-index.html | 25 ++ Glint/Resources/WebRemote/web-remote.css | 135 +++++++ Glint/Resources/WebRemote/web-remote.js | 368 ++++++++++++++++++ Glint/WebRemote/WebRemoteProtocol.swift | 128 ++++++ Glint/WebRemote/WebRemoteServer.swift | 70 ++++ Glint/Workspace/WorkspaceStore.swift | 13 + GlintTests/WebRemoteProtocolTests.swift | 87 +++++ 7 files changed, 826 insertions(+) diff --git a/Glint/Resources/WebRemote/web-remote-index.html b/Glint/Resources/WebRemote/web-remote-index.html index e93e4eb4..d54f1685 100644 --- a/Glint/Resources/WebRemote/web-remote-index.html +++ b/Glint/Resources/WebRemote/web-remote-index.html @@ -59,12 +59,37 @@

项目与终端

aria-label="关闭项目与终端" data-i18n-aria="close_sidebar">
+
›_

从左侧选择一个终端

画面和输入会在浏览器与这台 Mac 上的 Glint 会话之间实时同步。

+
diff --git a/Glint/Resources/WebRemote/web-remote.css b/Glint/Resources/WebRemote/web-remote.css index 97d3f34c..ee873e63 100644 --- a/Glint/Resources/WebRemote/web-remote.css +++ b/Glint/Resources/WebRemote/web-remote.css @@ -293,6 +293,21 @@ input:focus { grid-template-rows: minmax(0, 1fr) auto; background: var(--terminal-bg); } +.terminal-files-button { + position: absolute; + z-index: 3; + top: 11px; + right: 12px; + width: 30px; + height: 30px; + border: 1px solid var(--line); + border-radius: 8px; + color: var(--muted); + background: var(--panel-2); + font-size: 16px; +} +.terminal-files-button:hover { color: var(--accent); } +.terminal-files-button[hidden], .terminal-panel.show-files .terminal-files-button { display: none; } #terminal { min-width: 0; @@ -304,6 +319,125 @@ input:focus { touch-action: manipulation; } #terminal.visible { display: block; } +.terminal-panel.show-files #terminal, +.terminal-panel.show-files .empty-state, +.terminal-panel.show-files .quick-keys { display: none; } +.files-panel { + position: absolute; + inset: 0; + z-index: 2; + display: flex; + min-height: 0; + flex-direction: column; + background: var(--panel-2); +} +.files-panel[hidden] { display: none; } +.files-toolbar { + display: flex; + align-items: center; + gap: 8px; + padding: 10px; + border-bottom: 1px solid var(--line); +} +.files-toolbar .icon-button:disabled { opacity: .35; cursor: default; } +.files-location { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--muted); + font: 11px ui-monospace, monospace; +} +.files-body { + display: grid; + grid-template-columns: minmax(180px, 32%) minmax(0, 1fr); + min-height: 0; + flex: 1; +} +.files-list { + min-height: 0; + overflow: auto; + padding: 8px; + border-right: 1px solid var(--line); +} +.file-entry { + display: block; + width: 100%; + overflow: hidden; + border: 0; + border-radius: 6px; + padding: 7px; + color: var(--text); + background: transparent; + font: 11px ui-monospace, monospace; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; +} +.file-entry:hover:not(:disabled) { background: var(--overlay); } +.file-entry:disabled { color: var(--faint); cursor: default; } +.files-list-note { padding: 0 7px; color: var(--faint); font-size: 10px; } +.files-preview { display: flex; min-width: 0; min-height: 0; flex-direction: column; } +.files-preview-header { + display: flex; + align-items: center; + gap: 8px; + min-height: 36px; + border-bottom: 1px solid var(--line); +} +.files-preview-name { + flex: 1; + min-width: 0; + overflow: hidden; + padding: 10px 14px; + color: var(--muted); + font: 11px ui-monospace, monospace; + text-overflow: ellipsis; + white-space: nowrap; +} +.files-preview-header .ghost-button { margin-right: 10px; font-size: 10px; } +#files-content { + min-height: 0; + overflow: auto; + flex: 1; + margin: 0; + padding: 14px; + color: var(--text); + font: 12px/1.5 ui-monospace, "SFMono-Regular", Menlo, monospace; + tab-size: 4; +} +#files-content[hidden], #files-image-viewport[hidden], #files-html[hidden], +#files-source-toggle[hidden] { display: none; } +.files-image-viewport { + position: relative; + min-height: 0; + flex: 1; + overflow: hidden; + touch-action: none; + overscroll-behavior: contain; + background: var(--terminal-bg); +} +#files-image { + position: absolute; + top: 50%; + left: 50%; + max-width: 100%; + max-height: 100%; + transform: translate(-50%, -50%) translate(var(--pan-x, 0px), var(--pan-y, 0px)) scale(var(--zoom, 1)); + transform-origin: center; + user-select: none; + -webkit-user-drag: none; + pointer-events: none; +} +#files-html { + width: 100%; + min-height: 0; + flex: 1; + border: 0; + background: #fff; +} +.files-message { margin: 0; padding: 8px 14px; color: var(--muted); font-size: 11px; } #terminal .xterm { height: 100%; } #terminal .xterm-viewport { scrollbar-color: color-mix(in srgb, var(--text) 16%, transparent) transparent; } @@ -446,6 +580,7 @@ dialog::backdrop { background: var(--backdrop); backdrop-filter: blur(8px); } .workspace-pane-list { overflow: visible; } #workspace-list { white-space: normal; overflow: auto; } .terminal-panel { height: 100%; } + .files-body { grid-template-columns: minmax(110px, 40%) minmax(0, 1fr); } #terminal { padding-top: 7px; } } diff --git a/Glint/Resources/WebRemote/web-remote.js b/Glint/Resources/WebRemote/web-remote.js index 48ae93f8..6e7fb5a6 100644 --- a/Glint/Resources/WebRemote/web-remote.js +++ b/Glint/Resources/WebRemote/web-remote.js @@ -76,6 +76,25 @@ const translations = { disconnected: "连接已断开", enter_access_key: "输入访问密钥", existing_directory_hint: "目录必须已经存在;不会自动 git init 或 clone。", + files: "文件", + open_current_folder: "查看当前目录文件", + files_up: "上一级", + files_refresh: "刷新文件", + files_close: "返回终端", + files_empty: "此目录没有文件", + files_choose: "选择文件以预览", + files_loading: "正在读取…", + html_preview: "页面预览", + html_source: "源码", + html_preview_notice: "静态预览:脚本和外部资源不会加载", + image_too_large: "图片过大,无法生成预览", + image_unavailable: "无法解码此图片", + files_limited: "仅显示前 200 项", + files_unavailable: "当前终端没有可访问的本地目录", + invalid_file_path: "文件路径无效或超出项目目录", + file_unavailable: "文件或目录无法读取", + file_too_large: "文件超过 128 KB,无法预览", + file_not_text: "仅支持预览 UTF-8 文本文件", invalid_project_path: "目录不存在,或不是这台 Mac 上可访问的文件夹。", loading_terminal: "正在载入终端", last_terminal: "每个 Workspace 至少需要保留一个终端", @@ -127,6 +146,25 @@ const translations = { disconnected: "Disconnected", enter_access_key: "Enter access key", existing_directory_hint: "The directory must already exist; Glint will not run git init or clone.", + files: "Files", + open_current_folder: "Browse the current terminal folder", + files_up: "Parent folder", + files_refresh: "Refresh files", + files_close: "Back to terminal", + files_empty: "This folder is empty", + files_choose: "Select a file to preview", + files_loading: "Loading…", + html_preview: "Preview", + html_source: "Source", + html_preview_notice: "Static preview: scripts and external resources are disabled", + image_too_large: "This image is too large to preview", + image_unavailable: "Unable to decode this image", + files_limited: "Showing the first 200 items", + files_unavailable: "This terminal has no accessible local directory", + invalid_file_path: "File path is invalid or outside the workspace", + file_unavailable: "File or folder cannot be read", + file_too_large: "Files over 128 KB cannot be previewed", + file_not_text: "Only UTF-8 text files can be previewed", invalid_project_path: "The directory does not exist or is not accessible on this Mac.", loading_terminal: "Loading terminal", last_terminal: "Each workspace must keep at least one terminal", @@ -194,6 +232,20 @@ const elements = { createMessage: document.querySelector("#create-message"), createProject: document.querySelector("#create-project"), emptyState: document.querySelector("#empty-state"), + filesPanel: document.querySelector("#files-panel"), + filesUp: document.querySelector("#files-up"), + filesLocation: document.querySelector("#files-location"), + filesRefresh: document.querySelector("#files-refresh"), + filesClose: document.querySelector("#files-close"), + filesList: document.querySelector("#files-list"), + filesPreviewName: document.querySelector("#files-preview-name"), + filesContent: document.querySelector("#files-content"), + filesImage: document.querySelector("#files-image"), + filesImageViewport: document.querySelector("#files-image-viewport"), + filesHTML: document.querySelector("#files-html"), + filesSourceToggle: document.querySelector("#files-source-toggle"), + filesMessage: document.querySelector("#files-message"), + openFiles: document.querySelector("#open-files"), projectPath: document.querySelector("#project-path"), reconnect: document.querySelector("#reconnect"), refresh: document.querySelector("#refresh"), @@ -288,6 +340,13 @@ let resizeTimer; let lastSentTerminalSize = ""; let appliedBrandSignature = ""; let appliedThemeSignature = ""; +let fileWorkspace = ""; +let filePane = ""; +let fileRoot = ""; +let fileDirectory = ""; +let filePath = ""; +let htmlSource = ""; +let htmlSourceShown = false; const mobileSidebarLayout = matchMedia( "(max-width: 760px), (max-width: 900px) and (max-height: 520px) and (orientation: landscape)" ); @@ -323,6 +382,7 @@ function connect() { lastServerMessageAt = Date.now(); authenticated = false; controllingPane = ""; + updateFilesButton(); resetSession(); setStatus("connecting", t("connecting")); socket = new WebSocket(websocketURL()); @@ -349,6 +409,7 @@ function connect() { if (socket !== currentSocket) return; authenticated = false; controllingPane = ""; + updateFilesButton(); setStatus("error", t("disconnected")); reconnectTimer = setTimeout(connect, reconnectDelay); reconnectDelay = Math.min(reconnectDelay * 1.8, 8000); @@ -468,6 +529,7 @@ function handleMessage(raw) { break; case "authenticated": authenticated = true; + updateFilesButton(); setStatus("connected", t("connected")); if (elements.authDialog.open) elements.authDialog.close(); elements.authError.textContent = ""; @@ -478,6 +540,55 @@ function handleMessage(raw) { applyTheme(message.theme); renderState(message); chooseInitialPane(message); + updateFilesButton(); + if (filePane && !message.workspaces.some(item => + item.id === fileWorkspace && item.panes?.some(pane => pane.id === filePane))) { + closeFiles(); + } + break; + case "fileList": + if (message.workspace === fileWorkspace && message.pane === filePane && message.path === fileDirectory) { + fileRoot = message.root || ""; + updateFileLocation(); + renderFiles(message.entries || [], message.limit); + } + break; + case "fileContent": + if (message.workspace === fileWorkspace && message.pane === filePane && message.path === filePath) { + if (message.format === "html") { + htmlSource = message.content; + htmlSourceShown = false; + elements.filesHTML.srcdoc = sandboxedHTML(message.content); + elements.filesHTML.hidden = false; + elements.filesContent.hidden = true; + elements.filesSourceToggle.hidden = false; + elements.filesSourceToggle.textContent = t("html_source"); + elements.filesMessage.textContent = t("html_preview_notice"); + } else { + elements.filesContent.textContent = message.content; + elements.filesMessage.textContent = ""; + } + } + break; + case "fileImage": + if (message.workspace === fileWorkspace && message.pane === filePane && message.path === filePath) { + elements.filesImage.src = `data:image/png;base64,${message.data}`; + elements.filesImage.alt = elements.filesPreviewName.textContent; + imageZoom.reset(); + elements.filesImageViewport.hidden = false; + elements.filesContent.hidden = true; + elements.filesMessage.textContent = ""; + } + break; + case "fileError": + if (message.workspace !== fileWorkspace || message.pane !== filePane) break; + if (message.path === filePath && filePath) { + elements.filesContent.textContent = ""; + elements.filesMessage.textContent = fileErrorLabel(message.code); + } else if (message.path === fileDirectory) { + elements.filesList.replaceChildren(); + elements.filesMessage.textContent = fileErrorLabel(message.code); + } break; case "snapshot": if (message.pane !== selectedPane) return; @@ -500,6 +611,7 @@ function handleMessage(raw) { elements.createDialog.close(); selectedPane = ""; controllingPane = ""; + updateFilesButton(); sessionStorage.removeItem("glint-selected-pane"); setSidebarOpen(false); setStatus("connected", t("project_opened")); @@ -520,6 +632,7 @@ function handleMessage(raw) { } case "terminalClosed": if (message.pane === selectedPane) { + if (filePane === selectedPane) closeFiles(); selectedPane = ""; controllingPane = ""; clearTimeout(paneRetryTimer); @@ -529,6 +642,7 @@ function handleMessage(raw) { elements.terminal.classList.remove("visible"); elements.emptyState.classList.remove("hidden"); elements.activeLabel.textContent = t("select_terminal"); + updateFilesButton(); } setStatus("connected", t("terminal_closed")); break; @@ -618,6 +732,7 @@ function applyTheme(theme) { function handleError(code) { if (code === "unauthorized") { authenticated = false; + updateFilesButton(); token = ""; encrypted = false; // proof failed; we're still in the handshake sessionStorage.removeItem("glint-session-token"); @@ -656,6 +771,240 @@ function errorLabel(code) { return labels[code] || t("operation_failed", { code }); } +function fileErrorLabel(code) { + return t({ + "files-unavailable": "files_unavailable", + "invalid-file-path": "invalid_file_path", + "file-unavailable": "file_unavailable", + "file-too-large": "file_too_large", + "file-not-text": "file_not_text", + "image-too-large": "image_too_large", + "image-unavailable": "image_unavailable", + }[code] || "file_unavailable"); +} + +function sandboxedHTML(html) { + const policy = ``; + const doctype = /^\s*]*>/i.exec(html); + return doctype + ? `${doctype[0]}${policy}${html.slice(doctype[0].length)}` + : `${policy}${html}`; +} + +function installImageZoom(viewport, image) { + let scale = 1; + let offsetX = 0; + let offsetY = 0; + let gesture = null; + let safariGestureScale = 1; + const maxScale = 6; + const clamp = (value, lower, upper) => Math.min(upper, Math.max(lower, value)); + const center = touches => ({ + x: (touches[0].clientX + touches[1].clientX) / 2, + y: (touches[0].clientY + touches[1].clientY) / 2, + }); + const distance = touches => Math.hypot( + touches[0].clientX - touches[1].clientX, + touches[0].clientY - touches[1].clientY + ); + + function apply() { + const maxX = Math.max(0, (image.offsetWidth * scale - viewport.clientWidth) / 2); + const maxY = Math.max(0, (image.offsetHeight * scale - viewport.clientHeight) / 2); + offsetX = clamp(offsetX, -maxX, maxX); + offsetY = clamp(offsetY, -maxY, maxY); + image.style.setProperty("--zoom", scale); + image.style.setProperty("--pan-x", `${offsetX}px`); + image.style.setProperty("--pan-y", `${offsetY}px`); + } + + function pointFromClient(clientX, clientY) { + const rect = viewport.getBoundingClientRect(); + return { x: clientX - rect.left - rect.width / 2, + y: clientY - rect.top - rect.height / 2 }; + } + + function zoomAt(clientX, clientY, nextScale) { + const point = pointFromClient(clientX, clientY); + const ratio = nextScale / scale; + offsetX = point.x - (point.x - offsetX) * ratio; + offsetY = point.y - (point.y - offsetY) * ratio; + scale = nextScale; + apply(); + } + + function startGesture(touches) { + if (touches.length >= 2) { + const middle = center(touches); + const point = pointFromClient(middle.x, middle.y); + gesture = { + kind: "pinch", distance: Math.max(1, distance(touches)), startScale: scale, + imageX: (point.x - offsetX) / scale, + imageY: (point.y - offsetY) / scale, + }; + } else if (touches.length === 1 && scale > 1) { + gesture = { + kind: "pan", x: touches[0].clientX, y: touches[0].clientY, + offsetX, offsetY, + }; + } else { + gesture = null; + } + } + + viewport.addEventListener("touchstart", event => { + startGesture(event.touches); + if (gesture) event.preventDefault(); + }, { passive: false }); + viewport.addEventListener("touchmove", event => { + if (event.touches.length >= 2) { + if (gesture?.kind !== "pinch") startGesture(event.touches); + const middle = center(event.touches); + const point = pointFromClient(middle.x, middle.y); + scale = clamp(gesture.startScale * distance(event.touches) / gesture.distance, 1, maxScale); + offsetX = point.x - gesture.imageX * scale; + offsetY = point.y - gesture.imageY * scale; + apply(); + event.preventDefault(); + } else if (event.touches.length === 1 && gesture?.kind === "pan") { + offsetX = gesture.offsetX + event.touches[0].clientX - gesture.x; + offsetY = gesture.offsetY + event.touches[0].clientY - gesture.y; + apply(); + event.preventDefault(); + } + }, { passive: false }); + const finishTouch = event => startGesture(event.touches); + viewport.addEventListener("touchend", finishTouch); + viewport.addEventListener("touchcancel", finishTouch); + viewport.addEventListener("dblclick", () => reset()); + viewport.addEventListener("wheel", event => { + if (!event.ctrlKey) return; + event.preventDefault(); + zoomAt(event.clientX, event.clientY, + clamp(scale * Math.exp(-event.deltaY * 0.01), 1, maxScale)); + }, { passive: false }); + viewport.addEventListener("gesturestart", event => { + event.preventDefault(); + safariGestureScale = scale; + }, { passive: false }); + viewport.addEventListener("gesturechange", event => { + event.preventDefault(); + if (gesture?.kind === "pinch") return; + const rect = viewport.getBoundingClientRect(); + zoomAt(event.clientX || rect.left + rect.width / 2, + event.clientY || rect.top + rect.height / 2, + clamp(safariGestureScale * event.scale, 1, maxScale)); + }, { passive: false }); + window.addEventListener("resize", apply); + + function reset() { + scale = 1; + offsetX = 0; + offsetY = 0; + gesture = null; + apply(); + } + return { reset }; +} + +const imageZoom = installImageZoom(elements.filesImageViewport, elements.filesImage); + +function clearFilePreview() { + htmlSource = ""; + htmlSourceShown = false; + elements.filesContent.textContent = ""; + elements.filesContent.hidden = false; + elements.filesImageViewport.hidden = true; + elements.filesImage.removeAttribute("src"); + imageZoom.reset(); + elements.filesHTML.hidden = true; + elements.filesHTML.srcdoc = ""; + elements.filesSourceToggle.hidden = true; +} + +function updateFilesButton() { + elements.openFiles.hidden = !authenticated || !selectedPane; +} + +function updateFileLocation() { + elements.filesLocation.textContent = fileRoot + ? `${fileRoot}${fileDirectory ? `/${fileDirectory}` : ""}` + : (fileDirectory ? `/${fileDirectory}` : "/"); +} + +function openFiles() { + if (!authenticated || !selectedPane) return; + const workspace = lastState?.workspaces.find(item => + item.panes?.some(pane => pane.id === selectedPane)); + if (!workspace) return; + fileWorkspace = workspace.id; + filePane = selectedPane; + fileRoot = workspace.panes.find(pane => pane.id === selectedPane)?.cwd || ""; + fileDirectory = ""; + filePath = ""; + elements.filesPanel.hidden = false; + document.querySelector(".terminal-panel").classList.add("show-files"); + elements.filesPreviewName.textContent = ""; + clearFilePreview(); + elements.filesMessage.textContent = t("files_choose"); + browseFiles(""); + if (mobileSidebarLayout.matches) setSidebarOpen(false); +} + +function closeFiles() { + fileWorkspace = ""; + filePane = ""; + fileRoot = ""; + filePath = ""; + clearFilePreview(); + elements.filesPanel.hidden = true; + document.querySelector(".terminal-panel").classList.remove("show-files"); + fitTerminal(); +} + +function browseFiles(path) { + fileDirectory = path; + filePath = ""; + updateFileLocation(); + elements.filesUp.disabled = !path; + elements.filesList.replaceChildren(); + elements.filesPreviewName.textContent = ""; + clearFilePreview(); + elements.filesMessage.textContent = t("files_loading"); + send({ type: "listFiles", workspace: fileWorkspace, pane: filePane, path }); +} + +function renderFiles(entries, limit) { + elements.filesList.replaceChildren(); + for (const entry of entries) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "file-entry"; + button.disabled = entry.kind === "link"; + button.textContent = `${entry.kind === "directory" ? "▸" : entry.kind === "link" ? "↗" : "▤"} ${entry.name}`; + button.addEventListener("click", () => { + const path = fileDirectory ? `${fileDirectory}/${entry.name}` : entry.name; + if (entry.kind === "directory") { + browseFiles(path); + } else { + filePath = path; + elements.filesPreviewName.textContent = entry.name; + clearFilePreview(); + elements.filesMessage.textContent = t("files_loading"); + send({ type: "readFile", workspace: fileWorkspace, pane: filePane, path }); + } + }); + elements.filesList.append(button); + } + elements.filesMessage.textContent = entries.length ? t("files_choose") : t("files_empty"); + if (entries.length >= limit) { + const note = document.createElement("p"); + note.className = "files-list-note"; + note.textContent = t("files_limited"); + elements.filesList.append(note); + } +} + function chooseInitialPane(state) { const panes = state.workspaces.flatMap(workspace => workspace.panes || []); const remembered = panes.find(pane => pane.id === selectedPane); @@ -754,7 +1103,9 @@ function renderState(state) { } function selectPane(pane) { + if (filePane) closeFiles(); selectedPane = pane; + updateFilesButton(); controllingPane = ""; clearTimeout(paneRetryTimer); paneRetryCount = 0; @@ -828,6 +1179,23 @@ elements.authForm.addEventListener("submit", event => { elements.reconnect.addEventListener("click", connect); elements.refresh.addEventListener("click", () => send({ type: "list" })); +elements.openFiles.addEventListener("click", openFiles); +elements.filesUp.addEventListener("click", () => { + browseFiles(fileDirectory.split("/").slice(0, -1).join("/")); +}); +elements.filesRefresh.addEventListener("click", () => browseFiles(fileDirectory)); +elements.filesClose.addEventListener("click", closeFiles); +elements.filesSourceToggle.addEventListener("click", () => { + htmlSourceShown = !htmlSourceShown; + elements.filesContent.textContent = htmlSource; + elements.filesContent.hidden = !htmlSourceShown; + elements.filesHTML.hidden = htmlSourceShown; + elements.filesSourceToggle.textContent = t(htmlSourceShown ? "html_preview" : "html_source"); + elements.filesMessage.textContent = htmlSourceShown ? "" : t("html_preview_notice"); +}); +elements.filesHTML.title = t("html_preview"); +[ ["filesUp", "files_up"], ["filesRefresh", "files_refresh"], ["filesClose", "files_close"] ] + .forEach(([element, label]) => elements[element].setAttribute("aria-label", t(label))); elements.autoFocusTerminal.addEventListener("change", () => { autoFocusTerminal = elements.autoFocusTerminal.checked; localStorage.setItem(autoFocusStorageKey, String(autoFocusTerminal)); diff --git a/Glint/WebRemote/WebRemoteProtocol.swift b/Glint/WebRemote/WebRemoteProtocol.swift index 3431b59f..772f1e72 100644 --- a/Glint/WebRemote/WebRemoteProtocol.swift +++ b/Glint/WebRemote/WebRemoteProtocol.swift @@ -1,6 +1,7 @@ import CryptoKit import Darwin import Foundation +import ImageIO import Security import os @@ -157,6 +158,133 @@ enum WebRemoteProjectPath { } } +/// Read-only, workspace-scoped file access for the browser. Paths on the wire +/// are relative to the workspace root; links are never followed. +enum WebRemoteFiles { + static let maxPreviewBytes = 128 * 1024 + static let maxImageBytes = 40 * 1024 * 1024 + static let maxImagePreviewBytes = 5 * 1024 * 1024 + static let maxEntries = 200 + private static let imageExtensions: Set = [ + "png", "jpg", "jpeg", "gif", "webp", "heic", "heif", "tif", "tiff", "bmp", "ico", "avif" + ] + + static func isImage(_ path: String) -> Bool { + imageExtensions.contains((path as NSString).pathExtension.lowercased()) + } + + static func isHTML(_ path: String) -> Bool { + ["html", "htm", "xhtml"].contains((path as NSString).pathExtension.lowercased()) + } + + static func resolve(root: String, relativePath: String) -> URL? { + guard relativePath.utf8.count <= 4096 else { return nil } + let components = relativePath.isEmpty ? [] : relativePath.split(separator: "/", omittingEmptySubsequences: false) + guard components.allSatisfy({ !$0.isEmpty && $0 != "." && $0 != ".." && !$0.contains("\0") }) else { + return nil + } + let rootURL = URL(fileURLWithPath: root, isDirectory: true).resolvingSymlinksInPath() + var target = rootURL + for component in components { + target.appendPathComponent(String(component)) + guard let values = try? target.resourceValues(forKeys: [.isSymbolicLinkKey]), + values.isSymbolicLink != true else { return nil } + } + let resolved = target.resolvingSymlinksInPath().standardizedFileURL.path + let base = rootURL.standardizedFileURL.path + guard resolved == base || resolved.hasPrefix(base == "/" ? "/" : base + "/") else { return nil } + return target + } + + static func list(root: String, path: String) -> Result<[[String: Any]], ErrorCode> { + guard let url = resolve(root: root, relativePath: path) else { return .failure(.invalidPath) } + guard (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true else { + return .failure(.unavailable) + } + do { + let children = try FileManager.default.contentsOfDirectory( + at: url, includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey], + options: [] + ) + let sorted = children.sorted { + let left = (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true + let right = (try? $1.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true + return left == right + ? $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent) == .orderedAscending + : left + } + return .success(sorted.prefix(maxEntries).map { child in + let values = try? child.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) + return [ + "name": child.lastPathComponent, + "kind": values?.isSymbolicLink == true ? "link" + : (values?.isDirectory == true ? "directory" + : (isImage(child.lastPathComponent) ? "image" : "file")), + ] + }) + } catch { return .failure(.unavailable) } + } + + static func read(root: String, path: String) -> Result { + guard !path.isEmpty, let url = resolve(root: root, relativePath: path) else { + return .failure(.invalidPath) + } + guard let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]), + values.isRegularFile == true else { return .failure(.unavailable) } + guard let size = values.fileSize, size <= maxPreviewBytes else { return .failure(.tooLarge) } + guard let data = try? Data(contentsOf: url), data.count <= maxPreviewBytes else { + return .failure(.unavailable) + } + guard !data.contains(where: { $0 < 32 && $0 != 9 && $0 != 10 && $0 != 13 }), + let text = String(data: data, encoding: .utf8) else { + return .failure(.notText) + } + return .success(text) + } + + /// ImageIO decodes only the first frame and bounds the image sent over the + /// encrypted socket. The browser receives PNG pixels, never source bytes. + static func readImage(root: String, path: String) -> Result { + guard !path.isEmpty, isImage(path), + let url = resolve(root: root, relativePath: path) else { + return .failure(.invalidPath) + } + guard let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]), + values.isRegularFile == true else { return .failure(.unavailable) } + guard let size = values.fileSize, size <= maxImageBytes else { return .failure(.imageTooLarge) } + + let sourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary + guard let source = CGImageSourceCreateWithURL(url as CFURL, sourceOptions), + CGImageSourceGetCount(source) > 0 else { return .failure(.imageUnavailable) } + let thumbnailOptions = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: 1024, + ] as CFDictionary + guard let thumbnail = CGImageSourceCreateThumbnailAtIndex(source, 0, thumbnailOptions) else { + return .failure(.imageUnavailable) + } + let output = NSMutableData() + guard let destination = CGImageDestinationCreateWithData( + output, "public.png" as CFString, 1, nil + ) else { return .failure(.imageUnavailable) } + CGImageDestinationAddImage(destination, thumbnail, nil) + guard CGImageDestinationFinalize(destination) else { return .failure(.imageUnavailable) } + let data = output as Data + guard data.count <= maxImagePreviewBytes else { return .failure(.imageTooLarge) } + return .success(data) + } + + enum ErrorCode: String, Error { + case invalidPath = "invalid-file-path" + case unavailable = "file-unavailable" + case tooLarge = "file-too-large" + case notText = "file-not-text" + case imageTooLarge = "image-too-large" + case imageUnavailable = "image-unavailable" + } +} + struct WebRemoteAccessToken { static func generate() -> String { var bytes = [UInt8](repeating: 0, count: 32) diff --git a/Glint/WebRemote/WebRemoteServer.swift b/Glint/WebRemote/WebRemoteServer.swift index d0300cb9..7e28b9fd 100644 --- a/Glint/WebRemote/WebRemoteServer.swift +++ b/Glint/WebRemote/WebRemoteServer.swift @@ -867,6 +867,19 @@ final class WebRemoteServer: @unchecked Sendable { confirmed: object["confirmed"] as? Bool ?? false, clientID: clientID ) + case "listFiles", "readFile": + guard let value = object["workspace"] as? String, + value.count <= 36, + let workspaceID = UUID(uuidString: value), + let pane = object["pane"] as? String, + pane.count <= 128, + let path = object["path"] as? String, + path.utf8.count <= 4096 else { + sendError("bad-request", to: clientID) + return + } + accessFiles(workspace: workspaceID, pane: pane, path: path, + read: type == "readFile", clientID: clientID) default: sendError("unknown-command", to: clientID) } @@ -1037,6 +1050,63 @@ final class WebRemoteServer: @unchecked Sendable { } } + private func accessFiles(workspace: UUID, pane: String, path: String, + read: Bool, clientID: UUID) { + DispatchQueue.main.async { [weak self] in + guard let self, let store = WorkspaceStore.current else { return } + guard let root = store.webRemoteFileRoot(workspace: workspace, pane: pane) else { + self.queue.async { [weak self] in + self?.sendJSON(["type": "fileError", "workspace": workspace.uuidString, + "pane": pane, "path": path, + "code": "files-unavailable"], to: clientID) + } + return + } + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + let response: [String: Any] + if read { + if WebRemoteFiles.isImage(path) { + switch WebRemoteFiles.readImage(root: root, path: path) { + case let .success(png): + response = ["type": "fileImage", "workspace": workspace.uuidString, + "pane": pane, + "path": path, "data": png.base64EncodedString()] + case let .failure(error): + response = ["type": "fileError", "workspace": workspace.uuidString, + "pane": pane, + "path": path, "code": error.rawValue] + } + } else { + switch WebRemoteFiles.read(root: root, path: path) { + case let .success(content): + response = ["type": "fileContent", "workspace": workspace.uuidString, + "pane": pane, + "path": path, "content": content, + "format": WebRemoteFiles.isHTML(path) ? "html" : "text"] + case let .failure(error): + response = ["type": "fileError", "workspace": workspace.uuidString, + "pane": pane, + "path": path, "code": error.rawValue] + } + } + } else { + switch WebRemoteFiles.list(root: root, path: path) { + case let .success(entries): + response = ["type": "fileList", "workspace": workspace.uuidString, + "pane": pane, "root": root, + "path": path, "entries": entries, + "limit": WebRemoteFiles.maxEntries] + case let .failure(error): + response = ["type": "fileError", "workspace": workspace.uuidString, + "pane": pane, + "path": path, "code": error.rawValue] + } + } + self?.queue.async { [weak self] in self?.sendJSON(response, to: clientID) } + } + } + } + private func createTerminal(workspace workspaceID: UUID, clientID: UUID) { DispatchQueue.main.async { [weak self] in guard let self, let store = WorkspaceStore.current else { return } diff --git a/Glint/Workspace/WorkspaceStore.swift b/Glint/Workspace/WorkspaceStore.swift index 9caa338f..af6ea18a 100644 --- a/Glint/Workspace/WorkspaceStore.swift +++ b/Glint/Workspace/WorkspaceStore.swift @@ -2914,6 +2914,19 @@ final class WorkspaceStore: ObservableObject { } } + /// Resolve the selected terminal's current local cwd, not its workspace + /// anchor. SSH panes report a local launch cwd as well, so exclude them. + func webRemoteFileRoot(workspace id: UUID, pane: String) -> String? { + guard let key = Self.parsePaneKey(pane), key.workspace == id, + let workspace = workspaces.first(where: { $0.id == id }), + !workspace.source.isRemote, + let model = workspace.panes[key.pane], model.remoteTarget == nil, + workspace.tabs.contains(where: { $0.root.leaves.contains(key.pane) }), + surfaceViews[key]?.remoteReviewContext == nil, + let cwd = surfaceViews[key]?.currentCwd() ?? model.workingDirectory else { return nil } + return WebRemoteProjectPath.resolveExistingDirectory(cwd) + } + func webRemoteThemePayload() -> [String: Any] { let theme = Theme.current let accentColor = accent diff --git a/GlintTests/WebRemoteProtocolTests.swift b/GlintTests/WebRemoteProtocolTests.swift index 60953641..dce60801 100644 --- a/GlintTests/WebRemoteProtocolTests.swift +++ b/GlintTests/WebRemoteProtocolTests.swift @@ -1,3 +1,4 @@ +import AppKit import CryptoKit import XCTest @testable import Glint @@ -490,6 +491,92 @@ final class WebRemoteProtocolTests: XCTestCase { XCTAssertNil(WebRemoteProjectPath.resolveExistingDirectory("relative/path")) } + func testWebRemoteFilesListsAndReadsOnlyWorkspaceText() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let folder = root.appendingPathComponent("src") + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + try Data("hello\n".utf8).write(to: folder.appendingPathComponent("main.swift")) + let entries = try WebRemoteFiles.list(root: root.path, path: "src").get() + XCTAssertEqual(entries.first?["name"] as? String, "main.swift") + XCTAssertEqual(entries.first?["kind"] as? String, "file") + XCTAssertEqual(try WebRemoteFiles.read(root: root.path, path: "src/main.swift").get(), "hello\n") + XCTAssertNil(WebRemoteFiles.resolve(root: root.path, relativePath: "../outside")) + XCTAssertNil(WebRemoteFiles.resolve(root: root.path, relativePath: "/etc/passwd")) + XCTAssertNil(WebRemoteFiles.resolve(root: root.path, relativePath: "src/../main.swift")) + } + + func testWebRemoteFilesRejectLinksBinaryAndOversize() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createSymbolicLink( + at: root.appendingPathComponent("outside"), withDestinationURL: URL(fileURLWithPath: "/etc") + ) + XCTAssertNil(WebRemoteFiles.resolve(root: root.path, relativePath: "outside/passwd")) + try Data([0, 1, 2]).write(to: root.appendingPathComponent("binary")) + try Data(repeating: 65, count: WebRemoteFiles.maxPreviewBytes + 1) + .write(to: root.appendingPathComponent("large")) + if case .failure(.notText) = WebRemoteFiles.read(root: root.path, path: "binary") { + } else { XCTFail("Binary content must be rejected") } + if case .failure(.tooLarge) = WebRemoteFiles.read(root: root.path, path: "large") { + } else { XCTFail("Large content must be rejected") } + } + + func testWebRemoteImagePreviewUsesWorkspacePathAndReturnsPNG() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let bitmap = try XCTUnwrap(NSBitmapImageRep( + bitmapDataPlanes: nil, pixelsWide: 8, pixelsHigh: 8, + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, + isPlanar: false, colorSpaceName: .deviceRGB, + bytesPerRow: 0, bitsPerPixel: 0 + )) + bitmap.setColor(.red, atX: 0, y: 0) + let source = try XCTUnwrap(bitmap.representation(using: .png, properties: [:])) + try source.write(to: root.appendingPathComponent("photo.PNG")) + + XCTAssertTrue(WebRemoteFiles.isImage("photo.PNG")) + XCTAssertFalse(WebRemoteFiles.isImage("page.html")) + XCTAssertTrue(WebRemoteFiles.isHTML("page.HTM")) + let preview = try WebRemoteFiles.readImage(root: root.path, path: "photo.PNG").get() + XCTAssertEqual(Array(preview.prefix(8)), [137, 80, 78, 71, 13, 10, 26, 10]) + XCTAssertNil(WebRemoteFiles.resolve(root: root.path, relativePath: "../photo.PNG")) + } + + @MainActor + func testWebRemoteFileRootUsesRequestedTerminalCwd() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let project = root.appendingPathComponent("project") + let other = root.appendingPathComponent("other") + try FileManager.default.createDirectory(at: project, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: other, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + var workspace = Workspace.fresh(name: "Files", accentHex: "5E5CE6", symbol: "F") + let first = try XCTUnwrap(workspace.selectedTab?.focusedPane) + workspace.panes[first]?.workingDirectory = project.path + workspace.source.repoRoot = project.path + let second = PaneID(value: 1) + workspace.panes[second] = Pane(id: second, title: "zsh", workingDirectory: other.path) + workspace.tabs.append(WorkspaceTab( + id: TabID(value: 1), name: nil, root: .leaf(second), focusedPane: second + )) + let store = WorkspaceStore(activity: PaneActivityStore()) + store.workspaces = [workspace] + + XCTAssertEqual(store.webRemoteFileRoot( + workspace: workspace.id, pane: "\(workspace.id.uuidString):1" + ), other.path) + XCTAssertNil(store.webRemoteFileRoot(workspace: UUID(), pane: "\(workspace.id.uuidString):1")) + XCTAssertNil(store.webRemoteFileRoot(workspace: workspace.id, pane: "\(workspace.id.uuidString):2")) + store.workspaces[0].panes[second]?.remoteTarget = "example.com" + XCTAssertNil(store.webRemoteFileRoot( + workspace: workspace.id, pane: "\(workspace.id.uuidString):1" + )) + } + @MainActor func testRemoteCloseTargetsRequestedPaneAndRequiresConfirmationForBusyProcess() { let workspaceID = UUID() From 380adc32a48ff7ac8a449249a9387a313e728e8e Mon Sep 17 00:00:00 2001 From: defia Date: Wed, 7 Oct 2026 11:58:04 +0800 Subject: [PATCH 2/2] fix(web-remote): reject SSH file access without remote title --- Glint/Workspace/WorkspaceStore.swift | 4 ++++ GlintTests/WebRemoteProtocolTests.swift | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/Glint/Workspace/WorkspaceStore.swift b/Glint/Workspace/WorkspaceStore.swift index af6ea18a..0bc3007d 100644 --- a/Glint/Workspace/WorkspaceStore.swift +++ b/Glint/Workspace/WorkspaceStore.swift @@ -2924,6 +2924,10 @@ final class WorkspaceStore: ObservableObject { workspace.tabs.contains(where: { $0.root.leaves.contains(key.pane) }), surfaceViews[key]?.remoteReviewContext == nil, let cwd = surfaceViews[key]?.currentCwd() ?? model.workingDirectory else { return nil } + // SSH does not always publish a remote cwd/title (notably one-shot + // commands). Reject its actual PTY process before exposing local cwd. + let process = surfaceViews[key]?.foregroundProcessName() ?? paneProcesses[key] + guard process?.lowercased() != "ssh" else { return nil } return WebRemoteProjectPath.resolveExistingDirectory(cwd) } diff --git a/GlintTests/WebRemoteProtocolTests.swift b/GlintTests/WebRemoteProtocolTests.swift index dce60801..38d12e06 100644 --- a/GlintTests/WebRemoteProtocolTests.swift +++ b/GlintTests/WebRemoteProtocolTests.swift @@ -523,6 +523,26 @@ final class WebRemoteProtocolTests: XCTestCase { } else { XCTFail("Large content must be rejected") } } + @MainActor + func testWebRemoteFileRootRejectsSSHWithoutRemoteTitleMetadata() throws { + let project = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: project, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: project) } + var workspace = Workspace.fresh(name: "SSH", accentHex: "5E5CE6", symbol: "S") + let pane = try XCTUnwrap(workspace.selectedTab?.focusedPane) + workspace.panes[pane]?.workingDirectory = project.path + // Both ssh host and ssh host command may have no remote cwd/title. + XCTAssertNil(workspace.panes[pane]?.remoteTarget) + let store = WorkspaceStore(activity: PaneActivityStore()) + store.workspaces = [workspace] + let key = WorkspaceStore.WorkspacePaneKey(workspace: workspace.id, pane: pane) + let handle = "\(workspace.id.uuidString):\(pane.value)" + store.paneProcesses[key] = "ssh" + XCTAssertNil(store.webRemoteFileRoot(workspace: workspace.id, pane: handle)) + store.paneProcesses[key] = "zsh" + XCTAssertEqual(store.webRemoteFileRoot(workspace: workspace.id, pane: handle), project.path) + } + func testWebRemoteImagePreviewUsesWorkspacePathAndReturnsPNG() throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)