cc-haha/desktop/src/lib/handlePreviewLink.ts
程序员阿江(Relakkes) ebc78befbe fix: avoid static previews for frontend index HTML
Project entry HTML often only mounts a dev-server app, while built output can be served statically. The desktop open-with policy now only offers the in-app browser for static HTML candidates, and preview-fs rewrites built HTML root-relative assets under the workspace preview path.

Constraint: Frontend project index.html needs a dev server instead of preview-fs static serving
Rejected: Treat every .html file as browser-previewable | project entry HTML can render blank or with missing assets
Confidence: high
Scope-risk: moderate
Tested: cd desktop && bun run check:desktop
Tested: bun run check:server
Tested: git diff --check
Not-tested: bun run verify was intentionally stopped at user request
2026-06-01 03:16:52 +08:00

85 lines
3.4 KiB
TypeScript

import { classifyPreviewLink } from './previewLinkRouter'
import { shouldOfferStaticHtmlPreview } from './htmlPreviewPolicy'
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.
if (!isAbsoluteLocalPath(filePath) && !shouldOfferStaticHtmlPreview(filePath)) {
deps.openFilePreview(deps.sessionId, filePath)
return true
}
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
}
}