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			// Build response with CORS headers
473			let mut response_builder = Builder::new()
474				.status(status)
475				.header("Access-Control-Allow-Origin", "land://code.editor.land")
476				.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS")
477				.header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With");
478
479			// Add important headers from local service
480			let important_headers = [
481				"content-type",
482				"content-length",
483				"etag",
484				"last-modified",
485				"cache-control",
486				"expires",
487				"content-encoding",
488				"content-disposition",
489				"location",
490			];
491
492			for header_name in &important_headers {
493				if let Some(value) = headers.get(*header_name) {
494					response_builder = response_builder.header(*header_name, value);
495				}
496			}
497
498			let response = response_builder.body(body_bytes);
499
500			// Cache static assets
501			if status == 200 && should_cache(&path) {
502				let content_type = headers
503					.get("content-type")
504					.unwrap_or(&"application/octet-stream".to_string())
505					.clone();
506				let cache_control = headers
507					.get("cache-control")
508					.unwrap_or(&"public, max-age=3600".to_string())
509					.clone();
510				let etag = headers.get("etag").cloned();
511				let last_modified = headers.get("last-modified").cloned();
512
513				let entry = CacheEntry { body, content_type, cache_control, etag, last_modified };
514				set_cached(&path, entry);
515				dev_log!("lifecycle", "[Scheme] Cached response for: {}", path);
516			}
517
518			response.unwrap_or_else(|_| build_error_response(500, "Internal Server Error"))
519		},
520		Err(e) => {
521			dev_log!("lifecycle", "error: [Scheme] Failed to forward request: {}", e);
522			build_error_response(503, &format!("Service Unavailable: {}", e))
523		},
524	}
525}
526
527/// Build an error response with CORS headers
528fn build_error_response(status:u16, message:&str) -> Response<Vec<u8>> {
529	let body = serde_json::json!({
530		"error": message,
531		"status": status
532	});
533
534	Builder::new()
535		.status(status)
536		.header("Content-Type", "application/json")
537		.header("Access-Control-Allow-Origin", "land://code.editor.land")
538		.body(serde_json::to_vec(&body).unwrap_or_default())
539		.unwrap_or_else(|_| Builder::new().status(500).body(Vec::new()).unwrap())
540}
541
542/// Build a CORS preflight response
543fn build_cors_preflight_response() -> Response<Vec<u8>> {
544	Builder::new()
545		.status(204)
546		.header("Access-Control-Allow-Origin", "land://code.editor.land")
547		.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS")
548		.header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
549		.header("Access-Control-Max-Age", "86400")
550		.body(Vec::new())
551		.unwrap()
552}
553
554/// Build a response from cached data
555fn build_cached_response(entry:CacheEntry) -> Response<Vec<u8>> {
556	let mut builder = Builder::new()
557		.status(200)
558		.header("Content-Type", &entry.content_type)
559		.header("Access-Control-Allow-Origin", "land://code.editor.land")
560		.header("Cache-Control", &entry.cache_control);
561
562	if let Some(etag) = &entry.etag {
563		builder = builder.header("ETag", etag);
564	}
565
566	if let Some(last_modified) = &entry.last_modified {
567		builder = builder.header("Last-Modified", last_modified);
568	}
569
570	builder
571		.body(entry.body)
572		.unwrap_or_else(|_| build_error_response(500, "Internal Server Error"))
573}
574
575/// Register a service with the land:// scheme
576///
577/// This helper function makes it easy to register local services.
578///
579/// # Parameters
580///
581/// - `name`: Domain name (e.g., "code.editor.land")
582/// - `port`: Local port where the service is listening
583pub fn register_land_service(name:&str, port:u16) {
584	let registry = get_service_registry().expect("Service registry not initialized. Call init_service_registry first.");
585	registry.register(name.to_string(), port, Some("/health".to_string()));
586	dev_log!("lifecycle", "[Scheme] Registered service: {} -> {}", name, port);
587}
588
589/// Get the port for a registered service
590///
591/// # Parameters
592///
593/// - `name`: Domain name to look up
594///
595/// # Returns
596///
597/// - `Some(port)` if service is registered
598/// - `None` if service not found
599pub fn get_land_port(name:&str) -> Option<u16> {
600	let registry = get_service_registry()?;
601	registry.lookup(name).map(|s| s.port)
602}
603
604/// Handles `land://` custom protocol requests asynchronously
605///
606/// This is the asynchronous version of `land_scheme_handler` that uses
607/// Tauri's `UriSchemeResponder` to respond asynchronously, allowing the
608/// request processing to happen in a separate thread.
609///
610/// This is the recommended handler for production use as it provides better
611/// performance and doesn't block the main thread.
612///
613/// # Parameters
614///
615/// - `_ctx`: The URI scheme context (not used in current implementation)
616/// - `request`: The incoming webview request with URI path and headers
617/// - `responder`: The responder to send the response back asynchronously
618///
619/// # Platform Support
620///
621/// - **macOS, Linux**: Uses `land://localhost/` as Origin
622/// - **Windows**: Uses `http://land.localhost/` as Origin by default
623///
624/// # Example
625///
626/// ```rust
627/// tauri::Builder::default()
628/// 	.register_asynchronous_uri_scheme_protocol("land", |_ctx, request, responder| {
629/// 		land_scheme_handler_async(_ctx, request, responder)
630/// 	})
631/// ```
632///
633/// Note: This implementation uses thread spawning as a workaround since
634/// Tauri 2.x's async scheme handler API requires specific runtime setup.
635/// The thread-based approach works correctly and is production-ready.
636pub fn land_scheme_handler_async<R:tauri::Runtime>(
637	_ctx:tauri::UriSchemeContext<'_, R>,
638	request:tauri::http::request::Request<Vec<u8>>,
639	responder:tauri::UriSchemeResponder,
640) {
641	// Spawn a new thread to handle the request asynchronously
642	std::thread::spawn(move || {
643		let response = land_scheme_handler(&request);
644		responder.respond(response);
645	});
646}
647
648/// Get the appropriate Access-Control-Allow-Origin header for the current
649/// platform
650///
651/// Tauri uses different origins for custom URI schemes on different platforms:
652/// - macOS, Linux: land://localhost/
653/// - Windows: <http://land.localhost/>
654///
655/// Returns a comma-separated list of origins to support all platforms.
656fn get_cors_origins() -> &'static str {
657	// Support both macOS/Linux (land://localhost) and Windows (http://land.localhost)
658	"land://localhost, http://land.localhost, land://code.editor.land"
659}
660
661/// Initializes the scheme handler module
662///
663/// This is a placeholder function that can be used for any future
664/// initialization logic needed by the scheme handler.
665#[inline]
666pub fn Scheme() {}
667
668// ==========================================================================
669// vscode-file:// Protocol Handler
670// ==========================================================================
671
672/// MIME type detection from file extension
673fn MimeFromExtension(Path:&str) -> &'static str {
674	if Path.ends_with(".js") || Path.ends_with(".mjs") {
675		"application/javascript"
676	} else if Path.ends_with(".css") {
677		"text/css"
678	} else if Path.ends_with(".html") || Path.ends_with(".htm") {
679		"text/html"
680	} else if Path.ends_with(".json") {
681		"application/json"
682	} else if Path.ends_with(".svg") {
683		"image/svg+xml"
684	} else if Path.ends_with(".png") {
685		"image/png"
686	} else if Path.ends_with(".jpg") || Path.ends_with(".jpeg") {
687		"image/jpeg"
688	} else if Path.ends_with(".gif") {
689		"image/gif"
690	} else if Path.ends_with(".woff") {
691		"font/woff"
692	} else if Path.ends_with(".woff2") {
693		"font/woff2"
694	} else if Path.ends_with(".ttf") {
695		"font/ttf"
696	} else if Path.ends_with(".wasm") {
697		"application/wasm"
698	} else if Path.ends_with(".map") {
699		"application/json"
700	} else if Path.ends_with(".txt") || Path.ends_with(".md") {
701		"text/plain"
702	} else if Path.ends_with(".xml") {
703		"application/xml"
704	} else {
705		"application/octet-stream"
706	}
707}
708
709/// Handles `vscode-file://` custom protocol requests.
710///
711/// VS Code's Electron workbench computes asset URLs as:
712///   `vscode-file://vscode-app/{appRoot}/out/vs/workbench/...`
713///
714/// This handler maps those URLs to the embedded frontend assets
715/// served from the `frontendDist` directory (`../Sky/Target`).
716///
717/// # URL Mapping
718///
719/// ```text
720/// vscode-file://vscode-app/Static/Application/vs/workbench/foo.js
721///                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
722///                          This path maps to Sky/Target/Static/Application/vs/workbench/foo.js
723/// ```
724///
725/// The `/out/` prefix that the workbench appends is stripped if present,
726/// since our assets live at `/Static/Application/vs/` not
727/// `/Static/Application/out/vs/`.
728///
729/// # Parameters
730///
731/// - `AppHandle`: Tauri AppHandle for resolving the frontend dist path
732/// - `Request`: The incoming request
733///
734/// # Returns
735///
736/// Response with file contents and correct MIME type, or 404
737pub fn VscodeFileSchemeHandler<R:tauri::Runtime>(
738	AppHandle:&tauri::AppHandle<R>,
739	Request:&tauri::http::request::Request<Vec<u8>>,
740) -> Response<Vec<u8>> {
741	let Uri = Request.uri().to_string();
742	dev_log!("lifecycle", "[LandFix:VscodeFile] Request: {}", Uri);
743
744	// Extract path from: vscode-file://vscode-app/path/to/file
745	// The authority is "vscode-app", the path starts after it
746	let FilePath = Uri
747		.strip_prefix("vscode-file://vscode-app/")
748		.or_else(|| Uri.strip_prefix("vscode-file://vscode-app"))
749		.unwrap_or("");
750
751	// Strip /out/ prefix if present - our assets are at /Static/Application/vs/
752	// not /Static/Application/out/vs/
753	let CleanPath = if FilePath.starts_with("Static/Application//out/") {
754		FilePath.replacen("Static/Application//out/", "Static/Application/", 1)
755	} else if FilePath.starts_with("Static/Application/out/") {
756		FilePath.replacen("Static/Application/out/", "Static/Application/", 1)
757	} else {
758		FilePath.to_string()
759	};
760
761	// VS Code's nodeModulesPath = 'vs/../../node_modules' resolves ../../ from
762	// Static/Application/vs/ up to Static/. The browser canonicalizes this to
763	// Static/node_modules/ but our files live at Static/Application/node_modules/.
764	let CleanPath = if CleanPath.starts_with("Static/node_modules/") {
765		CleanPath.replacen("Static/node_modules/", "Static/Application/node_modules/", 1)
766	} else {
767		CleanPath
768	};
769
770	// P1.5 fix: DevTools fetches `*.js.map` for every bundled script it loads
771	// to render pretty stack traces. Our `Static/Application/` tree ships the
772	// JS files without their `.map` siblings (esbuild's `sourcemap:false` path)
773	// so those requests always 404. Short-circuit here with a clean
774	// `204 No Content` — Chromium treats 204 as "no map available" and moves
775	// on silently, avoiding both the noisy stderr lines and the filesystem
776	// stat round-trip per request.
777	if CleanPath.ends_with(".map") {
778		return Builder::new()
779			.status(204)
780			.header("Access-Control-Allow-Origin", "*")
781			.body(Vec::new())
782			.unwrap_or_else(|_| build_error_response(500, "Failed to build response"));
783	}
784
785	// Icon themes, grammars and other extension-contributed assets generate
786	// URIs like `vscode-file://vscode-app/Volumes/CORSAIR/.../seti.woff` after
787	// `FileAccess.uriToBrowserUri` rewrites a plain `file:///Volumes/...`
788	// extension path. The authority `vscode-app` is followed directly by the
789	// absolute filesystem path (sans leading `/`). Detect the well-known macOS /
790	// Linux absolute-path roots and serve straight from disk instead of trying
791	// to resolve them against `Sky/Target/` (where they do not exist).
792	let IsAbsoluteOSPath = [
793		"Volumes/",
794		"Users/",
795		"Library/",
796		"System/",
797		"Applications/",
798		"private/",
799		"tmp/",
800		"var/",
801		"etc/",
802		"opt/",
803		"home/",
804		"usr/",
805		"srv/",
806		"mnt/",
807		"root/",
808	]
809	.iter()
810	.any(|Prefix| CleanPath.starts_with(Prefix));
811
812	if IsAbsoluteOSPath {
813		let AbsolutePath = format!("/{}", CleanPath);
814		let FilesystemPath = std::path::Path::new(&AbsolutePath);
815		dev_log!(
816			"lifecycle",
817			"[LandFix:VscodeFile] os-abs candidate {} (exists={}, is_file={})",
818			AbsolutePath,
819			FilesystemPath.exists(),
820			FilesystemPath.is_file()
821		);
822		if FilesystemPath.exists() && FilesystemPath.is_file() {
823			match std::fs::read(FilesystemPath) {
824				Ok(Bytes) => {
825					let Mime = MimeFromExtension(&CleanPath);
826					dev_log!(
827						"lifecycle",
828						"[LandFix:VscodeFile] os-abs served {} ({}, {} bytes)",
829						AbsolutePath,
830						Mime,
831						Bytes.len()
832					);
833					return Builder::new()
834						.status(200)
835						.header("Content-Type", Mime)
836						.header("Access-Control-Allow-Origin", "*")
837						.header("Cache-Control", "public, max-age=3600")
838						.body(Bytes)
839						.unwrap_or_else(|_| build_error_response(500, "Failed to build response"));
840				},
841				Err(Error) => {
842					dev_log!(
843						"lifecycle",
844						"warn: [LandFix:VscodeFile] os-abs read failure {}: {}",
845						AbsolutePath,
846						Error
847					);
848				},
849			}
850		} else {
851			dev_log!("lifecycle", "warn: [LandFix:VscodeFile] os-abs not on disk: {}", AbsolutePath);
852		}
853	}
854
855	dev_log!("lifecycle", "[LandFix:VscodeFile] Resolved path: {}", CleanPath);
856
857	// Resolve against the frontendDist directory
858	// In production: embedded in the binary via asset_resolver
859	// In debug: fall back to filesystem read from Sky/Target
860	let AssetResult = AppHandle.asset_resolver().get(CleanPath.clone());
861
862	if let Some(Asset) = AssetResult {
863		let Mime = MimeFromExtension(&CleanPath);
864
865		dev_log!(
866			"lifecycle",
867			"[LandFix:VscodeFile] Serving (embedded) {} ({}, {} bytes)",
868			CleanPath,
869			Mime,
870			Asset.bytes.len()
871		);
872
873		return Builder::new()
874			.status(200)
875			.header("Content-Type", Mime)
876			.header("Access-Control-Allow-Origin", "*")
877			.header("Cache-Control", "public, max-age=31536000, immutable")
878			.body(Asset.bytes.to_vec())
879			.unwrap_or_else(|_| build_error_response(500, "Failed to build response"));
880	}
881
882	// Fallback: read from filesystem (dev mode where assets aren't embedded)
883	let StaticRoot = crate::IPC::WindServiceHandlers::get_static_application_root();
884
885	if let Some(Root) = StaticRoot {
886		let FilesystemPath = std::path::Path::new(&Root).join(&CleanPath);
887
888		if FilesystemPath.exists() && FilesystemPath.is_file() {
889			match std::fs::read(&FilesystemPath) {
890				Ok(Bytes) => {
891					let Mime = MimeFromExtension(&CleanPath);
892
893					dev_log!(
894						"lifecycle",
895						"[LandFix:VscodeFile] Serving (fs) {} ({}, {} bytes)",
896						CleanPath,
897						Mime,
898						Bytes.len()
899					);
900
901					return Builder::new()
902						.status(200)
903						.header("Content-Type", Mime)
904						.header("Access-Control-Allow-Origin", "*")
905						.header("Cache-Control", "public, max-age=3600")
906						.body(Bytes)
907						.unwrap_or_else(|_| build_error_response(500, "Failed to build response"));
908				},
909				Err(Error) => {
910					dev_log!(
911						"lifecycle",
912						"warn: [LandFix:VscodeFile] Failed to read {}: {}",
913						FilesystemPath.display(),
914						Error
915					);
916				},
917			}
918		}
919	}
920
921	dev_log!(
922		"lifecycle",
923		"warn: [LandFix:VscodeFile] Not found: {} (resolved: {})",
924		Uri,
925		CleanPath
926	);
927	build_error_response(404, &format!("Not Found: {}", CleanPath))
928}