Skip to main content

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