9 Commits

9 changed files with 295 additions and 65 deletions

View File

@@ -65,3 +65,8 @@ Force-stop and clear app data:
& "$env:LOCALAPPDATA\Android\Sdk\platform-tools\adb.exe" shell am force-stop com.vyla.go & "$env:LOCALAPPDATA\Android\Sdk\platform-tools\adb.exe" shell am force-stop com.vyla.go
& "$env:LOCALAPPDATA\Android\Sdk\platform-tools\adb.exe" shell pm clear com.vyla.go & "$env:LOCALAPPDATA\Android\Sdk\platform-tools\adb.exe" shell pm clear com.vyla.go
``` ```
Fix emulator DNS timeouts (if emulator gets network timeouts connecting to GitLab):
```powershell
& "$env:LOCALAPPDATA\Android\Sdk\platform-tools\adb.exe" shell settings put global private_dns_mode off
```

View File

@@ -1,6 +1,6 @@
{ {
"name": "vyla-go", "name": "vyla-go",
"version": "1.0.10", "version": "1.0.15",
"description": "Vyla Go Mobile Launcher", "description": "Vyla Go Mobile Launcher",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {

1
src-tauri/Cargo.lock generated
View File

@@ -4593,6 +4593,7 @@ dependencies = [
"tauri-plugin-opener", "tauri-plugin-opener",
"tauri-plugin-process", "tauri-plugin-process",
"tauri-plugin-shell", "tauri-plugin-shell",
"tokio",
"urlencoding", "urlencoding",
"zip", "zip",
] ]

View File

@@ -23,3 +23,4 @@ reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"
zip = { version = "2.2", default-features = false, features = ["deflate"] } zip = { version = "2.2", default-features = false, features = ["deflate"] }
futures-util = "0.3" futures-util = "0.3"
urlencoding = "2.1" urlencoding = "2.1"
tokio = { version = "1", features = ["time"] }

View File

@@ -1,36 +1,73 @@
package com.vyla.go package com.vyla.go
import android.graphics.Bitmap import android.graphics.Color
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.WindowManager
import android.view.ViewGroup import android.webkit.JavascriptInterface
import android.webkit.WebChromeClient
import android.webkit.WebView import android.webkit.WebView
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
class MainActivity : TauriActivity() { class MainActivity : TauriActivity() {
override fun onCreate(savedInstanceState: Bundle?) { private var webView: WebView? = null
super.onCreate(savedInstanceState) private var insetTop = 0f
private var insetRight = 0f
private var insetBottom = 0f
private var insetLeft = 0f
val root = findViewById<ViewGroup>(android.R.id.content) private inner class InsetBridge {
root.viewTreeObserver.addOnGlobalLayoutListener { @JavascriptInterface
findAndHookWebView(root) fun get(): String =
} "{\"top\":$insetTop,\"right\":$insetRight,\"bottom\":$insetBottom,\"left\":$insetLeft}"
} }
private fun findAndHookWebView(view: View) { override fun onCreate(savedInstanceState: Bundle?) {
if (view is WebView) { WindowCompat.setDecorFitsSystemWindows(window, false)
view.webChromeClient = object : WebChromeClient() { window.statusBarColor = Color.TRANSPARENT
override fun getDefaultVideoPoster(): Bitmap { window.navigationBarColor = Color.TRANSPARENT
return Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
} window.isNavigationBarContrastEnforced = false
} window.isStatusBarContrastEnforced = false
return
} }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
window.attributes.layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
}
super.onCreate(savedInstanceState)
WindowInsetsControllerCompat(window, window.decorView).apply {
isAppearanceLightStatusBars = false
isAppearanceLightNavigationBars = false
}
ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { _, insets ->
val bars = insets.getInsets(
WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout()
)
val d = resources.displayMetrics.density
insetTop = bars.top / d
insetRight = bars.right / d
insetBottom = bars.bottom / d
insetLeft = bars.left / d
pushInsets()
insets
}
ViewCompat.requestApplyInsets(window.decorView)
}
if (view is ViewGroup) { override fun onWebViewCreate(webView: WebView) {
for (i in 0 until view.childCount) { this.webView = webView
findAndHookWebView(view.getChildAt(i)) webView.addJavascriptInterface(InsetBridge(), "VylaInsets")
} }
private fun pushInsets() {
val wv = webView ?: return
wv.post {
wv.evaluateJavascript(
"window.__vylaApplyInsets&&window.__vylaApplyInsets($insetTop,$insetRight,$insetBottom,$insetLeft)",
null
)
} }
} }
} }

View File

@@ -132,6 +132,28 @@ process.on('uncaughtException', (e) => console.error('uncaughtException', e && e
process.on('unhandledRejection', (e) => console.error('unhandledRejection', e && e.stack || e)); process.on('unhandledRejection', (e) => console.error('unhandledRejection', e && e.stack || e));
"#; "#;
const LAUNCHER_SCRIPT: &str = r#"
const path = require('path');
const { pathToFileURL } = require('url');
async function boot(file, port) {
process.env.PORT = String(port);
try {
await import(pathToFileURL(file).href);
} catch (e) {
console.error('failed to start ' + file, e && e.stack || e);
} finally {
process.env.PORT = '3000';
}
}
(async () => {
const root = process.env.VYLA_ROOT;
await boot(path.join(root, 'player', 'server.js'), 3000);
await boot(path.join(root, 'frontend', 'server.js'), 3001);
})();
"#;
fn android_log(msg: &str) { fn android_log(msg: &str) {
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
{ {
@@ -261,7 +283,10 @@ fn spawn_service_manager(base_dir: &std::path::Path) {
); );
} }
let server_js = server_script.to_string_lossy().to_string(); let launcher_path = base_dir.join("launcher.js");
let _ = std::fs::write(&launcher_path, LAUNCHER_SCRIPT);
std::env::set_var("VYLA_ROOT", base_dir.to_string_lossy().to_string());
let server_js = launcher_path.to_string_lossy().to_string();
let player_dir_str = player_dir.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 || { let _ = std::thread::Builder::new().stack_size(16 * 1024 * 1024).spawn(move || {
@@ -300,6 +325,7 @@ fn spawn_service_manager(base_dir: &std::path::Path) {
}); });
let _ = wait_for_port(3000, Duration::from_secs(45)); let _ = wait_for_port(3000, Duration::from_secs(45));
let _ = wait_for_port(3001, Duration::from_secs(45));
} }
fn apply_directory_replacements(dir: &std::path::Path, find_val: &str, replace_val: &str) { fn apply_directory_replacements(dir: &std::path::Path, find_val: &str, replace_val: &str) {
@@ -352,44 +378,160 @@ async fn download_and_extract_payload(app: tauri::AppHandle, window: WebviewWind
let base_dir = get_app_dir(&app); let base_dir = get_app_dir(&app);
std::fs::create_dir_all(&base_dir).map_err(|e| format!("Storage dir error: {}", e))?; std::fs::create_dir_all(&base_dir).map_err(|e| format!("Storage dir error: {}", e))?;
let marker = base_dir.join(".installed");
let frontend_index = base_dir.join("frontend").join("index.html"); let frontend_index = base_dir.join("frontend").join("index.html");
if frontend_index.exists() { let server_script = base_dir.join("player").join("server.js");
let frontend_server = base_dir.join("frontend").join("server.js");
if marker.exists() && frontend_index.exists() && server_script.exists() && frontend_server.exists() {
android_log("App already installed, launching...");
apply_patches(&base_dir); apply_patches(&base_dir);
spawn_service_manager(&base_dir); spawn_service_manager(&base_dir);
update_ui(&window, "Launching Vyla Go...", Some(100)); update_ui(&window, "Launching Vyla Go...", Some(100));
return Ok(base_dir); return Ok(base_dir);
} }
update_ui(&window, "Connecting to GitLab...", Some(10)); let local_payload_candidates = [
base_dir.join("update-payload.zip"),
base_dir.join("temp-payload.zip"),
PathBuf::from("/sdcard/Download/update-payload.zip"),
PathBuf::from("/data/local/tmp/update-payload.zip"),
];
for candidate in &local_payload_candidates {
if candidate.exists() {
if let Ok(meta) = std::fs::metadata(candidate) {
if meta.len() > 10_000_000 {
if let Ok(f) = File::open(candidate) {
if let Ok(archive) = zip::ZipArchive::new(f) {
if archive.len() > 0 {
android_log(&format!("Found valid local payload: {}", candidate.display()));
update_ui(&window, "Unpacking local package...", Some(80));
let extract_dir = base_dir.clone();
let candidate_path = candidate.clone();
let extract_ok = tauri::async_runtime::spawn_blocking(move || -> Result<(), String> {
let zip_file = File::open(&candidate_path).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::write(extract_dir.join(".installed"), "ok");
apply_patches(&extract_dir);
Ok(())
})
.await
.map_err(|e| e.to_string())?;
if extract_ok.is_ok() {
spawn_service_manager(&base_dir);
update_ui(&window, "Launching Vyla Go...", Some(100));
return Ok(base_dir);
}
}
}
}
}
}
}
}
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.user_agent("Mozilla/5.0 (Android) VylaGo/1.0") .user_agent("Mozilla/5.0 (Android) VylaGo/1.0")
.connect_timeout(std::time::Duration::from_secs(30)) .connect_timeout(std::time::Duration::from_secs(15))
.timeout(std::time::Duration::from_secs(180))
.redirect(reqwest::redirect::Policy::limited(10)) .redirect(reqwest::redirect::Policy::limited(10))
.local_address(std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED))
.http1_only()
.pool_max_idle_per_host(0)
.build() .build()
.map_err(|e| format!("Client error: {}", e))?; .map_err(|e| format!("Client error: {}", e))?;
let releases_url = "https://gitlab.com/api/v4/projects/vyla-entertainment%2Fhome/releases"; let release_endpoints = [
let res = client.get(releases_url).send().await.map_err(|e| format!("Release check failed: {}", e))?; "https://gitlab.com/api/v4/projects/vyla-entertainment%2Fgo/releases",
let json: serde_json::Value = res.json().await.map_err(|e| format!("JSON error: {}", e))?; "https://gitlab.com/api/v4/projects/vyla-entertainment%2Fhome/releases",
];
let download_url = json.get(0) let mut download_url: Option<String> = None;
.and_then(|r| r.get("assets")) let mut last_error = String::new();
.and_then(|a| a.get("links"))
.and_then(|l| l.as_array()) const MAX_RETRIES: u32 = 4;
.and_then(|links| { for attempt in 1..=MAX_RETRIES {
links.iter().find(|item| { let msg = if attempt == 1 {
item.get("name").and_then(|n| n.as_str()) == Some("update-payload.zip") "Connecting to GitLab...".to_string()
}) } else {
}) format!("Connecting to GitLab (attempt {}/{})...", attempt, MAX_RETRIES)
.and_then(|asset| asset.get("url")) };
.and_then(|u| u.as_str()) update_ui(&window, &msg, Some(10 + (attempt - 1) * 3));
.ok_or_else(|| "Could not locate update-payload.zip link in release assets".to_string())?;
for endpoint in &release_endpoints {
android_log(&format!("Checking endpoint: {} (attempt {})", endpoint, attempt));
match client.get(*endpoint).send().await {
Ok(res) => {
if res.status().is_success() {
if let Ok(json) = res.json::<serde_json::Value>().await {
if let Some(releases) = json.as_array() {
for rel in releases {
if let Some(links) = rel.get("assets").and_then(|a| a.get("links")).and_then(|l| l.as_array()) {
for item in links {
if item.get("name").and_then(|n| n.as_str()) == Some("update-payload.zip") {
if let Some(url) = item.get("url").and_then(|u| u.as_str()) {
download_url = Some(url.to_string());
break;
}
}
}
}
if download_url.is_some() {
break;
}
}
}
}
} else {
last_error = format!("HTTP {}", res.status());
}
}
Err(e) => {
let mut detail = format!("{}", e);
let mut src = StdError::source(&e);
while let Some(s) = src {
detail.push_str(&format!(" <- {}", s));
src = s.source();
}
android_log(&format!("Release check failed for {}: {}", endpoint, detail));
last_error = detail;
}
}
if download_url.is_some() {
break;
}
}
if download_url.is_some() {
break;
}
if attempt < MAX_RETRIES {
let backoff = attempt * 2;
update_ui(
&window,
&format!("Connection pending, retrying in {}s...", backoff),
Some(10 + attempt * 3),
);
tokio::time::sleep(std::time::Duration::from_secs(backoff as u64)).await;
}
}
let download_url = download_url.ok_or_else(|| {
format!(
"Could not connect to release server ({})",
if last_error.is_empty() { "timeout" } else { &last_error }
)
})?;
update_ui(&window, "Downloading update payload...", Some(25)); update_ui(&window, "Downloading update payload...", Some(25));
android_log(&format!("Downloading from: {}", download_url)); 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 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 total_size = response.content_length().unwrap_or(0) as usize;
let temp_zip_path = base_dir.join("temp-payload.zip"); let temp_zip_path = base_dir.join("temp-payload.zip");
@@ -408,7 +550,7 @@ async fn download_and_extract_payload(app: tauri::AppHandle, window: WebviewWind
downloaded += chunk.len(); downloaded += chunk.len();
let percent = if total_size > 0 { let percent = if total_size > 0 {
10 + ((downloaded as f64 / total_size as f64) * 75.0) as u32 25 + ((downloaded as f64 / total_size as f64) * 65.0) as u32
} else { } else {
35 35
}; };
@@ -431,7 +573,8 @@ async fn download_and_extract_payload(app: tauri::AppHandle, window: WebviewWind
let zip_file = File::open(&zip_path_clone).map_err(|e| format!("Open zip error: {}", e))?; 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))?; 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))?; archive.extract(&extract_dir).map_err(|e| format!("Extraction error: {}", e))?;
let _ = std::fs::remove_file(zip_path_clone); let _ = std::fs::remove_file(&zip_path_clone);
let _ = std::fs::write(extract_dir.join(".installed"), "ok");
apply_patches(&extract_dir); apply_patches(&extract_dir);
Ok(()) Ok(())
}) })
@@ -483,9 +626,37 @@ const ANDROID_VIDEO_FIX_SCRIPT: &str = r#"
})(); })();
"#; "#;
const EDGE_TO_EDGE_SCRIPT: &str = r#"
(function() {
var m = document.querySelector('meta[name=viewport]');
if (m && m.content.indexOf('viewport-fit') === -1) {
m.content += ', viewport-fit=cover';
}
function apply(t, r, b, l) {
var s = document.documentElement.style;
s.setProperty('--safe-top', t + 'px');
s.setProperty('--safe-right', r + 'px');
s.setProperty('--safe-bottom', b + 'px');
s.setProperty('--safe-left', l + 'px');
}
window.__vylaApplyInsets = apply;
try {
if (window.VylaInsets) {
var v = JSON.parse(window.VylaInsets.get());
apply(v.top, v.right, v.bottom, v.left);
}
} catch (e) {}
})();
"#;
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.on_page_load(|webview, payload| {
if payload.event() == tauri::webview::PageLoadEvent::Finished {
let _ = webview.eval(EDGE_TO_EDGE_SCRIPT);
}
})
.plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
@@ -554,25 +725,32 @@ pub fn run() {
} }
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
std::thread::sleep(std::time::Duration::from_millis(400)); std::thread::sleep(std::time::Duration::from_millis(600));
if let Some(window) = handle.get_webview_window("main") { if let Some(window) = handle.get_webview_window("main") {
update_ui(&window, "Initializing Vyla Go engine...", Some(5)); update_ui(&window, "Initializing Vyla Go engine...", Some(5));
match download_and_extract_payload(handle.clone(), window.clone()).await { loop {
Ok(_) => { match download_and_extract_payload(handle.clone(), window.clone()).await {
std::thread::sleep(std::time::Duration::from_millis(400)); Ok(_) => {
let _ = window.eval(&format!( std::thread::sleep(std::time::Duration::from_millis(400));
"{}; window.location.replace('http://vyla.localhost/frontend/index.html');", let _ = window.eval(&format!(
ANDROID_VIDEO_FIX_SCRIPT "{}; window.location.replace('http://localhost:3001/');",
)); ANDROID_VIDEO_FIX_SCRIPT
} ));
Err(err) => { break;
update_ui( }
&window, Err(err) => {
&format!("Error: {}", err), android_log(&format!("Installer error: {}", err));
Some(0), for remaining in (1..=8).rev() {
); update_ui(
&window,
&format!("Error: {}. Retrying in {}s...", err, remaining),
Some(0),
);
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
}
} }
} }
} }

View File

@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Vyla Go", "productName": "Vyla Go",
"version": "1.0.10", "version": "1.0.15",
"identifier": "com.vyla.go", "identifier": "com.vyla.go",
"build": { "build": {
"beforeDevCommand": "", "beforeDevCommand": "",

View File

@@ -3,9 +3,17 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> <meta name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>Vyla Go</title> <title>Vyla Go</title>
<style> <style>
:root {
--safe-top: env(safe-area-inset-top, 0px);
--safe-right: env(safe-area-inset-right, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
--safe-left: env(safe-area-inset-left, 0px);
}
* { * {
box-sizing: border-box; box-sizing: border-box;
margin: 0; margin: 0;
@@ -62,7 +70,7 @@
left: 0; left: 0;
right: 0; right: 0;
z-index: 10; z-index: 10;
padding: 20px 16px 30px 16px; padding: 20px calc(16px + var(--safe-right)) calc(30px + var(--safe-bottom)) calc(16px + var(--safe-left));
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -122,7 +130,7 @@
</div> </div>
<div class="center-brand"> <div class="center-brand">
<img src="https://gitlab.com/vyla-entertainment/go/-/raw/main/assets/logos/white_logo.png" alt="Logo" /> <img src="/white_logo.png" alt="Logo" />
</div> </div>
<div class="bottom-dock"> <div class="bottom-dock">

View File

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB