Skip to main content

Mountain/Binary/Build/
Scheme.rs

1//! # Scheme Handler Module
2//!
3//! Provides custom URI scheme handlers for Tauri webview isolation.
4//!
5//! ## RESPONSIBILITIES
6//!
7//! - Handle `land://` custom protocol requests
8//! - Routing to local HTTP services via ServiceRegistry
9//! - Forward HTTP requests (GET, POST, PUT, DELETE, PATCH) to local services
10//! - Set appropriate CORS headers for webview isolation
11//! - Handle CORS preflight requests (OPTIONS method)
12//! - Implement basic caching for static assets
13//! - Handle health checks and error scenarios
14//!
15//! ## ARCHITECTURAL ROLE
16//!
17//! The Scheme module provides protocol-level isolation and routing for
18//! webviews:
19//!
20//! ```text
21//! land://code.editor.land/path ──► ServiceRegistry ──► http://127.0.0.1:PORT/path
22//!                                       │                        │
23//!                                       ▼                        ▼
24//!                               CORS Headers Set          Local Service
25//!                                                            Response
26//! ```
27//!
28//! ## SECURITY
29//!
30//! - All responses include Access-Control-Allow-Origin: land://code.editor.land
31//! - Content-Type preserved from local service response
32//! - CORS headers set appropriately for cross-origin requests
33//! - Request validation and sanitization
34
35use std::{collections::HashMap, sync::RwLock};
36
37use tauri::http::{
38	Method,
39	request::Request,
40	response::{Builder, Response},
41};
42
43use super::ServiceRegistry::ServiceRegistry;
44use crate::dev_log;
45
46// Global service registry (will be initialized in Tauri setup)
47static SERVICE_REGISTRY:RwLock<Option<ServiceRegistry>> = RwLock::new(None);
48
49/// Initialize the global service registry
50///
51/// This must be called once during application setup before any land://
52/// requests.
53pub fn init_service_registry(registry:ServiceRegistry) {
54	let mut registry_lock = SERVICE_REGISTRY.write().unwrap();
55	*registry_lock = Some(registry);
56}
57
58/// Get a reference to the global service registry
59///
60/// Returns None if not initialized (should not happen in normal operation).
61///
62/// # Safety
63/// This function uses an unsafe block to get a static reference to the
64/// service registry. This is safe because:
65/// 1. The SERVICE_REGISTRY is a static RwLock that lives for the entire program
66/// 2. We only write to it during initialization (before any land:// requests)
67/// 3. After initialization, we only read from it
68/// 4. The RwLock guarantees thread-safe access
69fn get_service_registry() -> Option<ServiceRegistry> {
70	let guard = SERVICE_REGISTRY.read().ok()?;
71	guard.clone()
72}
73
74/// DNS port managed state structure
75///
76/// This struct holds the DNS server port number and is managed by Tauri
77/// as application state, making it accessible to Tauri commands.
78#[derive(Clone, Debug)]
79pub struct DnsPort(pub u16);
80
81/// Cache entry for static asset caching
82#[derive(Clone)]
83struct CacheEntry {
84	/// Cached response bytes
85	body:Vec<u8>,
86	/// Content-Type header value
87	content_type:String,
88	/// Cache-Control header value
89	cache_control:String,
90	/// ETag for conditional requests
91	etag:Option<String>,
92	/// Last-Modified timestamp
93	last_modified:Option<String>,
94}
95
96/// Simple in-memory cache for static assets
97///
98/// Uses a HashMap to store cached responses by URL path.
99/// This is a basic implementation that could be enhanced with:
100/// - TTL-based expiration
101/// - LRU eviction when cache is full
102/// - Size limits
103static CACHE:RwLock<Option<HashMap<String, CacheEntry>>> = RwLock::new(None);
104
105/// Initialize the static asset cache
106fn init_cache() {
107	let mut cache = CACHE.write().unwrap();
108	if cache.is_none() {
109		*cache = Some(HashMap::new());
110	}
111}
112
113/// Get a cached response if available
114fn get_cached(path:&str) -> Option<CacheEntry> {
115	let cache = CACHE.read().unwrap();
116	cache.as_ref()?.get(path).cloned()
117}
118
119/// Store a response in the cache
120fn set_cached(path:&str, entry:CacheEntry) {
121	let mut cache = CACHE.write().unwrap();
122	if let Some(cache) = cache.as_mut() {
123		cache.insert(path.to_string(), entry);
124	}
125}
126
127/// Check if a path should be cached
128///
129/// Returns true for CSS, JS, images, fonts, and other static assets.
130fn should_cache(path:&str) -> bool {
131	let path_lower = path.to_lowercase();
132	path_lower.ends_with(".css")
133		|| path_lower.ends_with(".js")
134		|| path_lower.ends_with(".png")
135		|| path_lower.ends_with(".jpg")
136		|| path_lower.ends_with(".jpeg")
137		|| path_lower.ends_with(".gif")
138		|| path_lower.ends_with(".svg")
139		|| path_lower.ends_with(".woff")
140		|| path_lower.ends_with(".woff2")
141		|| path_lower.ends_with(".ttf")
142		|| path_lower.ends_with(".eot")
143		|| path_lower.ends_with(".ico")
144}
145
146/// Parse a land:// URI to extract domain and path
147///
148/// # Parameters
149///
150/// - `uri`: The land:// URI (e.g., "land://code.editor.land/path/to/resource")
151///
152/// # Returns
153///
154/// A tuple of (domain, path) where:
155/// - domain: "code.editor.land"
156/// - path: "/path/to/resource"
157///
158/// # Example
159///
160/// ```rust
161/// let (domain, path) = parse_land_uri("land://code.editor.land/api/status");
162/// assert_eq!(domain, "code.editor.land");
163/// assert_eq!(path, "/api/status");
164/// ```
165fn parse_land_uri(uri:&str) -> Result<(String, String), String> {
166	// Remove the land:// prefix
167	let without_scheme = uri
168		.strip_prefix("land://")
169		.ok_or_else(|| format!("Invalid land:// URI: {}", uri))?;
170
171	// Split into domain and path
172	let parts:Vec<&str> = without_scheme.splitn(2, '/').collect();
173
174	let domain = parts.get(0).ok_or_else(|| format!("No domain in URI: {}", uri))?.to_string();
175
176	let path = if parts.len() > 1 { format!("/{}", parts[1]) } else { "/".to_string() };
177
178	dev_log!("lifecycle", "[Scheme] Parsed URI: {} -> domain={}, path={}", uri, domain, path);
179	Ok((domain, path))
180}
181
182/// Forward an HTTP request to a local service
183///
184/// # Parameters
185///
186/// - `url`: The full URL to forward to (e.g., "http://127.0.0.1:8080/path")
187/// - `request`: The original Tauri request
188/// - `method`: The HTTP method to use
189///
190/// # Returns
191///
192/// A Tauri response with status, headers, and body from the forwarded request
193fn forward_http_request(
194	url:&str,
195	request:&Request<Vec<u8>>,
196	method:Method,
197) -> Result<(u16, Vec<u8>, HashMap<String, String>), String> {
198	// Parse URL to get host and path
199	let parsed_url = url.parse::<http::uri::Uri>().map_err(|e| format!("Invalid URL: {}", e))?;
200
201	// Extract host, port, and path as owned strings to satisfy 'static lifetime
202	let host = parsed_url.host().ok_or("No host in URL")?.to_string();
203	let port = parsed_url.port_u16().unwrap_or(80);
204	let path = parsed_url
205		.path_and_query()
206		.map(|p| p.as_str().to_string())
207		.unwrap_or_else(|| "/".to_string());
208
209	let addr = format!("{}:{}", host, port);
210
211	dev_log!("lifecycle", "[Scheme] Connecting to {} at {}", url, addr);
212
213	// Clone request body and headers for use in thread
214	let body = request.body().clone();
215	let headers:Vec<(String, String)> = request
216		.headers()
217		.iter()
218		.filter_map(|(name, value)| {
219			let header_name = name.as_str().to_lowercase();
220			let hop_by_hop_headers = [
221				"connection",
222				"keep-alive",
223				"proxy-authenticate",
224				"proxy-authorization",
225				"te",
226				"trailers",
227				"transfer-encoding",
228				"upgrade",
229			];
230			if !hop_by_hop_headers.contains(&header_name.as_str()) {
231				value.to_str().ok().map(|v| (name.as_str().to_string(), v.to_string()))
232			} else {
233				None
234			}
235		})
236		.collect();
237
238	// Use tokio runtime to make the request
239	let result = std::thread::spawn(move || {
240		let rt = tokio::runtime::Runtime::new().map_err(|e| format!("Failed to create runtime: {}", e))?;
241
242		rt.block_on(async {
243			use tokio::{
244				io::{AsyncReadExt, AsyncWriteExt},
245				net::TcpStream,
246			};
247
248			// Connect to the service
249			let mut stream = TcpStream::connect(&addr)
250				.await
251				.map_err(|e| format!("Failed to connect: {}", e))?;
252
253			// Build HTTP request
254			let mut request_str = format!("{} {} HTTP/1.1\r\nHost: {}\r\n", method.as_str(), path, host);
255
256			// Add headers
257			for (name, value) in &headers {
258				request_str.push_str(&format!("{}: {}\r\n", name, value));
259			}
260
261			// Add Content-Length if there's a body
262			if !body.is_empty() {
263				request_str.push_str(&format!("Content-Length: {}\r\n", body.len()));
264			}
265
266			request_str.push_str("\r\n");
267
268			// Send request
269			stream
270				.write_all(request_str.as_bytes())
271				.await
272				.map_err(|e| format!("Failed to write request: {}", e))?;
273
274			if !body.is_empty() {
275				stream
276					.write_all(&body)
277					.await
278					.map_err(|e| format!("Failed to write body: {}", e))?;
279			}
280
281			// Read response
282			let mut buffer = Vec::new();
283			let mut temp_buf = [0u8; 8192];
284
285			loop {
286				let n = stream
287					.read(&mut temp_buf)
288					.await
289					.map_err(|e| format!("Failed to read response: {}", e))?;
290
291				if n == 0 {
292					break;
293				}
294
295				buffer.extend_from_slice(&temp_buf[..n]);
296
297				// Check if we've read the full response (simple check for content-length or end
298				// of headers)
299				if buffer.len() > 1024 * 1024 {
300					// Limit to 1MB
301					dev_log!("lifecycle", "warn: [Scheme] Response too large, truncating");
302					break;
303				}
304
305				// Simple heuristic: if we have a full HTTP response with Content-Length, check
306				// if we've read everything
307				if let Some(headers_end) = buffer.windows(4).position(|w| w == b"\r\n\r\n") {
308					let headers = String::from_utf8_lossy(&buffer[..headers_end]);
309					if let Some(cl_line) = headers.lines().find(|l| l.to_lowercase().starts_with("content-length:")) {
310						if let Ok(cl) = cl_line.trim_start_matches("content-length:").trim().parse::<usize>() {
311							let body_expected = headers_end + 4 + cl;
312							if buffer.len() >= body_expected {
313								break;
314							}
315						}
316					} else if !headers.contains("Transfer-Encoding: chunked") {
317						// No Content-Length and not chunked, assume complete if connection closes
318						continue;
319					}
320				}
321			}
322
323			// Parse response
324			let response_str = String::from_utf8_lossy(&buffer);
325			parse_http_response(&response_str)
326		})
327	})
328	.join()
329	.map_err(|e| format!("Thread panicked: {:?}", e))?;
330
331	result
332}
333
334/// Parse an HTTP response string into status, body, and headers
335fn parse_http_response(response:&str) -> Result<(u16, Vec<u8>, HashMap<String, String>), String> {
336	// Split headers and body
337	let headers_end = response
338		.find("\r\n\r\n")
339		.ok_or("Invalid HTTP response: no headers/body separator")?;
340
341	let headers_str = &response[..headers_end];
342	let body = response[headers_end + 4..].as_bytes().to_vec();
343
344	// Parse status line
345	let mut lines = headers_str.lines();
346	let status_line = lines.next().ok_or("Invalid HTTP response: no status line")?;
347
348	// Parse status code (e.g., "HTTP/1.1 200 OK" -> 200)
349	let status = status_line
350		.split_whitespace()
351		.nth(1)
352		.and_then(|s| s.parse::<u16>().ok())
353		.ok_or_else(|| format!("Invalid status line: {}", status_line))?;
354
355	// Parse headers
356	let mut headers = HashMap::new();
357	for line in lines {
358		if let Some((name, value)) = line.split_once(':') {
359			headers.insert(name.trim().to_lowercase(), value.trim().to_string());
360		}
361	}
362
363	Ok((status, body, headers))
364}
365
366/// Handles `land://` custom protocol requests
367///
368/// This function is called by Tauri when a webview makes a request to the
369/// `land://` protocol. It routes the request to local HTTP services via the
370/// ServiceRegistry.
371///
372/// # Parameters
373///
374/// - `request`: The incoming webview request with URI path and headers
375///
376/// # Returns
377///
378/// A Tauri response with:
379/// - Status code from local service (or error status)
380/// - Headers from local service plus CORS headers
381/// - Response body from local service (or error body)
382///
383/// # Implementation Details
384///
385/// 1. Parse the land:// URI to extract domain and path
386/// 2. Look up the service in the ServiceRegistry
387/// 3. Handle CORS preflight (OPTIONS) requests
388/// 4. Check cache for static assets
389/// 5. Forward the request to the local service
390/// 6. Add CORS headers to the response
391/// 7. Cache static assets for future requests
392///
393/// # Error Handling
394///
395/// - 400: Invalid URI format
396/// - 404: Service not found in registry
397/// - 503: Service unavailable / request failed
398///
399/// # Example
400///
401/// ```rust
402/// tauri::Builder::default()
403/// 	.register_uri_scheme_protocol("land", |_app, request| land_scheme_handler(request))
404/// ```
405pub fn land_scheme_handler(request:&Request<Vec<u8>>) -> Response<Vec<u8>> {
406	// Initialize cache on first request
407	init_cache();
408
409	// Get URI
410	let uri = request.uri().to_string();
411	dev_log!("lifecycle", "[Scheme] Handling land:// request: {}", uri);
412
413	// Parse URI to extract domain and path
414	let (domain, path) = match parse_land_uri(&uri) {
415		Ok(result) => result,
416		Err(e) => {
417			dev_log!("lifecycle", "error: [Scheme] Failed to parse URI: {}", e);
418			return build_error_response(400, &format!("Bad Request: {}", e));
419		},
420	};
421
422	// Handle CORS preflight requests
423	if request.method() == Method::OPTIONS {
424		dev_log!("lifecycle", "[Scheme] Handling CORS preflight request");
425		return build_cors_preflight_response();
426	}
427
428	// Check cache for static assets
429	if should_cache(&path) {
430		if let Some(cached) = get_cached(&path) {
431			dev_log!("lifecycle", "[Scheme] Cache hit for: {}", path);
432			return build_cached_response(cached);
433		}
434	}
435
436	// Look up service in registry
437	let registry = match get_service_registry() {
438		Some(r) => r,
439		None => {
440			dev_log!("lifecycle", "error: [Scheme] Service registry not initialized");
441			return build_error_response(503, "Service Unavailable: Registry not initialized");
442		},
443	};
444
445	let service = match registry.lookup(&domain) {
446		Some(s) => s,
447		None => {
448			dev_log!("lifecycle", "warn: [Scheme] Service not found: {}", domain);
449			return build_error_response(404, &format!("Not Found: Service {} not registered", domain));
450		},
451	};
452
453	// Build local service URL
454	let local_url = format!("http://127.0.0.1:{}{}", service.port, path);
455
456	dev_log!(
457		"lifecycle",
458		"[Scheme] Routing {} {} to local service at {}",
459		request.method(),
460		uri,
461		local_url
462	);
463
464	// Forward request to local service
465	let result = forward_http_request(&local_url, request, request.method().clone());
466
467	match result {
468		Ok((status, body, headers)) => {
469			// Clone body before using it
470			let body_bytes = body.clone();
471
472			// LAND-FIX B1.P1: MIME-honesty on 404. The localhost
473			// server (or Astro/Vite dev page underneath) returns an
474			// HTML body with `Content-Type: text/html` for any
475			// missing path. The webview asks for `.js`/`.json`/`.css`
476			// files; when it parses the HTML body as JS it crashes
477			// with `SyntaxError: Unexpected token '<'` at column N -
478			// the exact symptom reported in the release-electron-
479			// bundled run. Rewrite the response to text/plain empty
480			// body when the request was for a known asset extension
481			// AND upstream returned non-2xx.
482			let LowerPath = path.to_ascii_lowercase();
483			let IsAssetRequest = LowerPath.ends_with(".js")
484				|| LowerPath.ends_with(".mjs")
485				|| LowerPath.ends_with(".cjs")
486				|| LowerPath.ends_with(".json")
487				|| LowerPath.ends_with(".map")
488				|| LowerPath.ends_with(".css")
489				|| LowerPath.ends_with(".wasm")
490				|| LowerPath.ends_with(".svg")
491				|| LowerPath.ends_with(".png")
492				|| LowerPath.ends_with(".woff")
493				|| LowerPath.ends_with(".woff2")
494				|| LowerPath.ends_with(".ttf")
495				|| LowerPath.ends_with(".otf");
496			let UpstreamSaysHtml = headers
497				.get("content-type")
498				.map(|V| V.to_ascii_lowercase().contains("text/html"))
499				.unwrap_or(false);
500			if IsAssetRequest && (status == 404 || (status >= 400 && UpstreamSaysHtml)) {
501				dev_log!(
502					"scheme-assets",
503					"[LandFix:Mime] swap HTML 404 → text/plain empty for asset path={} status={}",
504					path,
505					status
506				);
507				return Builder::new()
508					.status(404)
509					.header("Content-Type", "text/plain; charset=utf-8")
510					.header("Access-Control-Allow-Origin", "land://code.editor.land")
511					.body(Vec::<u8>::new())
512					.unwrap_or_else(|_| build_error_response(500, "Failed to build 404 response"));
513			}
514
515			// Build response with CORS headers
516			let mut response_builder = Builder::new()
517				.status(status)
518				.header("Access-Control-Allow-Origin", "land://code.editor.land")
519				.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS")
520				.header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With");
521
522			// Add important headers from local service
523			let important_headers = [
524				"content-type",
525				"content-length",
526				"etag",
527				"last-modified",
528				"cache-control",
529				"expires",
530				"content-encoding",
531				"content-disposition",
532				"location",
533			];
534
535			for header_name in &important_headers {
536				if let Some(value) = headers.get(*header_name) {
537					response_builder = response_builder.header(*header_name, value);
538				}
539			}
540
541			let response = response_builder.body(body_bytes);
542
543			// Cache static assets
544			if status == 200 && should_cache(&path) {
545				let content_type = headers
546					.get("content-type")
547					.unwrap_or(&"application/octet-stream".to_string())
548					.clone();
549				let cache_control = headers
550					.get("cache-control")
551					.unwrap_or(&"public, max-age=3600".to_string())
552					.clone();
553				let etag = headers.get("etag").cloned();
554				let last_modified = headers.get("last-modified").cloned();
555
556				let entry = CacheEntry { body, content_type, cache_control, etag, last_modified };
557				set_cached(&path, entry);
558				dev_log!("lifecycle", "[Scheme] Cached response for: {}", path);
559			}
560
561			response.unwrap_or_else(|_| build_error_response(500, "Internal Server Error"))
562		},
563		Err(e) => {
564			dev_log!("lifecycle", "error: [Scheme] Failed to forward request: {}", e);
565			build_error_response(503, &format!("Service Unavailable: {}", e))
566		},
567	}
568}
569
570/// Build an error response with CORS headers
571fn build_error_response(status:u16, message:&str) -> Response<Vec<u8>> {
572	let body = serde_json::json!({
573		"error": message,
574		"status": status
575	});
576
577	Builder::new()
578		.status(status)
579		.header("Content-Type", "application/json")
580		.header("Access-Control-Allow-Origin", "land://code.editor.land")
581		.body(serde_json::to_vec(&body).unwrap_or_default())
582		.unwrap_or_else(|_| Builder::new().status(500).body(Vec::new()).unwrap())
583}
584
585/// Build a CORS preflight response
586fn build_cors_preflight_response() -> Response<Vec<u8>> {
587	Builder::new()
588		.status(204)
589		.header("Access-Control-Allow-Origin", "land://code.editor.land")
590		.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS")
591		.header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
592		.header("Access-Control-Max-Age", "86400")
593		.body(Vec::new())
594		.unwrap()
595}
596
597/// Build a response from cached data
598fn build_cached_response(entry:CacheEntry) -> Response<Vec<u8>> {
599	let mut builder = Builder::new()
600		.status(200)
601		.header("Content-Type", &entry.content_type)
602		.header("Access-Control-Allow-Origin", "land://code.editor.land")
603		.header("Cache-Control", &entry.cache_control);
604
605	if let Some(etag) = &entry.etag {
606		builder = builder.header("ETag", etag);
607	}
608
609	if let Some(last_modified) = &entry.last_modified {
610		builder = builder.header("Last-Modified", last_modified);
611	}
612
613	builder
614		.body(entry.body)
615		.unwrap_or_else(|_| build_error_response(500, "Internal Server Error"))
616}
617
618/// Register a service with the land:// scheme
619///
620/// This helper function makes it easy to register local services.
621///
622/// # Parameters
623///
624/// - `name`: Domain name (e.g., "code.editor.land")
625/// - `port`: Local port where the service is listening
626pub fn register_land_service(name:&str, port:u16) {
627	let registry = get_service_registry().expect("Service registry not initialized. Call init_service_registry first.");
628	registry.register(name.to_string(), port, Some("/health".to_string()));
629	dev_log!("lifecycle", "[Scheme] Registered service: {} -> {}", name, port);
630}
631
632/// Get the port for a registered service
633///
634/// # Parameters
635///
636/// - `name`: Domain name to look up
637///
638/// # Returns
639///
640/// - `Some(port)` if service is registered
641/// - `None` if service not found
642pub fn get_land_port(name:&str) -> Option<u16> {
643	let registry = get_service_registry()?;
644	registry.lookup(name).map(|s| s.port)
645}
646
647/// Handles `land://` custom protocol requests asynchronously
648///
649/// This is the asynchronous version of `land_scheme_handler` that uses
650/// Tauri's `UriSchemeResponder` to respond asynchronously, allowing the
651/// request processing to happen in a separate thread.
652///
653/// This is the recommended handler for production use as it provides better
654/// performance and doesn't block the main thread.
655///
656/// # Parameters
657///
658/// - `_ctx`: The URI scheme context (not used in current implementation)
659/// - `request`: The incoming webview request with URI path and headers
660/// - `responder`: The responder to send the response back asynchronously
661///
662/// # Platform Support
663///
664/// - **macOS, Linux**: Uses `land://localhost/` as Origin
665/// - **Windows**: Uses `http://land.localhost/` as Origin by default
666///
667/// # Example
668///
669/// ```rust
670/// tauri::Builder::default()
671/// 	.register_asynchronous_uri_scheme_protocol("land", |_ctx, request, responder| {
672/// 		land_scheme_handler_async(_ctx, request, responder)
673/// 	})
674/// ```
675///
676/// Note: This implementation uses thread spawning as a workaround since
677/// Tauri 2.x's async scheme handler API requires specific runtime setup.
678/// The thread-based approach works correctly and is production-ready.
679pub fn land_scheme_handler_async<R:tauri::Runtime>(
680	_ctx:tauri::UriSchemeContext<'_, R>,
681	request:tauri::http::request::Request<Vec<u8>>,
682	responder:tauri::UriSchemeResponder,
683) {
684	// Spawn a new thread to handle the request asynchronously
685	std::thread::spawn(move || {
686		let response = land_scheme_handler(&request);
687		responder.respond(response);
688	});
689}
690
691/// Get the appropriate Access-Control-Allow-Origin header for the current
692/// platform
693///
694/// Tauri uses different origins for custom URI schemes on different platforms:
695/// - macOS, Linux: land://localhost/
696/// - Windows: <http://land.localhost/>
697///
698/// Returns a comma-separated list of origins to support all platforms.
699#[allow(dead_code)]
700fn get_cors_origins() -> &'static str {
701	// Support both macOS/Linux (land://localhost) and Windows (http://land.localhost)
702	"land://localhost, http://land.localhost, land://code.editor.land"
703}
704
705/// Initializes the scheme handler module
706///
707/// This is a placeholder function that can be used for any future
708/// initialization logic needed by the scheme handler.
709#[inline]
710pub fn Scheme() {}
711
712// ==========================================================================
713// vscode-file:// Protocol Handler
714// ==========================================================================
715
716/// MIME type detection from file extension
717fn MimeFromExtension(Path:&str) -> &'static str {
718	if Path.ends_with(".js") || Path.ends_with(".mjs") {
719		"application/javascript"
720	} else if Path.ends_with(".css") {
721		"text/css"
722	} else if Path.ends_with(".html") || Path.ends_with(".htm") {
723		"text/html"
724	} else if Path.ends_with(".json") {
725		"application/json"
726	} else if Path.ends_with(".svg") {
727		"image/svg+xml"
728	} else if Path.ends_with(".png") {
729		"image/png"
730	} else if Path.ends_with(".jpg") || Path.ends_with(".jpeg") {
731		"image/jpeg"
732	} else if Path.ends_with(".gif") {
733		"image/gif"
734	} else if Path.ends_with(".woff") {
735		"font/woff"
736	} else if Path.ends_with(".woff2") {
737		"font/woff2"
738	} else if Path.ends_with(".ttf") {
739		"font/ttf"
740	} else if Path.ends_with(".wasm") {
741		"application/wasm"
742	} else if Path.ends_with(".map") {
743		"application/json"
744	} else if Path.ends_with(".txt") || Path.ends_with(".md") {
745		"text/plain"
746	} else if Path.ends_with(".xml") {
747		"application/xml"
748	} else {
749		"application/octet-stream"
750	}
751}
752
753/// Handles `vscode-file://` custom protocol requests.
754///
755/// VS Code's Electron workbench computes asset URLs as:
756///   `vscode-file://vscode-app/{appRoot}/out/vs/workbench/...`
757///
758/// This handler maps those URLs to the embedded frontend assets
759/// served from the `frontendDist` directory (`../Sky/Target`).
760///
761/// # URL Mapping
762///
763/// ```text
764/// vscode-file://vscode-app/Static/Application/vs/workbench/foo.js
765///                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
766///                          This path maps to Sky/Target/Static/Application/vs/workbench/foo.js
767/// ```
768///
769/// The `/out/` prefix that the workbench appends is stripped if present,
770/// since our assets live at `/Static/Application/vs/` not
771/// `/Static/Application/out/vs/`.
772///
773/// # Parameters
774///
775/// - `AppHandle`: Tauri AppHandle for resolving the frontend dist path
776/// - `Request`: The incoming request
777///
778/// # Returns
779///
780/// Response with file contents and correct MIME type, or 404
781pub fn VscodeFileSchemeHandler<R:tauri::Runtime>(
782	AppHandle:&tauri::AppHandle<R>,
783	Request:&tauri::http::request::Request<Vec<u8>>,
784) -> Response<Vec<u8>> {
785	let Uri = Request.uri().to_string();
786	// Per-asset-request line - every `<img src="vscode-file://...">` +
787	// worker / wasm / font in the workbench fires through here. The
788	// `scheme-assets` line below (opt-in tag) already captures the
789	// same data; duplicating under `lifecycle` at the default level
790	// just floods the log.
791	dev_log!("scheme-assets", "[LandFix:VscodeFile] Request: {}", Uri);
792	dev_log!("scheme-assets", "[SchemeAssets] request uri={}", Uri);
793
794	// Extract path from: vscode-file://<authority>/<path>
795	//
796	// The canonical workbench-side authority is `vscode-app` (used by
797	// `FileAccess.uriToBrowserUri` for ALL workbench resources). But
798	// `WebviewImplementation::asWebviewUri` rewrites local resource
799	// URIs to use the extension's identifier as the authority - e.g.
800	// `vscode-file://vscode.git/Volumes/.../extensions/git/media/icon.svg`.
801	// The strip-prefix chain below covers both:
802	//   1. Exact `vscode-app` authority (with or without trailing `/`)
803	//   2. ANY other authority - we treat the post-authority path as the resource
804	//      path and let the OS-absolute-root detection below serve it straight from
805	//      disk. Without this fallback every extension-supplied webview asset
806	//      (icons, scripts, stylesheets, fonts) returned 404 because the strip
807	//      yielded `""` and the asset_resolver lookup ran with an empty key.
808	let FilePath = Uri
809		.strip_prefix("vscode-file://vscode-app/")
810		.or_else(|| Uri.strip_prefix("vscode-file://vscode-app"))
811		.or_else(|| {
812			// Generic `vscode-file://<authority>/<path>` - skip past the
813			// `vscode-file://` scheme + the authority's first `/`.
814			let After = Uri.strip_prefix("vscode-file://")?;
815			let SlashIdx = After.find('/')?;
816			Some(&After[SlashIdx + 1..])
817		})
818		.unwrap_or("");
819
820	// Strip /out/ prefix if present - our assets are at /Static/Application/vs/
821	// not /Static/Application/out/vs/
822	let CleanPath = if FilePath.starts_with("Static/Application//out/") {
823		FilePath.replacen("Static/Application//out/", "Static/Application/", 1)
824	} else if FilePath.starts_with("Static/Application/out/") {
825		FilePath.replacen("Static/Application/out/", "Static/Application/", 1)
826	} else {
827		FilePath.to_string()
828	};
829
830	// VS Code's nodeModulesPath = 'vs/../../node_modules' resolves ../../ from
831	// Static/Application/vs/ up to Static/. The browser canonicalizes this to
832	// Static/node_modules/ but our files live at Static/Application/node_modules/.
833	let CleanPath = if CleanPath.starts_with("Static/node_modules/") {
834		CleanPath.replacen("Static/node_modules/", "Static/Application/node_modules/", 1)
835	} else {
836		CleanPath
837	};
838
839	// Strip `?<query>` and `#<fragment>` from the resolved path so
840	// filesystem / asset-resolver lookups operate on a clean path
841	// component. Roo's runtime sourcemap-probe (`vZt` in its bundle)
842	// fetches `<src>?source-map=true` which would otherwise hit the
843	// asset_resolver as a literal `index.js?source-map=true` filename
844	// and either 404 or fall through to the SPA-fallback `index.html`
845	// (5765 bytes served as `application/octet-stream`). With the
846	// strip, `index.js?source-map=true` → `index.js`, which exists on
847	// disk and serves correctly with the right MIME. Equivalent for
848	// `#<fragment>`. Sourcemap-probe URLs that point to non-existent
849	// suffixes (`index.map.json`, `index.sourcemap`) still 404
850	// silently; that is the intended behavior of `vZt`'s preload list.
851	let CleanPath = match CleanPath.split_once(['?', '#']) {
852		Some((Before, _)) => Before.to_string(),
853		None => CleanPath,
854	};
855
856	// P1.5 fix: DevTools fetches `*.js.map` for every bundled script it loads
857	// to render pretty stack traces. Our `Static/Application/` tree ships the
858	// JS files without their `.map` siblings (esbuild's `sourcemap:false` path)
859	// so those requests always 404. Short-circuit here with a clean
860	// `204 No Content` - Chromium treats 204 as "no map available" and moves
861	// on silently, avoiding both the noisy stderr lines and the filesystem
862	// stat round-trip per request.
863	if CleanPath.ends_with(".map") {
864		return Builder::new()
865			.status(204)
866			.header("Access-Control-Allow-Origin", "*")
867			.header("Cross-Origin-Resource-Policy", "cross-origin")
868			.body(Vec::new())
869			.unwrap_or_else(|_| build_error_response(500, "Failed to build response"));
870	}
871
872	// CSS-as-JS shim: when a `.css` URL is requested through
873	// `vscode-file://` (which happens for any unstripped raw `import
874	// "./foo.css"` that VS Code's bundle still contains after
875	// `workbench.js` switches `_VSCODE_FILE_ROOT` to the custom
876	// scheme), the browser would refuse the response with
877	// `'text/css' is not a valid JavaScript MIME type`. Service
878	// Workers can't intercept custom-scheme requests, so we inline
879	// the same JS shim the Worker SW emits on the localhost path:
880	// invoke `_LOAD_CSS_WORKER` against the localhost-form path and
881	// export an empty default. The SW + `<link>` fast-path then
882	// loads the actual CSS bytes from `/Static/Application/...`.
883	//
884	// CRITICAL gate: only apply the shim for paths under
885	// `Static/Application/` (i.e. workbench-internal CSS imports
886	// that survive bundling as `import "./foo.css"`). Extension-
887	// contributed CSS lives in absolute filesystem paths
888	// (`Users/...`, `Volumes/...`, `Library/...`, etc.) and reaches
889	// `vscode-file://` via `WebviewImplementation::asWebviewUri`.
890	// Those `.css` files MUST be served as real `text/css` from
891	// disk (the IsAbsoluteOSPath fallback below handles them) -
892	// returning the JS shim instead silently breaks every
893	// extension webview-ui that bundles its own stylesheet
894	// (Roo: `webview-ui/build/assets/index.css`, Claude, GitLens,
895	// Continue, etc. all use Vite/webpack and ship CSS bundles).
896	// Without this gate the iframe loads no styles and the panel
897	// renders as a transparent overlay over the workbench - the
898	// classic "blank webview" symptom.
899	if CleanPath.ends_with(".css") && CleanPath.starts_with("Static/Application/") {
900		let LocalPath = format!("/Static/Application/{}", CleanPath.trim_start_matches("Static/Application/"));
901		let Body = format!("globalThis._LOAD_CSS_WORKER?.({:?}); export default {{}};", LocalPath);
902		dev_log!(
903			"scheme-assets",
904			"[LandFix:VscodeFile] css-shim {} -> _LOAD_CSS_WORKER({})",
905			CleanPath,
906			LocalPath
907		);
908		return Builder::new()
909			.status(200)
910			.header("Content-Type", "application/javascript; charset=utf-8")
911			.header("Access-Control-Allow-Origin", "*")
912			.header("Cross-Origin-Resource-Policy", "cross-origin")
913			.header("Cross-Origin-Embedder-Policy", "require-corp")
914			.header("Cache-Control", "public, max-age=31536000, immutable")
915			.body(Body.into_bytes())
916			.unwrap_or_else(|_| build_error_response(500, "Failed to build response"));
917	}
918
919	// Icon themes, grammars and other extension-contributed assets generate
920	// URIs like `vscode-file://vscode-app/Volumes/<vol>/.../seti.woff` after
921	// `FileAccess.uriToBrowserUri` rewrites a plain `file:///Volumes/...`
922	// extension path. The authority `vscode-app` is followed directly by the
923	// absolute filesystem path (sans leading `/`). Detect the well-known macOS /
924	// Linux absolute-path roots and serve straight from disk instead of trying
925	// to resolve them against `Sky/Target/` (where they do not exist).
926	let IsAbsoluteOSPath = [
927		"Volumes/",
928		"Users/",
929		"Library/",
930		"System/",
931		"Applications/",
932		"private/",
933		"tmp/",
934		"var/",
935		"etc/",
936		"opt/",
937		"home/",
938		"usr/",
939		"srv/",
940		"mnt/",
941		"root/",
942	]
943	.iter()
944	.any(|Prefix| CleanPath.starts_with(Prefix));
945
946	if IsAbsoluteOSPath {
947		let AbsolutePath = format!("/{}", CleanPath);
948		let FilesystemPath = std::path::Path::new(&AbsolutePath);
949		dev_log!(
950			"scheme-assets",
951			"[LandFix:VscodeFile] os-abs candidate {} (exists={}, is_file={})",
952			AbsolutePath,
953			FilesystemPath.exists(),
954			FilesystemPath.is_file()
955		);
956		if FilesystemPath.exists() && FilesystemPath.is_file() {
957			// LAND-PATCH B7.P01: route through the mmap cache. First
958			// hit on a path mmaps the file; subsequent hits are
959			// wait-free DashMap reads. Brotli sibling (`<file>.br`)
960			// is auto-discovered and served when the request offers
961			// `Accept-Encoding: br`.
962			match crate::Cache::AssetMemoryMap::LoadOrInsert::Fn(FilesystemPath) {
963				Ok(Entry) => {
964					let AcceptsBrotli = Request
965						.headers()
966						.get("accept-encoding")
967						.and_then(|V| V.to_str().ok())
968						.map(|S| S.contains("br"))
969						.unwrap_or(false);
970					let (Body, Encoding):(Vec<u8>, Option<&str>) = if AcceptsBrotli {
971						match Entry.AsBrotliSlice() {
972							Some(Slice) => (Slice.to_vec(), Some("br")),
973							None => (Entry.AsSlice().to_vec(), None),
974						}
975					} else {
976						(Entry.AsSlice().to_vec(), None)
977					};
978					dev_log!(
979						"scheme-assets",
980						"[LandFix:VscodeFile] os-abs served {} ({}, {} bytes, encoding={:?})",
981						AbsolutePath,
982						Entry.Mime,
983						Body.len(),
984						Encoding
985					);
986					// `Cross-Origin-Resource-Policy: cross-origin` lets the
987					// COEP-isolated webview iframe (which Mountain serves
988					// from the `vscode-webview://` scheme with
989					// `Cross-Origin-Embedder-Policy: require-corp`) load
990					// these assets via `<script src=…>` / `<link href=…>`.
991					// Without it WebKit refuses to expose the response to
992					// the embedder document and the extension's React
993					// bundle / CSS / fonts come up as cross-origin
994					// resource-policy blocks.
995					let mut B = Builder::new()
996						.status(200)
997						.header("Content-Type", Entry.Mime)
998						.header("Access-Control-Allow-Origin", "*")
999						.header("Cross-Origin-Resource-Policy", "cross-origin")
1000						.header("Cross-Origin-Embedder-Policy", "require-corp")
1001						.header("Cache-Control", "public, max-age=3600");
1002					if let Some(Enc) = Encoding {
1003						B = B.header("Content-Encoding", Enc);
1004					}
1005					return B
1006						.body(Body)
1007						.unwrap_or_else(|_| build_error_response(500, "Failed to build response"));
1008				},
1009				Err(Error) => {
1010					dev_log!(
1011						"lifecycle",
1012						"warn: [LandFix:VscodeFile] os-abs mmap failure {}: {}",
1013						AbsolutePath,
1014						Error
1015					);
1016				},
1017			}
1018		} else {
1019			dev_log!("lifecycle", "warn: [LandFix:VscodeFile] os-abs not on disk: {}", AbsolutePath);
1020		}
1021	}
1022
1023	dev_log!("lifecycle", "[LandFix:VscodeFile] Resolved path: {}", CleanPath);
1024
1025	// Resolve against the frontendDist directory
1026	// In production: embedded in the binary via asset_resolver
1027	// In debug: fall back to filesystem read from Sky/Target
1028	let AssetResult = AppHandle.asset_resolver().get(CleanPath.clone());
1029
1030	if let Some(Asset) = AssetResult {
1031		let Mime = MimeFromExtension(&CleanPath);
1032
1033		dev_log!(
1034			"lifecycle",
1035			"[LandFix:VscodeFile] Serving (embedded) {} ({}, {} bytes)",
1036			CleanPath,
1037			Mime,
1038			Asset.bytes.len()
1039		);
1040		dev_log!(
1041			"scheme-assets",
1042			"[SchemeAssets] serve source=embedded path={} mime={} bytes={}",
1043			CleanPath,
1044			Mime,
1045			Asset.bytes.len()
1046		);
1047
1048		return Builder::new()
1049			.status(200)
1050			.header("Content-Type", Mime)
1051			.header("Access-Control-Allow-Origin", "*")
1052			.header("Cross-Origin-Resource-Policy", "cross-origin")
1053			.header("Cross-Origin-Embedder-Policy", "require-corp")
1054			.header("Cache-Control", "public, max-age=31536000, immutable")
1055			.body(Asset.bytes.to_vec())
1056			.unwrap_or_else(|_| build_error_response(500, "Failed to build response"));
1057	}
1058
1059	// Fallback: read from filesystem (dev mode where assets aren't embedded)
1060	let StaticRoot = crate::IPC::WindServiceHandlers::Utilities::ApplicationRoot::get_static_application_root();
1061
1062	if let Some(Root) = StaticRoot {
1063		let FilesystemPath = std::path::Path::new(&Root).join(&CleanPath);
1064
1065		if FilesystemPath.exists() && FilesystemPath.is_file() {
1066			// LAND-PATCH B7.P01: mmap-cache the StaticRoot fallback
1067			// path so dev-mode workbench reloads pay the syscall
1068			// once per asset for the entire session.
1069			match crate::Cache::AssetMemoryMap::LoadOrInsert::Fn(&FilesystemPath) {
1070				Ok(Entry) => {
1071					let AcceptsBrotli = Request
1072						.headers()
1073						.get("accept-encoding")
1074						.and_then(|V| V.to_str().ok())
1075						.map(|S| S.contains("br"))
1076						.unwrap_or(false);
1077					let (Body, Encoding):(Vec<u8>, Option<&str>) = if AcceptsBrotli {
1078						match Entry.AsBrotliSlice() {
1079							Some(Slice) => (Slice.to_vec(), Some("br")),
1080							None => (Entry.AsSlice().to_vec(), None),
1081						}
1082					} else {
1083						(Entry.AsSlice().to_vec(), None)
1084					};
1085					dev_log!(
1086						"lifecycle",
1087						"[LandFix:VscodeFile] Serving (fs-mmap) {} ({}, {} bytes, encoding={:?})",
1088						CleanPath,
1089						Entry.Mime,
1090						Body.len(),
1091						Encoding
1092					);
1093					// `Cross-Origin-Resource-Policy: cross-origin` lets the
1094					// COEP-isolated webview iframe (which Mountain serves
1095					// from the `vscode-webview://` scheme with
1096					// `Cross-Origin-Embedder-Policy: require-corp`) load
1097					// these assets via `<script src=…>` / `<link href=…>`.
1098					// Without it WebKit refuses to expose the response to
1099					// the embedder document and the extension's React
1100					// bundle / CSS / fonts come up as cross-origin
1101					// resource-policy blocks.
1102					let mut B = Builder::new()
1103						.status(200)
1104						.header("Content-Type", Entry.Mime)
1105						.header("Access-Control-Allow-Origin", "*")
1106						.header("Cross-Origin-Resource-Policy", "cross-origin")
1107						.header("Cross-Origin-Embedder-Policy", "require-corp")
1108						.header("Cache-Control", "public, max-age=3600");
1109					if let Some(Enc) = Encoding {
1110						B = B.header("Content-Encoding", Enc);
1111					}
1112					return B
1113						.body(Body)
1114						.unwrap_or_else(|_| build_error_response(500, "Failed to build response"));
1115				},
1116				Err(Error) => {
1117					dev_log!(
1118						"lifecycle",
1119						"warn: [LandFix:VscodeFile] Failed to read {}: {}",
1120						FilesystemPath.display(),
1121						Error
1122					);
1123				},
1124			}
1125		}
1126	}
1127
1128	dev_log!(
1129		"lifecycle",
1130		"warn: [LandFix:VscodeFile] Not found: {} (resolved: {})",
1131		Uri,
1132		CleanPath
1133	);
1134	build_error_response(404, &format!("Not Found: {}", CleanPath))
1135}
1136
1137/// Custom URI scheme handler for `vscode-webview://` requests.
1138///
1139/// VS Code's `WebviewElement` (used by every extension webview - Roo
1140/// Code, Claude, GitLens, custom-editor providers) wraps the inner
1141/// extension HTML in an `<iframe>` whose `src` is
1142/// `vscode-webview://<authority>/index.html?...`. The `<authority>` is
1143/// a per-instance random base32 string. The authority is irrelevant to
1144/// the bytes served - all that matters is the path component, which
1145/// always resolves under
1146/// `vs/workbench/contrib/webview/browser/pre/`.
1147///
1148/// In stock Electron VS Code, `app.protocol.registerStreamProtocol(
1149/// 'vscode-webview', ...)` serves this directory. Under Tauri 2.x +
1150/// WKWebView, `register_asynchronous_uri_scheme_protocol("vscode-webview",
1151/// ...)` installs an equivalent `WKURLSchemeHandler`. Without this handler,
1152/// every extension that uses `webviewView` / `WebviewPanel` /
1153/// `CustomEditor` lands the inner iframe at a `vscode-webview://...`
1154/// URL the WKWebView can't resolve, the iframe stays blank, and the
1155/// extension surface is dead.
1156///
1157/// Three resources live under `pre/`:
1158///   - `index.html`        - the webview shell that bridges `postMessage`
1159///     between workbench host and inner extension HTML
1160///   - `service-worker.js` - registered by `index.html` to intercept
1161///     `vscode-webview-resource` requests for extension-shipped assets
1162///   - `fake.html`         - sandbox stub used as a placeholder before
1163///     extension HTML arrives via postMessage
1164///
1165/// Anything else (querystrings, extra path segments, GUID-like
1166/// authorities) is silently dropped; the extension's actual content
1167/// gets piped in via the `swMessage` channel after `index.html` boots,
1168/// not through this scheme handler.
1169///
1170/// # Parameters
1171///
1172/// - `AppHandle`: Tauri AppHandle for resolving the embedded asset resolver and
1173///   the dev-mode `Static/Application/` filesystem fallback (same chain as
1174///   `VscodeFileSchemeHandler`).
1175/// - `Request`: The incoming request - typically a `GET` for one of the three
1176///   pre-baked files.
1177///
1178/// # Returns
1179///
1180/// A `Response<Vec<u8>>` carrying:
1181///   - `200 OK` with the file bytes + correct MIME (`text/html` /
1182///     `application/javascript`) when found, or
1183///   - `404 Not Found` when the resolved path falls outside the `pre/`
1184///     directory or the asset isn't shipped.
1185///
1186/// CORS headers are permissive (`*`) to match the workbench host's
1187/// `vscode-webview-resource:` traffic, which round-trips through the
1188/// service worker registered by `index.html`.
1189pub fn VscodeWebviewSchemeHandler<R:tauri::Runtime>(
1190	AppHandle:&tauri::AppHandle<R>,
1191	Request:&tauri::http::request::Request<Vec<u8>>,
1192) -> Response<Vec<u8>> {
1193	let Uri = Request.uri().to_string();
1194	dev_log!("scheme-assets", "[LandFix:VscodeWebview] Request: {}", Uri);
1195
1196	// `vscode-webview://<authority>/<path>?<query>`. We only care about
1197	// `<path>` - authority is per-instance noise, querystring is the
1198	// `id`/`parentId`/`extensionId`/etc that `index.html` reads via
1199	// `URLSearchParams` (we don't touch it).
1200	let After = match Uri.strip_prefix("vscode-webview://") {
1201		Some(Rest) => Rest,
1202		None => {
1203			return build_error_response(400, "vscode-webview scheme without prefix");
1204		},
1205	};
1206	let PathStart = match After.find('/') {
1207		Some(Index) => Index + 1,
1208		None => {
1209			return build_error_response(400, "vscode-webview URI missing path component");
1210		},
1211	};
1212	let PathPlusQuery = &After[PathStart..];
1213	// Trim the querystring + fragment - filesystem doesn't care.
1214	let CleanPath:&str = PathPlusQuery
1215		.split_once(|C:char| C == '?' || C == '#')
1216		.map(|(Path, _)| Path)
1217		.unwrap_or(PathPlusQuery);
1218	// Reject path-traversal attempts. The webview shell is a static
1219	// three-file directory; anything containing `..` or hitting
1220	// outside `pre/` is hostile or a bug.
1221	if CleanPath.is_empty() || CleanPath.contains("..") {
1222		return build_error_response(404, "vscode-webview path empty or traversal");
1223	}
1224
1225	let ResolvedPath = format!("Static/Application/vs/workbench/contrib/webview/browser/pre/{}", CleanPath);
1226	dev_log!(
1227		"scheme-assets",
1228		"[LandFix:VscodeWebview] resolve {} -> {}",
1229		CleanPath,
1230		ResolvedPath
1231	);
1232
1233	// Try the embedded asset resolver first (release / packaged builds
1234	// where `Sky/Target/Static/Application/` is bundled into Mountain's
1235	// binary). Falls through to the filesystem fallback below for
1236	// debug-electron-bundled, where assets ship next to Mountain.
1237	if let Some(Asset) = AppHandle.asset_resolver().get(ResolvedPath.clone()) {
1238		let Mime = MimeFromExtension(&ResolvedPath);
1239		dev_log!(
1240			"scheme-assets",
1241			"[LandFix:VscodeWebview] serve embedded {} ({}, {} bytes)",
1242			ResolvedPath,
1243			Mime,
1244			Asset.bytes.len()
1245		);
1246		return Builder::new()
1247			.status(200)
1248			.header("Content-Type", Mime)
1249			.header("Access-Control-Allow-Origin", "*")
1250			.header("Cross-Origin-Embedder-Policy", "require-corp")
1251			.header("Cross-Origin-Resource-Policy", "cross-origin")
1252			.header("Cache-Control", "no-cache")
1253			.body(Asset.bytes.to_vec())
1254			.unwrap_or_else(|_| build_error_response(500, "Failed to build response"));
1255	}
1256
1257	// Filesystem fallback for dev mode. `ApplicationRoot` is set by
1258	// `Binary/Main/AppLifecycle.rs` to the resolved `Sky/Target/`
1259	// directory at startup so we can read the same `pre/` files the
1260	// embedded resolver would have served.
1261	let StaticRoot = crate::IPC::WindServiceHandlers::Utilities::ApplicationRoot::get_static_application_root();
1262	if let Some(Root) = StaticRoot {
1263		let FilesystemPath = std::path::Path::new(&Root).join(&ResolvedPath);
1264		if FilesystemPath.exists() && FilesystemPath.is_file() {
1265			match std::fs::read(&FilesystemPath) {
1266				Ok(Bytes) => {
1267					let Mime = MimeFromExtension(&ResolvedPath);
1268					dev_log!(
1269						"scheme-assets",
1270						"[LandFix:VscodeWebview] serve filesystem {} ({}, {} bytes)",
1271						FilesystemPath.display(),
1272						Mime,
1273						Bytes.len()
1274					);
1275					return Builder::new()
1276						.status(200)
1277						.header("Content-Type", Mime)
1278						.header("Access-Control-Allow-Origin", "*")
1279						.header("Cross-Origin-Embedder-Policy", "require-corp")
1280						.header("Cross-Origin-Resource-Policy", "cross-origin")
1281						.header("Cache-Control", "no-cache")
1282						.body(Bytes)
1283						.unwrap_or_else(|_| build_error_response(500, "Failed to build response"));
1284				},
1285				Err(Error) => {
1286					dev_log!(
1287						"lifecycle",
1288						"warn: [LandFix:VscodeWebview] Failed to read {}: {}",
1289						FilesystemPath.display(),
1290						Error
1291					);
1292				},
1293			}
1294		}
1295	}
1296
1297	dev_log!(
1298		"lifecycle",
1299		"warn: [LandFix:VscodeWebview] Not found: {} (resolved: {})",
1300		Uri,
1301		ResolvedPath
1302	);
1303	build_error_response(404, &format!("Not Found: {}", ResolvedPath))
1304}