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:
Claude committed 2026-09-28 05:00:30 +00:00
1 parent af9147ec75
commit 50eb1a0cac
60 files changed
+9424 -47

No files matched your search

+286
View File
@@ -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())
}