Add Tauri launcher with Svelte UI
- Accounts: panel login/sign-up, Microsoft device-code sign-in, offline play; tokens encrypted with a key kept in the OS credential store - Home: instance rail, live server status, news feed, social links, Play button with download progress, speed and ETA - Full settings: accounts, memory/resolution/after-launch behaviour, Java and GC presets, Minecraft keybind editor applied to options.txt, theme overrides, storage manager, self-update via GitHub releases - Game console, crash dialog with hints, per-instance overrides - Live admin branding (colours, fonts, backgrounds, custom CSS) - Online install tests for vanilla/Fabric/Quilt/Forge/NeoForge (CI) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011ARcGWxLx21FwXJ3yfGriS
This commit is contained in:
60 files changed
+9424
-47
No files matched your search
@@ -0,0 +1,286 @@
|
||||
//! The Play button: install → sync → configure → start → watch.
|
||||
|
||||
use crate::accounts;
|
||||
use crate::state::{AppState, RunningGame};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use scopenet_core::install::{self, InstallSpec};
|
||||
use scopenet_core::launch::{self, LaunchOptions};
|
||||
use scopenet_core::progress::{Event, Reporter, Stage};
|
||||
use scopenet_core::{options, servers_dat, sync};
|
||||
use scopenet_shared::{InstanceManifest, LaunchEvent};
|
||||
use serde::Serialize;
|
||||
use std::collections::VecDeque;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct StatePayload {
|
||||
pub instance_id: String,
|
||||
/// "preparing" | "running" | "idle" | "error"
|
||||
pub state: String,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct ExitPayload {
|
||||
pub instance_id: String,
|
||||
pub code: Option<i32>,
|
||||
pub crashed: bool,
|
||||
pub tail: Vec<String>,
|
||||
pub crash_report: Option<String>,
|
||||
}
|
||||
|
||||
pub fn emit_state(app: &AppHandle, id: &str, state: &str, message: Option<String>) {
|
||||
app.emit("launch://state", StatePayload { instance_id: id.into(), state: state.into(), message }).ok();
|
||||
}
|
||||
|
||||
/// Fetch the instance definition, falling back to the last copy we saw so
|
||||
/// the game still starts without internet.
|
||||
async fn instance_manifest(state: &AppState, id: &str) -> Result<InstanceManifest> {
|
||||
let cache = state.layout.instance_dir(id).join(".scopenet").join("manifest.json");
|
||||
let fetched = async {
|
||||
let panel = state.panel_url().ok_or_else(|| anyhow!("no panel configured"))?;
|
||||
let mut req = state.http.get(format!("{panel}/api/v1/launcher/instances/{id}"));
|
||||
if let Some(token) = accounts::panel_token(state) {
|
||||
req = req.bearer_auth(token);
|
||||
}
|
||||
let resp = req.send().await?;
|
||||
if resp.status().as_u16() == 404 {
|
||||
anyhow::bail!("this instance no longer exists or you don't have access to it");
|
||||
}
|
||||
Ok::<_, anyhow::Error>(resp.error_for_status()?.json::<InstanceManifest>().await?)
|
||||
}
|
||||
.await;
|
||||
match fetched {
|
||||
Ok(m) => {
|
||||
std::fs::create_dir_all(cache.parent().unwrap()).ok();
|
||||
std::fs::write(&cache, serde_json::to_vec(&m)?).ok();
|
||||
Ok(m)
|
||||
}
|
||||
Err(e) => {
|
||||
let bytes = std::fs::read(&cache).map_err(|_| e)?;
|
||||
tracing::warn!("panel unreachable, using cached instance manifest");
|
||||
Ok(serde_json::from_slice(&bytes)?)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reporter(app: &AppHandle) -> Reporter {
|
||||
let app = app.clone();
|
||||
Arc::new(move |ev: Event| {
|
||||
app.emit("launch://progress", ev).ok();
|
||||
})
|
||||
}
|
||||
|
||||
/// Install/verify an instance and (when `start`) run the game.
|
||||
pub async fn run(app: AppHandle, instance_id: String, start: bool, deep: bool) -> Result<()> {
|
||||
let state = app.state::<AppState>();
|
||||
let report = reporter(&app);
|
||||
emit_state(&app, &instance_id, "preparing", None);
|
||||
|
||||
let account = state.accounts.read().unwrap().active().cloned();
|
||||
let account = match (start, account) {
|
||||
(true, None) => anyhow::bail!("sign in first"),
|
||||
(_, a) => a,
|
||||
};
|
||||
let auth = match &account {
|
||||
Some(a) if start => Some(accounts::game_auth(&state, a).await?),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let manifest = instance_manifest(&state, &instance_id).await?;
|
||||
let inst = &manifest.instance;
|
||||
let settings = state.settings.read().unwrap().clone();
|
||||
let advanced = state.manifest.read().unwrap().as_ref().map(|m| m.branding.features.allow_advanced_java).unwrap_or(true);
|
||||
let ov = settings.instance_overrides.get(&instance_id).cloned().unwrap_or_default();
|
||||
let game_dir = state.layout.instance_dir(&instance_id);
|
||||
let panel = state.panel_url().unwrap_or_default();
|
||||
|
||||
let java_override = if advanced {
|
||||
ov.java_path.clone().or(settings.java_path.clone()).filter(|p| !p.trim().is_empty()).map(PathBuf::from)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let spec = InstallSpec {
|
||||
mc_version: inst.mc_version.clone(),
|
||||
loader: inst.loader,
|
||||
loader_version: inst.loader_version.clone(),
|
||||
java_override,
|
||||
game_dir: game_dir.clone(),
|
||||
concurrency: settings.concurrent_downloads.clamp(1, 32) as usize,
|
||||
deep_verify: deep,
|
||||
};
|
||||
let installed = install::install(&state.http, &state.layout, &spec, &report).await?;
|
||||
sync::sync(&state.http, &panel, &game_dir, inst.revision, &manifest.files, spec.concurrency, deep, &report).await?;
|
||||
|
||||
if let Some(server) = &inst.server {
|
||||
if server.inject && !server.address.is_empty() {
|
||||
let name = if server.name.is_empty() { inst.name.clone() } else { server.name.clone() };
|
||||
servers_dat::upsert(&game_dir, &name, &servers_dat::format_address(&server.address, server.port)).ok();
|
||||
}
|
||||
}
|
||||
if settings.keybinds_enabled && installed.version.is_modern() && !settings.keybinds.is_empty() {
|
||||
options::apply_keybinds(&game_dir, &settings.keybinds).ok();
|
||||
}
|
||||
|
||||
if !start {
|
||||
emit_state(&app, &instance_id, "idle", Some("Instance is up to date".into()));
|
||||
return Ok(());
|
||||
}
|
||||
let auth = auth.unwrap();
|
||||
|
||||
report(Event::Stage { stage: Stage::Launching, label: "Starting Minecraft".into() });
|
||||
let pick = |a: u32, b: u32, c: u32| if a > 0 { a } else if b > 0 { b } else { c };
|
||||
let max = pick(ov.memory_max_mb, settings.memory_max_mb, inst.memory.max_mb).max(512);
|
||||
let min = pick(ov.memory_min_mb, settings.memory_min_mb, inst.memory.min_mb).min(max);
|
||||
let mut jvm = launch::split_args(&inst.jvm_args);
|
||||
if advanced {
|
||||
jvm.extend(launch::split_args(&settings.jvm_args));
|
||||
jvm.extend(launch::split_args(&ov.jvm_args));
|
||||
}
|
||||
let branding = state.manifest.read().unwrap().as_ref().map(|m| m.branding.clone()).unwrap_or_default();
|
||||
let opts = LaunchOptions {
|
||||
auth: auth.clone(),
|
||||
game_dir: game_dir.clone(),
|
||||
memory_min_mb: min,
|
||||
memory_max_mb: max,
|
||||
gc: settings.gc,
|
||||
extra_jvm_args: jvm,
|
||||
resolution: settings.custom_resolution.then_some((settings.width, settings.height)),
|
||||
fullscreen: settings.fullscreen,
|
||||
join_server: inst.server.as_ref().filter(|s| s.auto_join && !s.address.is_empty()).map(|s| (s.address.clone(), s.port)),
|
||||
version_label: if branding.version_label.is_empty() { branding.name.clone() } else { branding.version_label.clone() },
|
||||
launcher_name: "scopenet".into(),
|
||||
launcher_version: env!("CARGO_PKG_VERSION").into(),
|
||||
};
|
||||
let cmd = launch::build(&installed, &state.layout, &opts);
|
||||
tracing::info!("launching: {} {}", cmd.java.display(), launch::redact(&cmd.args, &auth.access_token).join(" "));
|
||||
|
||||
let close_after = settings.after_launch == "close";
|
||||
let mut child = launch::spawn(&cmd, installed.java_major, !close_after).context("couldn't start Java")?;
|
||||
|
||||
if settings.send_stats && !panel.is_empty() {
|
||||
let http = state.http.clone();
|
||||
let token = accounts::panel_token(&state);
|
||||
let ev = LaunchEvent { instance_id: instance_id.clone(), kind: "launch".into(), username: Some(auth.username.clone()) };
|
||||
tokio::spawn(async move {
|
||||
let mut req = http.post(format!("{panel}/api/v1/launcher/events")).json(&ev);
|
||||
if let Some(t) = token {
|
||||
req = req.bearer_auth(t);
|
||||
}
|
||||
req.send().await.ok();
|
||||
});
|
||||
}
|
||||
|
||||
if close_after {
|
||||
tokio::time::sleep(Duration::from_millis(1500)).await;
|
||||
app.exit(0);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let (kill_tx, kill_rx) = tokio::sync::oneshot::channel();
|
||||
*state.game.lock().unwrap() = Some(RunningGame { instance_id: instance_id.clone(), kill: kill_tx });
|
||||
emit_state(&app, &instance_id, "running", None);
|
||||
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
match settings.after_launch.as_str() {
|
||||
"hide" => {
|
||||
window.hide().ok();
|
||||
}
|
||||
"minimize" => {
|
||||
window.minimize().ok();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Stream logs to the UI in small batches (cheap IPC), keep a tail for crash reports.
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>();
|
||||
for stream in [child.stdout.take().map(|s| Box::new(s) as Box<dyn tokio::io::AsyncRead + Unpin + Send>), child.stderr.take().map(|s| Box::new(s) as Box<dyn tokio::io::AsyncRead + Unpin + Send>)].into_iter().flatten() {
|
||||
let tx = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(stream).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
if tx.send(line).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
drop(tx);
|
||||
|
||||
let app2 = app.clone();
|
||||
let id2 = instance_id.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut tail: VecDeque<String> = VecDeque::with_capacity(200);
|
||||
let mut batch: Vec<String> = Vec::new();
|
||||
let mut kill_rx = kill_rx;
|
||||
let mut tick = tokio::time::interval(Duration::from_millis(150));
|
||||
let mut streams_open = true;
|
||||
let mut kill_armed = true;
|
||||
let mut killed = false;
|
||||
let status = loop {
|
||||
tokio::select! {
|
||||
line = rx.recv(), if streams_open => match line {
|
||||
Some(l) => {
|
||||
if tail.len() == 200 { tail.pop_front(); }
|
||||
tail.push_back(l.clone());
|
||||
batch.push(l);
|
||||
}
|
||||
None => streams_open = false,
|
||||
},
|
||||
_ = tick.tick() => {
|
||||
if !batch.is_empty() {
|
||||
app2.emit("game://log", std::mem::take(&mut batch)).ok();
|
||||
}
|
||||
},
|
||||
res = &mut kill_rx, if kill_armed => {
|
||||
kill_armed = false;
|
||||
if res.is_ok() {
|
||||
killed = true;
|
||||
child.kill().await.ok();
|
||||
}
|
||||
},
|
||||
status = child.wait() => break status.ok(),
|
||||
}
|
||||
};
|
||||
// Drain whatever is left.
|
||||
while let Ok(l) = rx.try_recv() {
|
||||
tail.push_back(l.clone());
|
||||
batch.push(l);
|
||||
}
|
||||
if !batch.is_empty() {
|
||||
app2.emit("game://log", batch).ok();
|
||||
}
|
||||
let code = status.and_then(|s| s.code());
|
||||
let crashed = !killed && code.is_some_and(|c| c != 0);
|
||||
let crash_report = if crashed { newest_crash_report(&app2, &id2) } else { None };
|
||||
let st = app2.state::<AppState>();
|
||||
*st.game.lock().unwrap() = None;
|
||||
if let Some(w) = app2.get_webview_window("main") {
|
||||
w.show().ok();
|
||||
w.unminimize().ok();
|
||||
if crashed {
|
||||
w.set_focus().ok();
|
||||
}
|
||||
}
|
||||
app2.emit("game://exit", ExitPayload { instance_id: id2.clone(), code, crashed, tail: tail.into_iter().collect(), crash_report }).ok();
|
||||
emit_state(&app2, &id2, "idle", None);
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn newest_crash_report(app: &AppHandle, id: &str) -> Option<String> {
|
||||
let dir = app.state::<AppState>().layout.instance_dir(id).join("crash-reports");
|
||||
let newest = std::fs::read_dir(dir)
|
||||
.ok()?
|
||||
.filter_map(|e| e.ok())
|
||||
.filter_map(|e| Some((e.metadata().ok()?.modified().ok()?, e.path())))
|
||||
.max_by_key(|(t, _)| *t)?;
|
||||
// Only if it was written in the last few minutes (i.e. by this session).
|
||||
(newest.0.elapsed().ok()? < Duration::from_secs(300)).then(|| newest.1.to_string_lossy().into_owned())
|
||||
}
|
||||
Reference in new issue
Block a user