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	// P1.5 fix: DevTools fetches `*.js.map` for every bundled script it loads
840	// to render pretty stack traces. Our `Static/Application/` tree ships the
841	// JS files without their `.map` siblings (esbuild's `sourcemap:false` path)
842	// so those requests always 404. Short-circuit here with a clean
843	// `204 No Content` - Chromium treats 204 as "no map available" and moves
844	// on silently, avoiding both the noisy stderr lines and the filesystem
845	// stat round-trip per request.
846	if CleanPath.ends_with(".map") {
847		return Builder::new()
848			.status(204)
849			.header("Access-Control-Allow-Origin", "*")
850			.body(Vec::new())
851			.unwrap_or_else(|_| build_error_response(500, "Failed to build response"));
852	}
853
854	// CSS-as-JS shim: when a `.css` URL is requested through
855	// `vscode-file://` (which happens for any unstripped raw `import
856	// "./foo.css"` that VS Code's bundle still contains after
857	// `workbench.js` switches `_VSCODE_FILE_ROOT` to the custom
858	// scheme), the browser would refuse the response with
859	// `'text/css' is not a valid JavaScript MIME type`. Service
860	// Workers can't intercept custom-scheme requests, so we inline
861	// the same JS shim the Worker SW emits on the localhost path:
862	// invoke `_LOAD_CSS_WORKER` against the localhost-form path and
863	// export an empty default. The SW + `<link>` fast-path then
864	// loads the actual CSS bytes from `/Static/Application/...`.
865	if CleanPath.ends_with(".css") {
866		let LocalPath = format!("/Static/Application/{}", CleanPath.trim_start_matches("Static/Application/"));
867		let Body = format!("globalThis._LOAD_CSS_WORKER?.({:?}); export default {{}};", LocalPath);
868		dev_log!(
869			"scheme-assets",
870			"[LandFix:VscodeFile] css-shim {} -> _LOAD_CSS_WORKER({})",
871			CleanPath,
872			LocalPath
873		);
874		return Builder::new()
875			.status(200)
876			.header("Content-Type", "application/javascript; charset=utf-8")
877			.header("Access-Control-Allow-Origin", "*")
878			.header("Cache-Control", "public, max-age=31536000, immutable")
879			.body(Body.into_bytes())
880			.unwrap_or_else(|_| build_error_response(500, "Failed to build response"));
881	}
882
883	// Icon themes, grammars and other extension-contributed assets generate
884	// URIs like `vscode-file://vscode-app/Volumes/CORSAIR/.../seti.woff` after
885	// `FileAccess.uriToBrowserUri` rewrites a plain `file:///Volumes/...`
886	// extension path. The authority `vscode-app` is followed directly by the
887	// absolute filesystem path (sans leading `/`). Detect the well-known macOS /
888	// Linux absolute-path roots and serve straight from disk instead of trying
889	// to resolve them against `Sky/Target/` (where they do not exist).
890	let IsAbsoluteOSPath = [
891		"Volumes/",
892		"Users/",
893		"Library/",
894		"System/",
895		"Applications/",
896		"private/",
897		"tmp/",
898		"var/",
899		"etc/",
900		"opt/",
901		"home/",
902		"usr/",
903		"srv/",
904		"mnt/",
905		"root/",
906	]
907	.iter()
908	.any(|Prefix| CleanPath.starts_with(Prefix));
909
910	if IsAbsoluteOSPath {
911		let AbsolutePath = format!("/{}", CleanPath);
912		let FilesystemPath = std::path::Path::new(&AbsolutePath);
913		dev_log!(
914			"scheme-assets",
915			"[LandFix:VscodeFile] os-abs candidate {} (exists={}, is_file={})",
916			AbsolutePath,
917			FilesystemPath.exists(),
918			FilesystemPath.is_file()
919		);
920		if FilesystemPath.exists() && FilesystemPath.is_file() {
921			// LAND-PATCH B7.P01: route through the mmap cache. First
922			// hit on a path mmaps the file; subsequent hits are
923			// wait-free DashMap reads. Brotli sibling (`<file>.br`)
924			// is auto-discovered and served when the request offers
925			// `Accept-Encoding: br`.
926			match crate::Cache::AssetMemoryMap::LoadOrInsert::Fn(FilesystemPath) {
927				Ok(Entry) => {
928					let AcceptsBrotli = Request
929						.headers()
930						.get("accept-encoding")
931						.and_then(|V| V.to_str().ok())
932						.map(|S| S.contains("br"))
933						.unwrap_or(false);
934					let (Body, Encoding):(Vec<u8>, Option<&str>) = if AcceptsBrotli {
935						match Entry.AsBrotliSlice() {
936							Some(Slice) => (Slice.to_vec(), Some("br")),
937							None => (Entry.AsSlice().to_vec(), None),
938						}
939					} else {
940						(Entry.AsSlice().to_vec(), None)
941					};
942					dev_log!(
943						"scheme-assets",
944						"[LandFix:VscodeFile] os-abs served {} ({}, {} bytes, encoding={:?})",
945						AbsolutePath,
946						Entry.Mime,
947						Body.len(),
948						Encoding
949					);
950					let mut B = Builder::new()
951						.status(200)
952						.header("Content-Type", Entry.Mime)
953						.header("Access-Control-Allow-Origin", "*")
954						.header("Cache-Control", "public, max-age=3600");
955					if let Some(Enc) = Encoding {
956						B = B.header("Content-Encoding", Enc);
957					}
958					return B
959						.body(Body)
960						.unwrap_or_else(|_| build_error_response(500, "Failed to build response"));
961				},
962				Err(Error) => {
963					dev_log!(
964						"lifecycle",
965						"warn: [LandFix:VscodeFile] os-abs mmap failure {}: {}",
966						AbsolutePath,
967						Error
968					);
969				},
970			}
971		} else {
972			dev_log!("lifecycle", "warn: [LandFix:VscodeFile] os-abs not on disk: {}", AbsolutePath);
973		}
974	}
975
976	dev_log!("lifecycle", "[LandFix:VscodeFile] Resolved path: {}", CleanPath);
977
978	// Resolve against the frontendDist directory
979	// In production: embedded in the binary via asset_resolver
980	// In debug: fall back to filesystem read from Sky/Target
981	let AssetResult = AppHandle.asset_resolver().get(CleanPath.clone());
982
983	if let Some(Asset) = AssetResult {
984		let Mime = MimeFromExtension(&CleanPath);
985
986		dev_log!(
987			"lifecycle",
988			"[LandFix:VscodeFile] Serving (embedded) {} ({}, {} bytes)",
989			CleanPath,
990			Mime,
991			Asset.bytes.len()
992		);
993		dev_log!(
994			"scheme-assets",
995			"[SchemeAssets] serve source=embedded path={} mime={} bytes={}",
996			CleanPath,
997			Mime,
998			Asset.bytes.len()
999		);
1000
1001		return Builder::new()
1002			.status(200)
1003			.header("Content-Type", Mime)
1004			.header("Access-Control-Allow-Origin", "*")
1005			.header("Cache-Control", "public, max-age=31536000, immutable")
1006			.body(Asset.bytes.to_vec())
1007			.unwrap_or_else(|_| build_error_response(500, "Failed to build response"));
1008	}
1009
1010	// Fallback: read from filesystem (dev mode where assets aren't embedded)
1011	let StaticRoot = crate::IPC::WindServiceHandlers::Utilities::ApplicationRoot::get_static_application_root();
1012
1013	if let Some(Root) = StaticRoot {
1014		let FilesystemPath = std::path::Path::new(&Root).join(&CleanPath);
1015
1016		if FilesystemPath.exists() && FilesystemPath.is_file() {
1017			// LAND-PATCH B7.P01: mmap-cache the StaticRoot fallback
1018			// path so dev-mode workbench reloads pay the syscall
1019			// once per asset for the entire session.
1020			match crate::Cache::AssetMemoryMap::LoadOrInsert::Fn(&FilesystemPath) {
1021				Ok(Entry) => {
1022					let AcceptsBrotli = Request
1023						.headers()
1024						.get("accept-encoding")
1025						.and_then(|V| V.to_str().ok())
1026						.map(|S| S.contains("br"))
1027						.unwrap_or(false);
1028					let (Body, Encoding):(Vec<u8>, Option<&str>) = if AcceptsBrotli {
1029						match Entry.AsBrotliSlice() {
1030							Some(Slice) => (Slice.to_vec(), Some("br")),
1031							None => (Entry.AsSlice().to_vec(), None),
1032						}
1033					} else {
1034						(Entry.AsSlice().to_vec(), None)
1035					};
1036					dev_log!(
1037						"lifecycle",
1038						"[LandFix:VscodeFile] Serving (fs-mmap) {} ({}, {} bytes, encoding={:?})",
1039						CleanPath,
1040						Entry.Mime,
1041						Body.len(),
1042						Encoding
1043					);
1044					let mut B = Builder::new()
1045						.status(200)
1046						.header("Content-Type", Entry.Mime)
1047						.header("Access-Control-Allow-Origin", "*")
1048						.header("Cache-Control", "public, max-age=3600");
1049					if let Some(Enc) = Encoding {
1050						B = B.header("Content-Encoding", Enc);
1051					}
1052					return B
1053						.body(Body)
1054						.unwrap_or_else(|_| build_error_response(500, "Failed to build response"));
1055				},
1056				Err(Error) => {
1057					dev_log!(
1058						"lifecycle",
1059						"warn: [LandFix:VscodeFile] Failed to read {}: {}",
1060						FilesystemPath.display(),
1061						Error
1062					);
1063				},
1064			}
1065		}
1066	}
1067
1068	dev_log!(
1069		"lifecycle",
1070		"warn: [LandFix:VscodeFile] Not found: {} (resolved: {})",
1071		Uri,
1072		CleanPath
1073	);
1074	build_error_response(404, &format!("Not Found: {}", CleanPath))
1075}
1076
1077/// Custom URI scheme handler for `vscode-webview://` requests.
1078///
1079/// VS Code's `WebviewElement` (used by every extension webview - Roo
1080/// Code, Claude, GitLens, custom-editor providers) wraps the inner
1081/// extension HTML in an `<iframe>` whose `src` is
1082/// `vscode-webview://<authority>/index.html?...`. The `<authority>` is
1083/// a per-instance random base32 string. The authority is irrelevant to
1084/// the bytes served - all that matters is the path component, which
1085/// always resolves under
1086/// `vs/workbench/contrib/webview/browser/pre/`.
1087///
1088/// In stock Electron VS Code, `app.protocol.registerStreamProtocol(
1089/// 'vscode-webview', ...)` serves this directory. Under Tauri 2.x +
1090/// WKWebView, `register_asynchronous_uri_scheme_protocol("vscode-webview",
1091/// ...)` installs an equivalent `WKURLSchemeHandler`. Without this handler,
1092/// every extension that uses `webviewView` / `WebviewPanel` /
1093/// `CustomEditor` lands the inner iframe at a `vscode-webview://...`
1094/// URL the WKWebView can't resolve, the iframe stays blank, and the
1095/// extension surface is dead.
1096///
1097/// Three resources live under `pre/`:
1098///   - `index.html`        - the webview shell that bridges `postMessage`
1099///     between workbench host and inner extension HTML
1100///   - `service-worker.js` - registered by `index.html` to intercept
1101///     `vscode-webview-resource` requests for extension-shipped assets
1102///   - `fake.html`         - sandbox stub used as a placeholder before
1103///     extension HTML arrives via postMessage
1104///
1105/// Anything else (querystrings, extra path segments, GUID-like
1106/// authorities) is silently dropped; the extension's actual content
1107/// gets piped in via the `swMessage` channel after `index.html` boots,
1108/// not through this scheme handler.
1109///
1110/// # Parameters
1111///
1112/// - `AppHandle`: Tauri AppHandle for resolving the embedded asset resolver and
1113///   the dev-mode `Static/Application/` filesystem fallback (same chain as
1114///   `VscodeFileSchemeHandler`).
1115/// - `Request`: The incoming request - typically a `GET` for one of the three
1116///   pre-baked files.
1117///
1118/// # Returns
1119///
1120/// A `Response<Vec<u8>>` carrying:
1121///   - `200 OK` with the file bytes + correct MIME (`text/html` /
1122///     `application/javascript`) when found, or
1123///   - `404 Not Found` when the resolved path falls outside the `pre/`
1124///     directory or the asset isn't shipped.
1125///
1126/// CORS headers are permissive (`*`) to match the workbench host's
1127/// `vscode-webview-resource:` traffic, which round-trips through the
1128/// service worker registered by `index.html`.
1129pub fn VscodeWebviewSchemeHandler<R:tauri::Runtime>(
1130	AppHandle:&tauri::AppHandle<R>,
1131	Request:&tauri::http::request::Request<Vec<u8>>,
1132) -> Response<Vec<u8>> {
1133	let Uri = Request.uri().to_string();
1134	dev_log!("scheme-assets", "[LandFix:VscodeWebview] Request: {}", Uri);
1135
1136	// `vscode-webview://<authority>/<path>?<query>`. We only care about
1137	// `<path>` - authority is per-instance noise, querystring is the
1138	// `id`/`parentId`/`extensionId`/etc that `index.html` reads via
1139	// `URLSearchParams` (we don't touch it).
1140	let After = match Uri.strip_prefix("vscode-webview://") {
1141		Some(Rest) => Rest,
1142		None => {
1143			return build_error_response(400, "vscode-webview scheme without prefix");
1144		},
1145	};
1146	let PathStart = match After.find('/') {
1147		Some(Index) => Index + 1,
1148		None => {
1149			return build_error_response(400, "vscode-webview URI missing path component");
1150		},
1151	};
1152	let PathPlusQuery = &After[PathStart..];
1153	// Trim the querystring + fragment - filesystem doesn't care.
1154	let CleanPath:&str = PathPlusQuery
1155		.split_once(|C:char| C == '?' || C == '#')
1156		.map(|(Path, _)| Path)
1157		.unwrap_or(PathPlusQuery);
1158	// Reject path-traversal attempts. The webview shell is a static
1159	// three-file directory; anything containing `..` or hitting
1160	// outside `pre/` is hostile or a bug.
1161	if CleanPath.is_empty() || CleanPath.contains("..") {
1162		return build_error_response(404, "vscode-webview path empty or traversal");
1163	}
1164
1165	let ResolvedPath = format!("Static/Application/vs/workbench/contrib/webview/browser/pre/{}", CleanPath);
1166	dev_log!(
1167		"scheme-assets",
1168		"[LandFix:VscodeWebview] resolve {} -> {}",
1169		CleanPath,
1170		ResolvedPath
1171	);
1172
1173	// Try the embedded asset resolver first (release / packaged builds
1174	// where `Sky/Target/Static/Application/` is bundled into Mountain's
1175	// binary). Falls through to the filesystem fallback below for
1176	// debug-electron-bundled, where assets ship next to Mountain.
1177	if let Some(Asset) = AppHandle.asset_resolver().get(ResolvedPath.clone()) {
1178		let Mime = MimeFromExtension(&ResolvedPath);
1179		dev_log!(
1180			"scheme-assets",
1181			"[LandFix:VscodeWebview] serve embedded {} ({}, {} bytes)",
1182			ResolvedPath,
1183			Mime,
1184			Asset.bytes.len()
1185		);
1186		return Builder::new()
1187			.status(200)
1188			.header("Content-Type", Mime)
1189			.header("Access-Control-Allow-Origin", "*")
1190			.header("Cross-Origin-Embedder-Policy", "require-corp")
1191			.header("Cross-Origin-Resource-Policy", "cross-origin")
1192			.header("Cache-Control", "no-cache")
1193			.body(Asset.bytes.to_vec())
1194			.unwrap_or_else(|_| build_error_response(500, "Failed to build response"));
1195	}
1196
1197	// Filesystem fallback for dev mode. `ApplicationRoot` is set by
1198	// `Binary/Main/AppLifecycle.rs` to the resolved `Sky/Target/`
1199	// directory at startup so we can read the same `pre/` files the
1200	// embedded resolver would have served.
1201	let StaticRoot = crate::IPC::WindServiceHandlers::Utilities::ApplicationRoot::get_static_application_root();
1202	if let Some(Root) = StaticRoot {
1203		let FilesystemPath = std::path::Path::new(&Root).join(&ResolvedPath);
1204		if FilesystemPath.exists() && FilesystemPath.is_file() {
1205			match std::fs::read(&FilesystemPath) {
1206				Ok(Bytes) => {
1207					let Mime = MimeFromExtension(&ResolvedPath);
1208					dev_log!(
1209						"scheme-assets",
1210						"[LandFix:VscodeWebview] serve filesystem {} ({}, {} bytes)",
1211						FilesystemPath.display(),
1212						Mime,
1213						Bytes.len()
1214					);
1215					return Builder::new()
1216						.status(200)
1217						.header("Content-Type", Mime)
1218						.header("Access-Control-Allow-Origin", "*")
1219						.header("Cross-Origin-Embedder-Policy", "require-corp")
1220						.header("Cross-Origin-Resource-Policy", "cross-origin")
1221						.header("Cache-Control", "no-cache")
1222						.body(Bytes)
1223						.unwrap_or_else(|_| build_error_response(500, "Failed to build response"));
1224				},
1225				Err(Error) => {
1226					dev_log!(
1227						"lifecycle",
1228						"warn: [LandFix:VscodeWebview] Failed to read {}: {}",
1229						FilesystemPath.display(),
1230						Error
1231					);
1232				},
1233			}
1234		}
1235	}
1236
1237	dev_log!(
1238		"lifecycle",
1239		"warn: [LandFix:VscodeWebview] Not found: {} (resolved: {})",
1240		Uri,
1241		ResolvedPath
1242	);
1243	build_error_response(404, &format!("Not Found: {}", ResolvedPath))
1244}