Mountain/Binary/Build/LocalhostPlugin.rs
1//! # Localhost Plugin Module
2//!
3//! Configures and creates the Tauri localhost plugin with CORS headers for
4//! Service Workers and an OTLP proxy for build-baked telemetry.
5
6use std::{
7 io::{Read, Write},
8 net::TcpStream,
9 time::Duration,
10};
11
12use tauri::plugin::TauriPlugin;
13
14/// OTLP collector host:port. OTELBridge.ts sends to `/v1/traces` (same-origin),
15/// this proxy forwards to the real collector via raw TCP. Zero CORS issues.
16///
17/// Currently unused - the OTLP proxy path requires `Response::set_handled` /
18/// `set_status` / `Request::body()` from a patched fork of
19/// `tauri-plugin-localhost`. After resetting the vendored copy to upstream
20/// (`Dependency/Tauri/Dependency/PluginsWorkspace/plugins/localhost`),
21/// those methods are gone;
22#[allow(dead_code)]
23const OTLP_HOST:&str = "127.0.0.1:4318";
24
25/// Resolve the correct `Content-Type` for a request URL by its file extension.
26///
27/// The vendored `tauri-plugin-localhost` asset resolver sometimes reports
28/// `text/html` for disk-served `.js` / `.css` assets, which breaks module
29/// loading in the webview (the browser refuses JS with `'text/html' is not a
30/// valid JavaScript MIME type'`). By pre-setting `Content-Type` in
31/// `on_request`, we guarantee the right MIME for the extensions the workbench
32/// actually loads; the patched plugin keeps our value instead of overwriting.
33///
34/// Fonts are listed explicitly. WKWebView is strict about font MIME types -
35/// when the asset resolver falls back to `application/octet-stream` for a
36/// `.ttf` (which `infer` does on some macOS versions because TrueType has
37/// no magic header), the browser silently refuses to use the font and the
38/// workbench renders icons as blank squares with no console error. The
39/// codicon font is the visible symptom; KaTeX and Seti fonts under
40/// `/Static/Application/extensions/...` follow the same path.
41///
42/// Returns `None` for unknown extensions so the plugin's `asset.mime_type`
43/// fallback still applies (images, WASM, etc.).
44fn MimeFromUrl(Url:&str) -> Option<&'static str> {
45 // Strip query string / fragment before extension match.
46 let Path = Url.split(['?', '#']).next().unwrap_or(Url);
47
48 let Extension = Path.rsplit('.').next()?.to_ascii_lowercase();
49
50 match Extension.as_str() {
51 "js" | "mjs" | "cjs" => Some("application/javascript; charset=utf-8"),
52
53 "css" => Some("text/css; charset=utf-8"),
54
55 "json" | "map" => Some("application/json; charset=utf-8"),
56
57 "html" | "htm" => Some("text/html; charset=utf-8"),
58
59 "svg" => Some("image/svg+xml"),
60
61 "wasm" => Some("application/wasm"),
62
63 "txt" => Some("text/plain; charset=utf-8"),
64
65 "ttf" => Some("font/ttf"),
66
67 "otf" => Some("font/otf"),
68
69 "woff" => Some("font/woff"),
70
71 "woff2" => Some("font/woff2"),
72
73 "eot" => Some("application/vnd.ms-fontobject"),
74
75 _ => None,
76 }
77}
78
79/// Forward a JSON body to the OTLP collector via raw HTTP/1.1 POST.
80/// Returns true if the collector accepted (2xx), false otherwise.
81///
82/// See `OTLP_HOST` for why this is currently unused.
83#[allow(dead_code)]
84fn ProxyToOTLP(Body:&[u8]) -> bool {
85 let Ok(mut Stream) = TcpStream::connect_timeout(&OTLP_HOST.parse().unwrap(), Duration::from_millis(500)) else {
86 return false;
87 };
88
89 let _ = Stream.set_write_timeout(Some(Duration::from_millis(500)));
90
91 let _ = Stream.set_read_timeout(Some(Duration::from_millis(500)));
92
93 let Request = format!(
94 "POST /v1/traces HTTP/1.1\r\nHost: {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: \
95 close\r\n\r\n",
96 OTLP_HOST,
97 Body.len(),
98 );
99
100 if Stream.write_all(Request.as_bytes()).is_err() {
101 return false;
102 }
103
104 if Stream.write_all(Body).is_err() {
105 return false;
106 }
107
108 let mut ResponseBuffer = [0u8; 32];
109
110 let _ = Stream.read(&mut ResponseBuffer);
111
112 // Check for "HTTP/1.1 2" - any 2xx status
113 ResponseBuffer.starts_with(b"HTTP/1.1 2") || ResponseBuffer.starts_with(b"HTTP/1.0 2")
114}
115
116/// Creates and configures the localhost plugin with CORS headers preconfigured.
117///
118/// # CORS Configuration
119///
120/// - Access-Control-Allow-Origin: * (allows all origins)
121/// - Access-Control-Allow-Methods: GET, POST, OPTIONS, HEAD
122/// - Access-Control-Allow-Headers: Content-Type, Authorization, Origin, Accept
123///
124/// # OTLP Proxy
125///
126/// Requests to `/v1/traces` are forwarded to the local OTLP collector
127/// (Jaeger, OTEL Collector, etc.) so OTELBridge.ts can send telemetry
128/// without cross-origin issues. Uses raw TCP - no extra HTTP client dependency.
129pub fn LocalhostPlugin<R:tauri::Runtime>(ServerPort:u16) -> TauriPlugin<R> {
130 // Resolve the user's home directory once at startup. Used to seed
131 // the vendored localhost plugin's `extension_root` allowlist for
132 // the `/Extension/<abs-fs-path>` URL prefix, which serves
133 // extension-contributed assets (icon fonts, webview resources,
134 // images) directly from the user's extension installation dirs.
135 //
136 // Without this, every workbench `@font-face` rule emitted by
137 // `iconsStyleSheet.js` for a sideloaded extension (GitLens,
138 // dart-code, ...) lands as a missing-glyph blank box - the
139 // upstream URL is `vscode-file://vscode-app/<absolute-fs-path>`
140 // which WKWebView cannot resolve under Tauri. Land's Output
141 // transform `RewriteIconsStyleSheetURLs` rewrites those to
142 // `<origin>/Extension/<absolute-fs-path>`; this allowlist is
143 // the receiving end.
144 let HomeDirectory = std::env::var_os("HOME")
145 .or_else(|| std::env::var_os("USERPROFILE"))
146 .map(std::path::PathBuf::from);
147
148 let LandExtensionsRoot = HomeDirectory.as_ref().map(|Home| Home.join(".land/extensions"));
149
150 let VSCodeExtensionsRoot = HomeDirectory.as_ref().map(|Home| Home.join(".vscode/extensions"));
151
152 // Bundle-resident built-ins. When the app is shipped as a `.app`,
153 // the 93 built-in extensions live under
154 // `Contents/Resources/extensions/`. Without this entry their
155 // icon-stylesheet URLs (rewritten to `/Extension/<abs-path>`) are
156 // rejected by the localhost plugin's allowlist and render as blank
157 // boxes. Two probe paths cover both bundle conventions: the Tauri
158 // default (`Contents/Resources/extensions`) and the VS Code-style
159 // nested `Contents/Resources/app/extensions` some tooling produces.
160 // The repo-layout fallback covers raw `Target/release/<bin>`
161 // launches that resolve from inside the monorepo.
162 let ExeParent = std::env::current_exe()
163 .ok()
164 .and_then(|Exe| Exe.parent().map(|P| P.to_path_buf()));
165
166 let BundleExtensionsRoot = ExeParent.as_ref().and_then(|Parent| {
167 let Candidate = Parent.join("../Resources/extensions");
168 Candidate.canonicalize().ok().or(Some(Candidate))
169 });
170
171 let BundleExtensionsAppRoot = ExeParent.as_ref().and_then(|Parent| {
172 let Candidate = Parent.join("../Resources/app/extensions");
173 Candidate.canonicalize().ok().or(Some(Candidate))
174 });
175
176 let RepoExtensionsRoot = ExeParent.as_ref().and_then(|Parent| {
177 let Candidate = Parent.join("../../../Sky/Target/Static/Application/extensions");
178 Candidate.canonicalize().ok().or(Some(Candidate))
179 });
180
181 let mut Builder = tauri_plugin_localhost::Builder::new(ServerPort);
182
183 if let Some(Root) = LandExtensionsRoot {
184 Builder = Builder.extension_root(Root);
185 }
186
187 if let Some(Root) = VSCodeExtensionsRoot {
188 Builder = Builder.extension_root(Root);
189 }
190
191 if let Some(Root) = BundleExtensionsRoot {
192 Builder = Builder.extension_root(Root);
193 }
194
195 if let Some(Root) = BundleExtensionsAppRoot {
196 Builder = Builder.extension_root(Root);
197 }
198
199 if let Some(Root) = RepoExtensionsRoot {
200 Builder = Builder.extension_root(Root);
201 }
202
203 Builder
204 .on_request(|Request, Response| {
205 // CORS headers for Service Workers and frontend integration.
206 Response.add_header("Access-Control-Allow-Origin", "*");
207 Response.add_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS, HEAD");
208 Response.add_header("Access-Control-Allow-Headers", "Content-Type, Authorization, Origin, Accept");
209
210 let Url = Request.url();
211
212 // LAND-PATCH B1.X: the upstream tauri-plugin-localhost `Response`
213 // only exposes `add_header` - no `set_handled` / `set_status`,
214 // and `Request` exposes only `url()` (no `body()`). Mountain's
215 // previous OTLP proxy + status override depended on a patched
216 // fork. After resetting the vendored copy to upstream those
217 // methods are gone. The OTLP proxy is moved to the dev OTEL
218 // collector (run separately from Tauri); the status override
219 // is no longer needed because the upstream plugin always emits
220 // 200 OK on a successful asset hit and the asset resolver's
221 // 404 path is sufficient for the un-mocked case.
222 //
223 // To restore the OTLP-proxy / status-override path, patch the
224 // vendored `tauri-plugin-localhost` (`Dependency/Tauri/
225 // Dependency/PluginsWorkspace/plugins/localhost/src/lib.rs`)
226 // to add `Response::set_handled(bool)`, `Response::set_status(
227 // u16)`, and `Request::body() -> &[u8]`.
228
229 // Pre-set the correct `Content-Type` for known asset extensions.
230 // The upstream plugin sets `Content-Type` from `asset.mime_type`
231 // before invoking the on_request callback, but the user-set
232 // value via `add_header` overrides it (HashMap insert).
233 if let Some(Mime) = MimeFromUrl(Url) {
234 Response.add_header("Content-Type", Mime);
235 }
236 })
237 .build()
238}