mirror of
https://gitlab.com/vyla-entertainment/go.git
synced 2026-11-14 07:27:23 -05:00
585 lines
22 KiB
Rust
585 lines
22 KiB
Rust
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<u32>) {
|
|
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<PathBuf> {
|
|
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<u8> = Vec::new();
|
|
let mut offsets: Vec<usize> = 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::<serde_json::Value>(&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<PathBuf, String> {
|
|
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");
|
|
} |