cc-haha/desktop/src/lib/handlePreviewLink.ts
程序员阿江(Relakkes) 8ddac90c11 feat(server): serve absolute local files via /local-file so file:// links open in the in-app browser
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 17:40:46 +08:00

80 lines
3.1 KiB
TypeScript

import { classifyPreviewLink } from './previewLinkRouter'
export type PreviewLinkDeps = {
sessionId: string
serverBaseUrl: string
openBrowser: (sessionId: string, url: string) => void
openFilePreview: (sessionId: string, path: string) => void
openExternal: (url: string) => void
}
/**
* Build a `/preview-fs/<sessionId>/<path>` URL for the local server.
*
* Absolute file paths (leading slash) are preserved as-is, so the resulting URL
* carries a `//` between `<sessionId>` and the path. That double slash is
* intentional: the server slices everything after the `<sessionId>` segment and
* runs `path.resolve(workDir, relPath)`, so an absolute path is resolved as an
* absolute-within-workspace path and sandbox-checked against the work dir root.
*/
export function previewFsUrl(base: string, sessionId: string, filePath: string): string {
return `${base.replace(/\/$/, '')}/preview-fs/${encodeURIComponent(sessionId)}/${filePath.replace(/^\/+/, '/')}`
}
/** True for POSIX absolute (`/...`) or Windows drive (`X:\` / `X:/`) paths. */
export function isAbsoluteLocalPath(p: string): boolean {
return p.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(p)
}
/**
* Build a `/local-file/<absolute-path>` URL for the local server so an absolute
* file outside the session workspace can open in the in-app browser.
*
* The path is appended PATH-style (not as a query param) so relative asset URLs
* inside served HTML resolve against the same directory. Each path segment is
* `encodeURIComponent`-escaped (so spaces / unicode survive) while the `/`
* separators are preserved. A Windows drive path (`C:\proj\page.html`) has its
* backslashes normalized to `/`; the leading separator is always present so the
* server can re-root the path.
*/
export function localFileUrl(base: string, absPath: string): string {
const withForwardSlashes = absPath.replace(/\\/g, '/')
const withLeadingSlash = withForwardSlashes.startsWith('/')
? withForwardSlashes
: `/${withForwardSlashes}`
const encoded = withLeadingSlash
.split('/')
.map((segment) => encodeURIComponent(segment))
.join('/')
return `${base.replace(/\/$/, '')}/local-file${encoded}`
}
/** Returns true if handled (caller should preventDefault). */
export function handlePreviewLink(href: string, deps: PreviewLinkDeps): boolean {
const cls = classifyPreviewLink(href)
switch (cls.kind) {
case 'browser-localhost':
deps.openBrowser(deps.sessionId, cls.url!)
return true
case 'browser-file': {
const filePath = cls.path!
// Absolute paths (incl. file:// → absolute) may live OUTSIDE the session
// workspace, so serve them via the $HOME-sandboxed /local-file route.
// Relative paths stay workspace-scoped via /preview-fs.
const url = isAbsoluteLocalPath(filePath)
? localFileUrl(deps.serverBaseUrl, filePath)
: previewFsUrl(deps.serverBaseUrl, deps.sessionId, filePath)
deps.openBrowser(deps.sessionId, url)
return true
}
case 'file-preview':
deps.openFilePreview(deps.sessionId, cls.path!)
return true
case 'remote':
deps.openExternal(cls.url!)
return true
default:
return false
}
}