use std::error::Error as StdError; use std::fs::File; use std::io::Write; use std::net::TcpStream; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, Instant}; use tauri::{ http::{header, Response}, Manager, WebviewWindow, }; static SERVER_SPAWNED: AtomicBool = AtomicBool::new(false); const RUNTIME_INTERCEPT_SCRIPT: &str = r#" (function() { const origOpen = window.open; window.open = function(url, target, features) { if (url) { window.location.href = url; return window; } return origOpen.apply(this, arguments); }; })(); "#; const NODE_PRELOAD_SCRIPT: &str = r#" const Module = require('module'); const origLoad = Module._load; const stub = new Proxy(function () {}, { get: (t, k) => (typeof k === 'symbol' || k === 'then' ? undefined : stub), apply: () => stub, construct: () => stub, }); function buildTlsShim() { class Session { constructor(opts) { this.opts = opts || {}; this.headers = this.opts.headers || {}; } async request(method, url, options) { options = options || {}; const headers = Object.assign({}, this.headers, options.headers || {}); const init = { method, headers, redirect: options.followRedirects === false ? 'manual' : 'follow', }; let body = options.body !== undefined ? options.body : options.data; if (options.json !== undefined) { body = JSON.stringify(options.json); if (!headers['content-type'] && !headers['Content-Type']) { headers['content-type'] = 'application/json'; } } else if (body !== undefined && body !== null && typeof body === 'object' && !Buffer.isBuffer(body) && !(body instanceof URLSearchParams)) { body = new URLSearchParams(body).toString(); if (!headers['content-type'] && !headers['Content-Type']) { headers['content-type'] = 'application/x-www-form-urlencoded'; } } if (body !== undefined && body !== null && method !== 'GET' && method !== 'HEAD') { init.body = body; } let ms = Number(options.timeout || this.opts.timeout || 30000); if (ms < 1000) ms = ms * 1000; init.signal = AbortSignal.timeout(ms); const res = await fetch(url, init); const text = await res.text(); const outHeaders = {}; res.headers.forEach((v, k) => { outHeaders[k] = v; }); return { id: String(Date.now()), status: res.status, ok: res.ok, success: res.ok, url: res.url, target: res.url, headers: outHeaders, cookies: {}, body: text, text: async () => text, json: async () => JSON.parse(text), }; } get(url, o) { return this.request('GET', url, o); } post(url, o) { return this.request('POST', url, o); } put(url, o) { return this.request('PUT', url, o); } patch(url, o) { return this.request('PATCH', url, o); } delete(url, o) { return this.request('DELETE', url, o); } head(url, o) { return this.request('HEAD', url, o); } options(url, o) { return this.request('OPTIONS', url, o); } async close() {} async free() {} } const ClientIdentifier = new Proxy({}, { get: (t, k) => (typeof k === 'symbol' ? undefined : String(k)) }); const mod = { Session, ClientIdentifier, initTLS: async () => {}, destroyTLS: async () => {}, }; mod.default = mod; return mod; } let tlsShim = null; Module._load = function (request) { if (request === 'node-tls-client') { if (!tlsShim) { tlsShim = buildTlsShim(); console.error('node-tls-client replaced with fetch shim'); } return tlsShim; } try { return origLoad.apply(this, arguments); } catch (e) { const msg = String(e && e.message); if (/koffi/i.test(request) || /koffi/i.test(msg)) { console.error('stubbed native module: ' + request + ' (' + msg + ')'); return stub; } throw e; } }; process.on('uncaughtException', (e) => console.error('uncaughtException', e && e.stack || e)); process.on('unhandledRejection', (e) => console.error('unhandledRejection', e && e.stack || e)); "#; fn android_log(msg: &str) { #[cfg(target_os = "android")] { let tag = std::ffi::CString::new("VylaGo").unwrap(); let msg = std::ffi::CString::new(msg.replace('\0', "?")).unwrap(); unsafe { __android_log_write(4, tag.as_ptr(), msg.as_ptr()); } } #[cfg(not(target_os = "android"))] { println!("{}", msg); } } #[cfg(target_os = "android")] #[link(name = "log")] extern "C" { fn __android_log_write(prio: i32, tag: *const std::os::raw::c_char, text: *const std::os::raw::c_char) -> i32; } #[cfg(target_os = "android")] mod node_ffi { extern "C" { pub fn _ZN4node5StartEiPPc(argc: i32, argv: *mut *mut std::os::raw::c_char) -> i32; } } fn update_ui(window: &WebviewWindow, text: &str, percent: Option) { let escaped_text = text.replace('\'', "\\'"); let mut js = format!( "var s=document.getElementById('status-message');if(s)s.textContent='{}';", escaped_text ); if let Some(pct) = percent { js.push_str(&format!( "var p=document.getElementById('progress-fill');if(p)p.style.width='{}%';", pct )); } let _ = window.eval(&js); } fn get_app_dir(app: &tauri::AppHandle) -> PathBuf { let data_dir = app.path().app_data_dir().unwrap_or_else(|_| PathBuf::from("./vyla_data")); android_log(&format!("app_data_dir: {}", data_dir.display())); data_dir } fn find_native_node_path() -> Option { if let Ok(maps) = std::fs::read_to_string("/proc/self/maps") { for line in maps.lines() { if line.contains("libvyla_go_lib.so") { if let Some(path_str) = line.split_whitespace().last() { let lib_path = PathBuf::from(path_str); if let Some(parent) = lib_path.parent() { let candidate = parent.join("libnode.so"); android_log(&format!("libnode.so sibling: {} exists={}", candidate.display(), candidate.exists())); if candidate.exists() { return Some(candidate); } } } } } } None } fn wait_for_port(port: u16, timeout: Duration) -> bool { let start = Instant::now(); let addr = format!("127.0.0.1:{}", port); while start.elapsed() < timeout { if TcpStream::connect(&addr).is_ok() { return true; } std::thread::sleep(Duration::from_millis(100)); } false } fn spawn_service_manager(base_dir: &std::path::Path) { let logs_dir = base_dir.join("logs"); let _ = std::fs::create_dir_all(&logs_dir); let _ = std::fs::write(logs_dir.join("player.log"), ""); if SERVER_SPAWNED.swap(true, Ordering::SeqCst) { return; } let player_dir = base_dir.join("player"); let server_script = player_dir.join("server.js"); if !server_script.exists() { SERVER_SPAWNED.store(false, Ordering::SeqCst); return; } let node_modules = player_dir.join("node_modules"); android_log(&format!("player_dir: {}", player_dir.display())); android_log(&format!("server.js exists: {}", server_script.exists())); android_log(&format!("node_modules exists: {}", node_modules.exists())); let lib_dir = find_native_node_path() .and_then(|p| p.parent().map(|d| d.to_path_buf())) .map(|d| d.to_string_lossy().to_string()) .unwrap_or_default(); let tmp_dir = base_dir.join("tmp"); let _ = std::fs::create_dir_all(&tmp_dir); std::env::set_var("HOME", base_dir.to_string_lossy().to_string()); std::env::set_var("TMPDIR", tmp_dir.to_string_lossy().to_string()); let preload_path = base_dir.join("preload.js"); let _ = std::fs::write(&preload_path, NODE_PRELOAD_SCRIPT); let preload_str = preload_path.to_string_lossy().to_string(); std::env::set_var("PORT", "3000"); std::env::set_var("NODE_ENV", "production"); std::env::set_var("TMDB_API_KEY", "3f6703f3e2a0b2b40fe4fbb3e1546244"); std::env::set_var("TOKEN_SECRET", "4794cbb1f15531c45df16cbb7a80447170718d9ed859486d3fa0569d7fb6477a"); std::env::set_var("NODE_PATH", node_modules.to_string_lossy().to_string()); if !lib_dir.is_empty() { std::env::set_var( "LD_LIBRARY_PATH", format!("{}:/data/data/com.vyla.go/lib:/system/lib64:/vendor/lib64", lib_dir), ); } let server_js = server_script.to_string_lossy().to_string(); let player_dir_str = player_dir.to_string_lossy().to_string(); let _ = std::thread::Builder::new().stack_size(16 * 1024 * 1024).spawn(move || { #[cfg(target_os = "android")] { let _ = std::env::set_current_dir(&player_dir_str); let parts: [&str; 4] = ["node", "--require", preload_str.as_str(), server_js.as_str()]; let mut buf: Vec = Vec::new(); let mut offsets: Vec = Vec::new(); for p in parts.iter() { offsets.push(buf.len()); buf.extend_from_slice(p.as_bytes()); buf.push(0); } buf.shrink_to_fit(); let base = buf.as_mut_ptr() as *mut std::os::raw::c_char; let mut argv: Vec<*mut std::os::raw::c_char> = offsets .iter() .map(|o| unsafe { base.add(*o) }) .collect(); argv.push(std::ptr::null_mut()); std::mem::forget(buf); android_log("calling node::Start via FFI"); let ret = unsafe { node_ffi::_ZN4node5StartEiPPc(4, argv.as_mut_ptr()) }; android_log(&format!("node::Start returned: {}", ret)); } #[cfg(not(target_os = "android"))] { android_log("non-android: skipping node_main FFI"); } }); let _ = wait_for_port(3000, Duration::from_secs(45)); } fn apply_directory_replacements(dir: &std::path::Path, find_val: &str, replace_val: &str) { if let Ok(entries) = std::fs::read_dir(dir) { for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { apply_directory_replacements(&path, find_val, replace_val); } else if path.is_file() { let ext = path.extension().and_then(|s| s.to_str()).unwrap_or(""); if matches!(ext, "html" | "js" | "json" | "css") { if let Ok(content) = std::fs::read_to_string(&path) { if content.contains(find_val) { let modified = content.replace(find_val, replace_val); let _ = std::fs::write(&path, modified); } } } } } } } fn apply_patches(base_dir: &std::path::Path) { let replacements_path = base_dir.join("config").join("replacements.json"); if let Ok(content) = std::fs::read_to_string(&replacements_path) { if let Ok(json) = serde_json::from_str::(&content) { if let Some(map) = json.as_object() { for (target_dir_name, rules) in map { let target_dir = base_dir.join(target_dir_name); if target_dir.exists() { if let Some(rule_arr) = rules.as_array() { for rule in rule_arr { if let (Some(find_val), Some(replace_val)) = ( rule.get("find").and_then(|v| v.as_str()), rule.get("replace").and_then(|v| v.as_str()), ) { apply_directory_replacements(&target_dir, find_val, replace_val); } } } } } } } } } async fn download_and_extract_payload(app: tauri::AppHandle, window: WebviewWindow) -> Result { let base_dir = get_app_dir(&app); std::fs::create_dir_all(&base_dir).map_err(|e| format!("Storage dir error: {}", e))?; let frontend_index = base_dir.join("frontend").join("index.html"); if frontend_index.exists() { apply_patches(&base_dir); spawn_service_manager(&base_dir); update_ui(&window, "Launching Vyla Go...", Some(100)); return Ok(base_dir); } update_ui(&window, "Connecting to GitLab...", Some(10)); let client = reqwest::Client::builder() .user_agent("Mozilla/5.0 (Android) VylaGo/1.0") .connect_timeout(std::time::Duration::from_secs(30)) .redirect(reqwest::redirect::Policy::limited(10)) .build() .map_err(|e| format!("Client error: {}", e))?; let releases_url = "https://gitlab.com/api/v4/projects/vyla-entertainment%2Fhome/releases"; let res = client.get(releases_url).send().await.map_err(|e| format!("Release check failed: {}", e))?; let json: serde_json::Value = res.json().await.map_err(|e| format!("JSON error: {}", e))?; let download_url = json.get(0) .and_then(|r| r.get("assets")) .and_then(|a| a.get("links")) .and_then(|l| l.as_array()) .and_then(|links| { links.iter().find(|item| { item.get("name").and_then(|n| n.as_str()) == Some("update-payload.zip") }) }) .and_then(|asset| asset.get("url")) .and_then(|u| u.as_str()) .ok_or_else(|| "Could not locate update-payload.zip link in release assets".to_string())?; update_ui(&window, "Downloading update payload...", Some(25)); android_log(&format!("Downloading from: {}", download_url)); let response = client.get(download_url).send().await.map_err(|e| format!("Download error: {} (source: {:?})", e, StdError::source(&e)))?; android_log(&format!("Response status: {}", response.status())); let total_size = response.content_length().unwrap_or(0) as usize; let temp_zip_path = base_dir.join("temp-payload.zip"); let mut file = File::create(&temp_zip_path).map_err(|e| format!("Temp file error: {}", e))?; let mut stream = response.bytes_stream(); let mut downloaded: usize = 0; use futures_util::StreamExt; while let Some(item) = stream.next().await { let chunk = item.map_err(|e| { android_log(&format!("stream error detail: {:?}", e)); format!("Stream error: {} (is_body: {}, is_timeout: {})", e, e.is_body(), e.is_timeout()) })?; file.write_all(&chunk).map_err(|e| format!("Write error: {}", e))?; downloaded += chunk.len(); let percent = if total_size > 0 { 10 + ((downloaded as f64 / total_size as f64) * 75.0) as u32 } else { 35 }; update_ui( &window, &format!("Downloading package ({}%)...", percent), Some(percent), ); } drop(file); update_ui(&window, "Unpacking archive resources...", Some(90)); let extract_dir = base_dir.clone(); let zip_path_clone = temp_zip_path.clone(); tauri::async_runtime::spawn_blocking(move || -> Result<(), String> { let zip_file = File::open(&zip_path_clone).map_err(|e| format!("Open zip error: {}", e))?; let mut archive = zip::ZipArchive::new(zip_file).map_err(|e| format!("Zip parse error: {}", e))?; archive.extract(&extract_dir).map_err(|e| format!("Extraction error: {}", e))?; let _ = std::fs::remove_file(zip_path_clone); apply_patches(&extract_dir); Ok(()) }) .await .map_err(|e| e.to_string())??; spawn_service_manager(&base_dir); update_ui(&window, "Launching Vyla Go...", Some(100)); Ok(base_dir) } const ANDROID_VIDEO_FIX_SCRIPT: &str = r#" (function() { function suppressNativeOverlay() { const styleId = '__vyla_native_video_fix'; if (!document.getElementById(styleId)) { const s = document.createElement('style'); s.id = styleId; s.textContent = ` video::-webkit-media-controls, video::-webkit-media-controls-enclosure, video::-webkit-media-controls-panel, video::-webkit-media-controls-overlay-play-button, video::-webkit-media-controls-start-playback-button { display: none !important; -webkit-appearance: none !important; opacity: 0 !important; pointer-events: none !important; } `; (document.head || document.documentElement).appendChild(s); } document.querySelectorAll('video').forEach(v => { v.setAttribute('playsinline', ''); v.setAttribute('webkit-playsinline', ''); v.removeAttribute('poster'); }); } suppressNativeOverlay(); new MutationObserver(suppressNativeOverlay).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['poster'] }); })(); "#; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_opener::init()) .register_asynchronous_uri_scheme_protocol("vyla", |ctx, request, responder| { let app_handle = ctx.app_handle().clone(); let base_dir = get_app_dir(&app_handle); let uri_path = request.uri().path().to_string(); let clean = uri_path.trim_start_matches('/'); let candidates = [ base_dir.join(clean), base_dir.join("frontend").join(clean), base_dir.join("frontend/index.html"), ]; let mut final_path = None; for candidate in &candidates { if candidate.exists() && candidate.is_file() { final_path = Some(candidate.clone()); break; } } if final_path.is_none() && (clean.is_empty() || clean == "/") { final_path = Some(base_dir.join("frontend/index.html")); } if let Some(path) = final_path { if let Ok(data) = std::fs::read(&path) { let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); let mime = match ext { "html" => "text/html; charset=utf-8", "js" => "application/javascript; charset=utf-8", "css" => "text/css; charset=utf-8", "png" => "image/png", "jpg" | "jpeg" => "image/jpeg", "svg" => "image/svg+xml", "json" => "application/json", _ => "application/octet-stream", }; let response = Response::builder() .header(header::CONTENT_TYPE, mime) .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*") .header(header::ACCESS_CONTROL_ALLOW_METHODS, "GET, POST, OPTIONS") .header(header::ACCESS_CONTROL_ALLOW_HEADERS, "*") .body(data) .unwrap(); responder.respond(response); return; } } let response = Response::builder() .status(404) .body(b"Not Found".to_vec()) .unwrap(); responder.respond(response); }) .setup(|app| { let handle = app.handle().clone(); if let Some(window) = handle.get_webview_window("main") { let _ = window.eval(ANDROID_VIDEO_FIX_SCRIPT); } tauri::async_runtime::spawn(async move { std::thread::sleep(std::time::Duration::from_millis(400)); if let Some(window) = handle.get_webview_window("main") { update_ui(&window, "Initializing Vyla Go engine...", Some(5)); match download_and_extract_payload(handle.clone(), window.clone()).await { Ok(_) => { std::thread::sleep(std::time::Duration::from_millis(400)); let _ = window.eval(&format!( "{}; window.location.replace('http://vyla.localhost/frontend/index.html');", ANDROID_VIDEO_FIX_SCRIPT )); } Err(err) => { update_ui( &window, &format!("Error: {}", err), Some(0), ); } } } }); Ok(()) }) .run(tauri::generate_context!()) .expect("error while running tauri application"); }