返回 DeepSeek-Reasonix
protocol.ts
根目录 / desktop / electron / src / main / protocol.ts
1 import { createReadStream, statSync } from "node:fs";
2 import { extname, join, resolve, sep } from "node:path";
3 import { Readable } from "node:stream";
4 import { errorText, type Logger } from "./log.js";
5
6 export const APP_SCHEME = "reasonix";
7 export const APP_ORIGIN = "reasonix://app";
8 export const APP_INDEX_URL = "reasonix://app/index.html";
9
10 // Must match desktop/workspace_media.go, desktop/theme_assets.go and
11 // desktop/remote_markdown_image.go; only these reach the resource origin.
12 export const FORWARDED_PATHS = [
13 "/__reasonix_workspace_media/",
14 "/__reasonix_theme_asset/",
15 "/__reasonix_remote_markdown_image",
16 ] as const;
17
18 export type AppRoute =
19 | { kind: "file"; path: string; mime: string }
20 | { kind: "forward"; target: string }
21 | { kind: "notFound"; reason: string };
22
23 const MIME: Record<string, string> = {
24 ".html": "text/html; charset=utf-8",
25 ".js": "text/javascript; charset=utf-8",
26 ".mjs": "text/javascript; charset=utf-8",
27 ".css": "text/css; charset=utf-8",
28 ".json": "application/json; charset=utf-8",
29 ".map": "application/json; charset=utf-8",
30 ".webmanifest": "application/manifest+json",
31 ".svg": "image/svg+xml",
32 ".png": "image/png",
33 ".jpg": "image/jpeg",
34 ".jpeg": "image/jpeg",
35 ".gif": "image/gif",
36 ".webp": "image/webp",
37 ".ico": "image/x-icon",
38 ".woff": "font/woff",
39 ".woff2": "font/woff2",
40 ".ttf": "font/ttf",
41 ".txt": "text/plain; charset=utf-8",
42 ".wasm": "application/wasm",
43 ".mp3": "audio/mpeg",
44 ".ogg": "audio/ogg",
45 ".wav": "audio/wav",
46 ".mp4": "video/mp4",
47 ".webm": "video/webm",
48 };
49
50 export function mimeFor(path: string): string {
51 return MIME[extname(path).toLowerCase()] ?? "application/octet-stream";
52 }
53
54 export function isForwardedPath(pathname: string): boolean {
55 return FORWARDED_PATHS.some((prefix) =>
56 prefix.endsWith("/") ? pathname.startsWith(prefix) : pathname === prefix || pathname.startsWith(prefix + "/"));
57 }
58
59 const notFound = (reason: string): AppRoute => ({ kind: "notFound", reason });
60
61 export function routeAppRequest(rawURL: string, distRoot: string, isFile: (path: string) => boolean): AppRoute {
62 let url: URL;
63 try {
64 url = new URL(rawURL);
65 } catch {
66 return notFound("malformed URL");
67 }
68 if (url.protocol !== `${APP_SCHEME}:`) return notFound("unsupported scheme");
69 if (url.host !== "app") return notFound("unknown host");
70 if (isForwardedPath(url.pathname)) return { kind: "forward", target: url.pathname + url.search };
71 let decoded: string;
72 try {
73 decoded = decodeURIComponent(url.pathname);
74 } catch {
75 return notFound("malformed path");
76 }
77 if (decoded.includes("\0") || decoded.includes("\\")) return notFound("illegal characters");
78 const pathname = decoded === "/" || decoded === "" ? "/index.html" : decoded;
79 const segments = pathname.split("/").slice(1);
80 if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) return notFound("path traversal");
81 const root = resolve(distRoot);
82 const file = join(root, ...segments);
83 if (!file.startsWith(root + sep)) return notFound("outside dist root");
84 if (!isFile(file)) return notFound("no such file");
85 return { kind: "file", path: file, mime: mimeFor(file) };
86 }
87
88 export function resolveDistRoot(input: { env: NodeJS.ProcessEnv; appPath: string; resourcesPath: string; packaged: boolean }): string {
89 const override = (input.env.REASONIX_FRONTEND_DIST ?? "").trim();
90 if (override !== "") return resolve(override);
91 return input.packaged ? join(input.resourcesPath, "app") : resolve(input.appPath, "..", "frontend", "dist");
92 }
93
94 export interface ResourceOrigin {
95 origin: string;
96 token: string;
97 }
98
99 export interface AppProtocolDeps {
100 protocol: { handle(scheme: string, handler: (request: Request) => Promise<Response> | Response): void };
101 fetch(input: string, init: RequestInit & { bypassCustomProtocolHandlers?: boolean }): Promise<Response>;
102 distRoot: string;
103 resources(): ResourceOrigin | null;
104 log: Logger;
105 }
106
107 const FORWARDED_REQUEST_HEADERS = ["accept", "range", "if-none-match", "if-modified-since"];
108
109 function fileExists(path: string): boolean {
110 try {
111 return statSync(path).isFile();
112 } catch {
113 return false;
114 }
115 }
116
117 function text(status: number, body: string): Response {
118 return new Response(body, { status, headers: { "content-type": "text/plain; charset=utf-8" } });
119 }
120
121 export function registerAppProtocol(deps: AppProtocolDeps): void {
122 deps.protocol.handle(APP_SCHEME, async (request) => {
123 const route = routeAppRequest(request.url, deps.distRoot, fileExists);
124 if (route.kind === "notFound") {
125 deps.log.warn(`404 ${request.url}: ${route.reason}`);
126 return text(404, "Not found");
127 }
128 if (route.kind === "forward") {
129 const resources = deps.resources();
130 if (!resources) return text(503, "Desktop service unavailable");
131 const headers = new Headers({ authorization: `Bearer ${resources.token}` });
132 for (const name of FORWARDED_REQUEST_HEADERS) {
133 const value = request.headers.get(name);
134 if (value) headers.set(name, value);
135 }
136 try {
137 return await deps.fetch(resources.origin + route.target, { method: request.method, headers, bypassCustomProtocolHandlers: true });
138 } catch (error) {
139 deps.log.warn(`resource forward failed for ${route.target}: ${errorText(error)}`);
140 return text(502, "Resource origin unreachable");
141 }
142 }
143 const body = Readable.toWeb(createReadStream(route.path)) as unknown as ReadableStream;
144 return new Response(body, { status: 200, headers: { "content-type": route.mime, "cache-control": "no-cache" } });
145 });
146 }
147
147 lines TYPESCRIPT