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

+261
View File
@@ -0,0 +1,261 @@
//! Player accounts: panel (username/password), Microsoft, and local offline.
use crate::secrets::Secret;
use crate::state::AppState;
use anyhow::{anyhow, bail, Context, Result};
use scopenet_shared::{offline_uuid, valid_username, AuthResponse, LoginRequest, RegisterRequest};
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Account {
pub id: String,
/// "panel" | "microsoft" | "offline"
pub kind: String,
pub username: String,
pub uuid: String,
/// Panel accounts: which panel they belong to.
#[serde(default)]
pub panel_url: Option<String>,
#[serde(default)]
pub role: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct AccountsFile {
pub accounts: Vec<Account>,
pub active: Option<String>,
}
impl AccountsFile {
pub fn load(path: &Path) -> Self {
std::fs::read(path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
}
pub fn save(&self, path: &Path) -> Result<()> {
std::fs::write(path, serde_json::to_vec_pretty(self)?)?;
Ok(())
}
pub fn active(&self) -> Option<&Account> {
let id = self.active.as_ref()?;
self.accounts.iter().find(|a| &a.id == id)
}
/// Add or replace (same kind + uuid + panel) and make it active.
pub fn upsert(&mut self, account: Account) -> Account {
if let Some(existing) = self
.accounts
.iter_mut()
.find(|a| a.kind == account.kind && a.uuid == account.uuid && a.panel_url == account.panel_url)
{
let id = existing.id.clone();
*existing = Account { id: id.clone(), ..account };
self.active = Some(id);
return existing.clone();
}
self.active = Some(account.id.clone());
self.accounts.push(account.clone());
account
}
}
fn now() -> i64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64
}
async fn panel_error(resp: reqwest::Response) -> anyhow::Error {
let status = resp.status();
let body: serde_json::Value = resp.json().await.unwrap_or_default();
anyhow!("{}", body.get("error").and_then(|e| e.as_str()).map(String::from).unwrap_or_else(|| format!("panel returned {status}")))
}
pub async fn login_panel(state: &AppState, username: &str, password: &str) -> Result<Account> {
let panel = state.panel_url().ok_or_else(|| anyhow!("no panel configured"))?;
let resp = state
.http
.post(format!("{panel}/api/v1/auth/login"))
.json(&LoginRequest { username: username.trim().into(), password: password.into() })
.send()
.await
.context("couldn't reach the server")?;
if !resp.status().is_success() {
return Err(panel_error(resp).await);
}
let auth: AuthResponse = resp.json().await?;
save_panel_account(state, &panel, auth)
}
pub async fn register_panel(state: &AppState, username: &str, password: &str, email: Option<String>) -> Result<(Option<Account>, bool)> {
let panel = state.panel_url().ok_or_else(|| anyhow!("no panel configured"))?;
let resp = state
.http
.post(format!("{panel}/api/v1/auth/register"))
.json(&RegisterRequest { username: username.trim().into(), password: password.into(), email })
.send()
.await
.context("couldn't reach the server")?;
if !resp.status().is_success() {
return Err(panel_error(resp).await);
}
let auth: AuthResponse = resp.json().await?;
if auth.pending {
return Ok((None, true));
}
Ok((Some(save_panel_account(state, &panel, auth)?), false))
}
fn save_panel_account(state: &AppState, panel: &str, auth: AuthResponse) -> Result<Account> {
let account = Account {
id: uuid::Uuid::new_v4().to_string(),
kind: "panel".into(),
username: auth.user.username.clone(),
uuid: auth.user.uuid.clone(),
panel_url: Some(panel.to_string()),
role: Some(auth.user.role.clone()),
};
let account = state.accounts.write().unwrap().upsert(account);
state.secrets.set(&account.id, Secret { panel_token: Some(auth.token), ..Default::default() })?;
state.save_accounts()?;
Ok(account)
}
pub fn add_offline(state: &AppState, username: &str) -> Result<Account> {
let allowed = state.manifest.read().unwrap().as_ref().map(|m| m.auth.offline_local).unwrap_or(true);
if !allowed {
bail!("offline accounts are disabled on this server");
}
let name = username.trim();
if !valid_username(name) {
bail!("usernames are 3–16 letters, numbers or underscores");
}
let account = Account {
id: uuid::Uuid::new_v4().to_string(),
kind: "offline".into(),
username: name.into(),
uuid: offline_uuid(name),
panel_url: None,
role: None,
};
let account = state.accounts.write().unwrap().upsert(account);
state.save_accounts()?;
Ok(account)
}
pub fn ms_client_id(state: &AppState) -> Result<String> {
state
.manifest
.read()
.unwrap()
.as_ref()
.and_then(|m| m.auth.microsoft.then(|| m.auth.microsoft_client_id.clone()).flatten())
.filter(|c| !c.is_empty())
.ok_or_else(|| anyhow!("Microsoft sign-in isn't enabled on this server"))
}
pub fn save_ms_session(state: &AppState, session: scopenet_core::msa::MsaSession) -> Result<Account> {
let uuid = dashed(&session.profile.id);
let account = Account {
id: uuid::Uuid::new_v4().to_string(),
kind: "microsoft".into(),
username: session.profile.name.clone(),
uuid,
panel_url: None,
role: None,
};
let account = state.accounts.write().unwrap().upsert(account);
state.secrets.set(
&account.id,
Secret {
ms_refresh_token: Some(session.refresh_token),
mc_access_token: Some(session.mc_access_token),
mc_expires_at: session.mc_expires_at,
xuid: session.xuid,
..Default::default()
},
)?;
state.save_accounts()?;
Ok(account)
}
fn dashed(id: &str) -> String {
let id = id.replace('-', "");
if id.len() != 32 {
return id;
}
format!("{}-{}-{}-{}-{}", &id[0..8], &id[8..12], &id[12..16], &id[16..20], &id[20..32])
}
/// Credentials passed to the game.
pub async fn game_auth(state: &AppState, account: &Account) -> Result<scopenet_core::launch::Auth> {
use scopenet_core::launch::Auth;
match account.kind.as_str() {
"microsoft" => {
let mut secret = state.secrets.get(&account.id);
if secret.mc_access_token.is_none() || secret.mc_expires_at < now() + 300 {
let refresh = secret.ms_refresh_token.clone().ok_or_else(|| anyhow!("please sign in to Microsoft again"))?;
let client_id = ms_client_id(state)?;
let session = scopenet_core::msa::refresh(&state.http, &client_id, &refresh).await?;
if session.profile.name != account.username {
let mut accounts = state.accounts.write().unwrap();
if let Some(a) = accounts.accounts.iter_mut().find(|a| a.id == account.id) {
a.username = session.profile.name.clone();
}
}
state.save_accounts().ok();
secret = Secret {
ms_refresh_token: Some(session.refresh_token),
mc_access_token: Some(session.mc_access_token),
mc_expires_at: session.mc_expires_at,
xuid: session.xuid,
..secret
};
state.secrets.set(&account.id, secret.clone())?;
}
let active_name = state.accounts.read().unwrap().accounts.iter().find(|a| a.id == account.id).map(|a| a.username.clone());
Ok(Auth {
username: active_name.unwrap_or_else(|| account.username.clone()),
uuid: account.uuid.clone(),
access_token: secret.mc_access_token.unwrap_or_default(),
user_type: "msa".into(),
xuid: secret.xuid,
})
}
_ => Ok(Auth {
username: account.username.clone(),
uuid: account.uuid.clone(),
// Offline mode: any token works; servers in offline mode ignore it.
access_token: "0".into(),
user_type: "legacy".into(),
xuid: None,
}),
}
}
/// Panel token for requests, when the active account belongs to this panel.
pub fn panel_token(state: &AppState) -> Option<String> {
let panel = state.panel_url()?;
let accounts = state.accounts.read().unwrap();
let active = accounts.active()?;
if active.kind != "panel" || active.panel_url.as_deref() != Some(panel.as_str()) {
return None;
}
state.secrets.get(&active.id).panel_token
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn upsert_replaces_same_identity() {
let mut f = AccountsFile::default();
let a = Account { id: "1".into(), kind: "offline".into(), username: "Steve".into(), uuid: "u".into(), panel_url: None, role: None };
f.upsert(a.clone());
f.upsert(Account { id: "2".into(), ..a.clone() });
assert_eq!(f.accounts.len(), 1);
assert_eq!(f.active.as_deref(), Some("1"));
assert_eq!(dashed("b50ad385829d3141a2167e7d7539ba7f"), "b50ad385-829d-3141-a216-7e7d7539ba7f");
}
}
+343
View File
@@ -0,0 +1,343 @@
//! Everything the UI can ask the backend to do.
use crate::accounts::{self, Account};
use crate::game;
use crate::settings::Settings;
use crate::state::{build, AppState};
use crate::updater;
use scopenet_core::msa::DeviceCode;
use scopenet_core::ping::ServerStatus;
use scopenet_shared::LauncherManifest;
use serde::Serialize;
use tauri::{AppHandle, Manager, State};
use tauri_plugin_opener::OpenerExt;
/// Commands return `Err(String)`; the UI shows it as-is.
type Res<T> = Result<T, String>;
fn err(e: impl std::fmt::Display) -> String {
e.to_string()
}
/// anyhow's alternate format keeps the context chain readable.
fn aerr(e: anyhow::Error) -> String {
format!("{e:#}")
}
#[derive(Serialize)]
pub struct AppInfo {
version: String,
default_panel_url: Option<String>,
panel_locked: bool,
repo: Option<String>,
os: String,
arch: String,
total_ram_mb: u64,
data_dir: String,
}
#[derive(Serialize)]
pub struct Bootstrap {
app: AppInfo,
panel_url: Option<String>,
settings: Settings,
accounts: Vec<Account>,
active_account: Option<String>,
manifest: Option<LauncherManifest>,
game_running: Option<String>,
}
#[tauri::command]
pub fn bootstrap(state: State<'_, AppState>) -> Bootstrap {
let accounts = state.accounts.read().unwrap().clone();
Bootstrap {
app: AppInfo {
version: env!("CARGO_PKG_VERSION").into(),
default_panel_url: build::PANEL_URL.filter(|u| !u.is_empty()).map(String::from),
panel_locked: build::panel_locked(),
repo: build::REPO.map(String::from),
os: std::env::consts::OS.into(),
arch: std::env::consts::ARCH.into(),
total_ram_mb: scopenet_core::sys::total_memory_mb(),
data_dir: state.data_dir.to_string_lossy().into_owned(),
},
panel_url: state.panel_url(),
settings: state.settings.read().unwrap().clone(),
accounts: accounts.accounts,
active_account: accounts.active,
manifest: state.manifest.read().unwrap().clone(),
game_running: state.game.lock().unwrap().as_ref().map(|g| g.instance_id.clone()),
}
}
async fn fetch_manifest(state: &AppState, panel: &str) -> anyhow::Result<LauncherManifest> {
let mut req = state.http.get(format!("{panel}/api/v1/launcher/manifest"));
if let Some(t) = accounts::panel_token(state) {
req = req.bearer_auth(t);
}
let resp = req.send().await.map_err(|e| anyhow::anyhow!("couldn't reach {panel}: {e}"))?;
if !resp.status().is_success() {
anyhow::bail!("{panel} returned {}", resp.status());
}
let m: LauncherManifest = resp.json().await.map_err(|_| anyhow::anyhow!("{panel} doesn't look like a ScopeNet panel"))?;
Ok(m)
}
#[tauri::command]
pub async fn refresh_manifest(state: State<'_, AppState>) -> Res<LauncherManifest> {
let panel = state.panel_url().ok_or("no panel configured")?;
let m = fetch_manifest(&state, &panel).await.map_err(aerr)?;
state.cache_manifest(&m);
Ok(m)
}
#[tauri::command]
pub async fn set_panel_url(state: State<'_, AppState>, url: String) -> Res<LauncherManifest> {
if build::panel_locked() {
return Err("this launcher is locked to its server".into());
}
let mut url = url.trim().trim_end_matches('/').to_string();
if !url.starts_with("http://") && !url.starts_with("https://") {
url = format!("https://{url}");
}
let m = fetch_manifest(&state, &url).await.map_err(aerr)?;
state.settings.write().unwrap().panel_url = Some(url);
state.save_settings().map_err(aerr)?;
state.cache_manifest(&m);
Ok(m)
}
#[tauri::command]
pub fn save_settings(state: State<'_, AppState>, settings: Settings) -> Res<()> {
let mut current = state.settings.write().unwrap();
let panel = current.panel_url.clone();
*current = Settings { panel_url: panel, ..settings };
drop(current);
state.save_settings().map_err(aerr)
}
// ---- accounts ----
#[tauri::command]
pub async fn login_panel(state: State<'_, AppState>, username: String, password: String) -> Res<Account> {
accounts::login_panel(&state, &username, &password).await.map_err(aerr)
}
#[derive(Serialize)]
pub struct RegisterResult {
account: Option<Account>,
pending: bool,
}
#[tauri::command]
pub async fn register_panel(state: State<'_, AppState>, username: String, password: String, email: Option<String>) -> Res<RegisterResult> {
let (account, pending) = accounts::register_panel(&state, &username, &password, email).await.map_err(aerr)?;
Ok(RegisterResult { account, pending })
}
#[tauri::command]
pub fn add_offline(state: State<'_, AppState>, username: String) -> Res<Account> {
accounts::add_offline(&state, &username).map_err(aerr)
}
#[tauri::command]
pub async fn ms_start(state: State<'_, AppState>) -> Res<DeviceCode> {
let client_id = accounts::ms_client_id(&state).map_err(aerr)?;
let code = scopenet_core::msa::start_device_code(&state.http, &client_id).await.map_err(aerr)?;
*state.device_code.lock().unwrap() = Some(code.clone());
Ok(code)
}
#[tauri::command]
pub async fn ms_finish(state: State<'_, AppState>) -> Res<Account> {
let client_id = accounts::ms_client_id(&state).map_err(aerr)?;
let code = state.device_code.lock().unwrap().clone().ok_or("start the sign-in first")?;
let (tx, rx) = tokio::sync::oneshot::channel();
*state.ms_cancel.lock().unwrap() = Some(tx);
let session = tokio::select! {
r = scopenet_core::msa::finish_device_code(&state.http, &client_id, &code) => r.map_err(aerr)?,
_ = rx => return Err("cancelled".into()),
};
*state.device_code.lock().unwrap() = None;
accounts::save_ms_session(&state, session).map_err(aerr)
}
#[tauri::command]
pub fn ms_cancel(state: State<'_, AppState>) {
if let Some(tx) = state.ms_cancel.lock().unwrap().take() {
tx.send(()).ok();
}
}
#[tauri::command]
pub fn select_account(state: State<'_, AppState>, id: String) -> Res<()> {
let mut accounts = state.accounts.write().unwrap();
if !accounts.accounts.iter().any(|a| a.id == id) {
return Err("account not found".into());
}
accounts.active = Some(id);
drop(accounts);
state.save_accounts().map_err(aerr)
}
#[tauri::command]
pub fn remove_account(state: State<'_, AppState>, id: String) -> Res<()> {
let mut accounts = state.accounts.write().unwrap();
accounts.accounts.retain(|a| a.id != id);
if accounts.active.as_deref() == Some(id.as_str()) {
accounts.active = accounts.accounts.first().map(|a| a.id.clone());
}
drop(accounts);
state.secrets.remove(&id).map_err(aerr)?;
state.save_accounts().map_err(aerr)
}
// ---- game ----
#[tauri::command]
pub fn launch(app: AppHandle, state: State<'_, AppState>, instance_id: String) -> Res<()> {
start_task(app, &state, instance_id, true, false)
}
#[tauri::command]
pub fn repair_instance(app: AppHandle, state: State<'_, AppState>, instance_id: String) -> Res<()> {
start_task(app, &state, instance_id, false, true)
}
fn start_task(app: AppHandle, state: &AppState, instance_id: String, start: bool, deep: bool) -> Res<()> {
if state.game.lock().unwrap().is_some() {
return Err("Minecraft is already running".into());
}
let mut task = state.task.lock().unwrap();
if task.as_ref().is_some_and(|t| !t.is_finished()) {
return Err("already preparing an instance".into());
}
let app2 = app.clone();
let handle = tauri::async_runtime::spawn(async move {
if let Err(e) = game::run(app2.clone(), instance_id.clone(), start, deep).await {
tracing::error!("launch failed: {e:#}");
game::emit_state(&app2, &instance_id, "error", Some(format!("{e:#}")));
}
});
*task = Some(handle.inner().abort_handle());
Ok(())
}
#[tauri::command]
pub fn cancel_launch(app: AppHandle, state: State<'_, AppState>, instance_id: String) {
if let Some(t) = state.task.lock().unwrap().take() {
t.abort();
}
game::emit_state(&app, &instance_id, "idle", Some("Cancelled".into()));
}
#[tauri::command]
pub fn kill_game(state: State<'_, AppState>) {
if let Some(g) = state.game.lock().unwrap().take() {
g.kill.send(()).ok();
}
}
#[derive(Serialize)]
pub struct InstanceLocal {
installed: bool,
revision: i64,
size: u64,
}
#[tauri::command]
pub async fn instance_local(state: State<'_, AppState>, instance_id: String) -> Res<InstanceLocal> {
let dir = state.layout.instance_dir(&instance_id);
let s = scopenet_core::sync::load_state(&dir);
let size = tokio::task::spawn_blocking(move || scopenet_core::sys::dir_size(&dir)).await.unwrap_or(0);
Ok(InstanceLocal { installed: s.revision > 0, revision: s.revision, size })
}
#[tauri::command]
pub async fn ping_server(host: String, port: u16) -> Res<ServerStatus> {
scopenet_core::ping::ping(&host, port).await.map_err(aerr)
}
#[tauri::command]
pub fn open_folder(app: AppHandle, state: State<'_, AppState>, kind: String, instance_id: Option<String>) -> Res<()> {
let base = match &instance_id {
Some(id) => state.layout.instance_dir(id),
None => state.data_dir.clone(),
};
let path = match kind.as_str() {
"instance" | "data" => base,
"logs" | "screenshots" | "mods" | "resourcepacks" | "shaderpacks" | "saves" | "crash-reports" => base.join(&kind),
_ => return Err("unknown folder".into()),
};
std::fs::create_dir_all(&path).map_err(err)?;
app.opener().open_path(path.to_string_lossy(), None::<&str>).map_err(err)
}
#[tauri::command]
pub fn open_file(app: AppHandle, path: String) -> Res<()> {
app.opener().open_path(path, None::<&str>).map_err(err)
}
#[derive(Serialize)]
pub struct Storage {
shared: u64,
runtimes: u64,
instances: Vec<(String, u64)>,
}
#[tauri::command]
pub async fn storage_info(state: State<'_, AppState>) -> Res<Storage> {
let layout = state.layout.clone();
tokio::task::spawn_blocking(move || {
use scopenet_core::sys::dir_size;
let shared = dir_size(&layout.libraries()) + dir_size(&layout.assets()) + dir_size(&layout.versions());
let runtimes = dir_size(&layout.runtimes());
let instances = std::fs::read_dir(layout.instances())
.map(|rd| rd.filter_map(|e| e.ok()).map(|e| (e.file_name().to_string_lossy().into_owned(), dir_size(&e.path()))).collect())
.unwrap_or_default();
Storage { shared, runtimes, instances }
})
.await
.map_err(err)
}
#[tauri::command]
pub async fn delete_instance_data(state: State<'_, AppState>, instance_id: String) -> Res<()> {
if state.game.lock().unwrap().as_ref().is_some_and(|g| g.instance_id == instance_id) {
return Err("close the game first".into());
}
let dir = state.layout.instance_dir(&instance_id);
tokio::fs::remove_dir_all(dir).await.or_else(|e| if e.kind() == std::io::ErrorKind::NotFound { Ok(()) } else { Err(e) }).map_err(err)
}
#[tauri::command]
pub async fn clear_cache(state: State<'_, AppState>) -> Res<()> {
let cache = state.layout.cache();
tokio::fs::remove_dir_all(cache).await.ok();
Ok(())
}
#[tauri::command]
pub async fn detect_java(path: String) -> Option<u32> {
scopenet_core::java::detect_major(std::path::Path::new(&path)).await
}
#[tauri::command]
pub async fn check_update(state: State<'_, AppState>) -> Res<Option<updater::UpdateInfo>> {
updater::check(&state.http).await.map_err(aerr)
}
#[tauri::command]
pub async fn install_update(app: AppHandle, state: State<'_, AppState>, url: String) -> Res<()> {
updater::download_and_run(&state.http, &url).await.map_err(aerr)?;
app.exit(0);
Ok(())
}
#[tauri::command]
pub fn show_window(app: AppHandle) {
if let Some(w) = app.get_webview_window("main") {
w.show().ok();
w.set_focus().ok();
}
}
+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())
}
+78
View File
@@ -0,0 +1,78 @@
//! ScopeNet launcher (Tauri shell). The heavy lifting lives in
//! `scopenet-core`; this crate wires it to the UI.
mod accounts;
mod commands;
mod game;
mod secrets;
mod settings;
mod state;
mod updater;
use tauri::Manager;
pub fn run() {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
.init();
tauri::Builder::default()
// A second launch just focuses the existing window.
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
if let Some(w) = app.get_webview_window("main") {
w.show().ok();
w.unminimize().ok();
w.set_focus().ok();
}
}))
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.setup(|app| {
let data_dir = app.path().app_data_dir()?;
app.manage(state::AppState::new(data_dir));
// The window starts hidden and the UI reveals it once painted (no
// white flash). If the UI never does, show it anyway.
let handle = app.handle().clone();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_secs(4));
if let Some(w) = handle.get_webview_window("main") {
if !w.is_visible().unwrap_or(true) {
tracing::warn!("UI didn't signal ready; showing window");
w.show().ok();
}
}
});
Ok(())
})
.invoke_handler(tauri::generate_handler![
commands::bootstrap,
commands::refresh_manifest,
commands::set_panel_url,
commands::save_settings,
commands::login_panel,
commands::register_panel,
commands::add_offline,
commands::ms_start,
commands::ms_finish,
commands::ms_cancel,
commands::select_account,
commands::remove_account,
commands::launch,
commands::repair_instance,
commands::cancel_launch,
commands::kill_game,
commands::instance_local,
commands::ping_server,
commands::open_folder,
commands::open_file,
commands::storage_info,
commands::delete_instance_data,
commands::clear_cache,
commands::detect_java,
commands::check_update,
commands::install_update,
commands::show_window,
])
.run(tauri::generate_context!())
.expect("error while running the launcher");
}
+6
View File
@@ -0,0 +1,6 @@
// Hide the console window on Windows release builds.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
scopenet_launcher_lib::run()
}
+132
View File
@@ -0,0 +1,132 @@
//! Encrypted storage for tokens (panel sessions, Microsoft refresh tokens).
//!
//! Secrets live in `secrets.bin`, encrypted with ChaCha20-Poly1305. The key
//! is kept in the OS credential store (Windows Credential Manager / macOS
//! Keychain), so copying the data folder to another machine doesn't leak
//! sessions. Where no credential store exists we fall back to a key file.
use anyhow::{anyhow, Result};
use base64::Engine;
use chacha20poly1305::aead::{Aead, KeyInit};
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
use rand::RngCore;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Mutex;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct Secret {
pub panel_token: Option<String>,
pub ms_refresh_token: Option<String>,
pub mc_access_token: Option<String>,
pub mc_expires_at: i64,
pub xuid: Option<String>,
}
pub struct Secrets {
path: PathBuf,
key: [u8; 32],
data: Mutex<HashMap<String, Secret>>,
}
const SERVICE: &str = "net.scopenet.launcher";
fn load_or_create_key(dir: &std::path::Path) -> [u8; 32] {
let b64 = base64::engine::general_purpose::STANDARD;
#[cfg(any(windows, target_os = "macos"))]
{
if let Ok(entry) = keyring::Entry::new(SERVICE, "secrets-key") {
if let Ok(existing) = entry.get_password() {
if let Ok(bytes) = b64.decode(existing) {
if let Ok(key) = <[u8; 32]>::try_from(bytes.as_slice()) {
return key;
}
}
}
let mut key = [0u8; 32];
rand::thread_rng().fill_bytes(&mut key);
if entry.set_password(&b64.encode(key)).is_ok() {
return key;
}
tracing::warn!("credential store unavailable, falling back to a key file");
}
}
let _ = SERVICE;
let path = dir.join("secrets.key");
if let Ok(bytes) = std::fs::read(&path).map(|b| b64.decode(b).unwrap_or_default()) {
if let Ok(key) = <[u8; 32]>::try_from(bytes.as_slice()) {
return key;
}
}
let mut key = [0u8; 32];
rand::thread_rng().fill_bytes(&mut key);
std::fs::write(&path, b64.encode(key)).ok();
key
}
impl Secrets {
pub fn open(dir: &std::path::Path) -> Self {
let key = load_or_create_key(dir);
let path = dir.join("secrets.bin");
let data = std::fs::read(&path).ok().and_then(|raw| decrypt(&key, &raw).ok()).unwrap_or_default();
Self { path, key, data: Mutex::new(data) }
}
pub fn get(&self, id: &str) -> Secret {
self.data.lock().unwrap().get(id).cloned().unwrap_or_default()
}
pub fn set(&self, id: &str, secret: Secret) -> Result<()> {
let mut data = self.data.lock().unwrap();
data.insert(id.to_string(), secret);
self.flush(&data)
}
pub fn remove(&self, id: &str) -> Result<()> {
let mut data = self.data.lock().unwrap();
data.remove(id);
self.flush(&data)
}
fn flush(&self, data: &HashMap<String, Secret>) -> Result<()> {
let plain = serde_json::to_vec(data)?;
let cipher = ChaCha20Poly1305::new(Key::from_slice(&self.key));
let mut nonce = [0u8; 12];
rand::thread_rng().fill_bytes(&mut nonce);
let mut out = nonce.to_vec();
out.extend(cipher.encrypt(Nonce::from_slice(&nonce), plain.as_slice()).map_err(|_| anyhow!("encryption failed"))?);
let tmp = self.path.with_extension("bin.tmp");
std::fs::write(&tmp, out)?;
std::fs::rename(tmp, &self.path)?;
Ok(())
}
}
fn decrypt(key: &[u8; 32], raw: &[u8]) -> Result<HashMap<String, Secret>> {
if raw.len() < 13 {
return Err(anyhow!("secrets file too short"));
}
let cipher = ChaCha20Poly1305::new(Key::from_slice(key));
let plain = cipher.decrypt(Nonce::from_slice(&raw[..12]), &raw[12..]).map_err(|_| anyhow!("can't decrypt secrets"))?;
Ok(serde_json::from_slice(&plain)?)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip() {
let dir = std::env::temp_dir().join(format!("scopenet-secrets-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
let s = Secrets::open(&dir);
s.set("a", Secret { panel_token: Some("tok".into()), ..Default::default() }).unwrap();
let raw = std::fs::read(dir.join("secrets.bin")).unwrap();
assert!(!String::from_utf8_lossy(&raw).contains("tok"), "stored encrypted");
let again = Secrets::open(&dir);
assert_eq!(again.get("a").panel_token.as_deref(), Some("tok"));
std::fs::remove_dir_all(dir).ok();
}
}
+107
View File
@@ -0,0 +1,107 @@
//! Player settings, stored as JSON in the app data directory.
use scopenet_core::launch::GcPreset;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap};
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(default)]
pub struct InstanceOverride {
/// 0 = use the global / instance default.
pub memory_max_mb: u32,
pub memory_min_mb: u32,
pub jvm_args: String,
pub java_path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct Settings {
/// Panel URL chosen by the player (ignored when one is baked in and locked).
pub panel_url: Option<String>,
pub selected_instance: Option<String>,
// ---- Game ----
/// 0 = use the value the admin set for the instance.
pub memory_max_mb: u32,
pub memory_min_mb: u32,
pub custom_resolution: bool,
pub width: u32,
pub height: u32,
pub fullscreen: bool,
/// "minimize" | "hide" | "keep" | "close"
pub after_launch: String,
pub show_console: bool,
// ---- Java ----
pub java_path: Option<String>,
pub gc: GcPreset,
pub jvm_args: String,
// ---- Appearance ----
/// "server" (admin's theme) | "custom"
pub theme_mode: String,
pub accent: Option<String>,
pub background_video: bool,
pub reduce_motion: bool,
/// None = follow the admin's setting.
pub glass: Option<bool>,
pub ui_scale: u32,
// ---- Launcher ----
pub concurrent_downloads: u32,
pub check_updates: bool,
pub send_stats: bool,
// ---- Controls ----
pub keybinds_enabled: bool,
pub keybinds: BTreeMap<String, String>,
pub instance_overrides: HashMap<String, InstanceOverride>,
}
impl Default for Settings {
fn default() -> Self {
Self {
panel_url: None,
selected_instance: None,
memory_max_mb: 0,
memory_min_mb: 0,
custom_resolution: false,
width: 1280,
height: 720,
fullscreen: false,
after_launch: "minimize".into(),
show_console: false,
java_path: None,
gc: GcPreset::G1,
jvm_args: String::new(),
theme_mode: "server".into(),
accent: None,
background_video: true,
reduce_motion: false,
glass: None,
ui_scale: 100,
concurrent_downloads: 12,
check_updates: true,
send_stats: true,
keybinds_enabled: false,
keybinds: BTreeMap::new(),
instance_overrides: HashMap::new(),
}
}
}
impl Settings {
pub fn load(path: &Path) -> Self {
std::fs::read(path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
}
pub fn save(&self, path: &Path) -> anyhow::Result<()> {
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, serde_json::to_vec_pretty(self)?)?;
std::fs::rename(tmp, path)?;
Ok(())
}
}
+87
View File
@@ -0,0 +1,87 @@
use crate::accounts::AccountsFile;
use crate::secrets::Secrets;
use crate::settings::Settings;
use scopenet_core::msa::DeviceCode;
use scopenet_core::Layout;
use scopenet_shared::LauncherManifest;
use std::path::PathBuf;
use std::sync::{Mutex, RwLock};
/// Values baked in at build time by CI (see `.github/workflows/release.yml`).
pub mod build {
/// Default panel URL for this build.
pub const PANEL_URL: Option<&str> = option_env!("SCOPENET_PANEL_URL");
/// `owner/repo` used to check GitHub releases for updates.
pub const REPO: Option<&str> = option_env!("SCOPENET_REPO");
/// When set to "1", players can't change the panel URL.
pub const LOCK_PANEL: Option<&str> = option_env!("SCOPENET_LOCK_PANEL");
pub fn panel_locked() -> bool {
LOCK_PANEL == Some("1") && PANEL_URL.is_some_and(|u| !u.is_empty())
}
}
pub struct RunningGame {
pub instance_id: String,
pub kill: tokio::sync::oneshot::Sender<()>,
}
pub struct AppState {
pub data_dir: PathBuf,
pub layout: Layout,
pub http: reqwest::Client,
pub settings: RwLock<Settings>,
pub accounts: RwLock<AccountsFile>,
pub secrets: Secrets,
pub manifest: RwLock<Option<LauncherManifest>>,
pub device_code: Mutex<Option<DeviceCode>>,
pub ms_cancel: Mutex<Option<tokio::sync::oneshot::Sender<()>>>,
/// Install/launch pipeline in progress (abortable).
pub task: Mutex<Option<tokio::task::AbortHandle>>,
pub game: Mutex<Option<RunningGame>>,
}
impl AppState {
pub fn new(data_dir: PathBuf) -> Self {
std::fs::create_dir_all(&data_dir).ok();
let settings = Settings::load(&data_dir.join("settings.json"));
let accounts = AccountsFile::load(&data_dir.join("accounts.json"));
let manifest = std::fs::read(data_dir.join("manifest.json")).ok().and_then(|b| serde_json::from_slice(&b).ok());
Self {
layout: Layout::new(data_dir.join("minecraft")),
http: scopenet_core::http::client(),
secrets: Secrets::open(&data_dir),
settings: RwLock::new(settings),
accounts: RwLock::new(accounts),
manifest: RwLock::new(manifest),
device_code: Mutex::new(None),
ms_cancel: Mutex::new(None),
task: Mutex::new(None),
game: Mutex::new(None),
data_dir,
}
}
/// The panel this launcher talks to (baked-in when locked).
pub fn panel_url(&self) -> Option<String> {
let chosen = self.settings.read().unwrap().panel_url.clone().filter(|u| !u.is_empty());
let baked = build::PANEL_URL.filter(|u| !u.is_empty()).map(String::from);
let url = if build::panel_locked() { baked } else { chosen.or(baked) };
url.map(|u| u.trim_end_matches('/').to_string())
}
pub fn save_settings(&self) -> anyhow::Result<()> {
self.settings.read().unwrap().save(&self.data_dir.join("settings.json"))
}
pub fn save_accounts(&self) -> anyhow::Result<()> {
self.accounts.read().unwrap().save(&self.data_dir.join("accounts.json"))
}
pub fn cache_manifest(&self, m: &LauncherManifest) {
*self.manifest.write().unwrap() = Some(m.clone());
if let Ok(bytes) = serde_json::to_vec(m) {
std::fs::write(self.data_dir.join("manifest.json"), bytes).ok();
}
}
}
+93
View File
@@ -0,0 +1,93 @@
//! Self-update via GitHub Releases: find a newer `*-setup.exe`, download
//! it and hand over to the installer.
use crate::state::build;
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize)]
pub struct UpdateInfo {
pub version: String,
pub notes: String,
pub url: String,
pub size: u64,
}
#[derive(Deserialize)]
struct Release {
tag_name: String,
#[serde(default)]
body: String,
#[serde(default)]
draft: bool,
#[serde(default)]
prerelease: bool,
assets: Vec<Asset>,
}
#[derive(Deserialize)]
struct Asset {
name: String,
browser_download_url: String,
size: u64,
}
fn parse(v: &str) -> Vec<u64> {
v.trim_start_matches('v').split(['.', '-', '+']).map_while(|p| p.parse().ok()).collect()
}
pub fn is_newer(candidate: &str, current: &str) -> bool {
parse(candidate) > parse(current)
}
pub async fn check(http: &reqwest::Client) -> Result<Option<UpdateInfo>> {
let Some(repo) = build::REPO.filter(|r| !r.is_empty()) else { return Ok(None) };
let release: Release = http
.get(format!("https://api.github.com/repos/{repo}/releases/latest"))
.header("Accept", "application/vnd.github+json")
.send()
.await?
.error_for_status()?
.json()
.await?;
if release.draft || release.prerelease || !is_newer(&release.tag_name, env!("CARGO_PKG_VERSION")) {
return Ok(None);
}
let asset = release
.assets
.iter()
.find(|a| a.name.to_lowercase().ends_with("-setup.exe"))
.ok_or_else(|| anyhow!("release {} has no installer", release.tag_name))?;
Ok(Some(UpdateInfo {
version: release.tag_name.trim_start_matches('v').to_string(),
notes: release.body,
url: asset.browser_download_url.clone(),
size: asset.size,
}))
}
/// Download the installer and start it. The caller exits the app afterwards.
pub async fn download_and_run(http: &reqwest::Client, url: &str) -> Result<()> {
if !url.starts_with("https://github.com/") && !url.starts_with("https://objects.githubusercontent.com/") {
anyhow::bail!("refusing to download an update from an unexpected host");
}
let dest = std::env::temp_dir().join("scopenet-launcher-setup.exe");
let dl = scopenet_core::http::Download::new(url, dest.clone(), None, None);
scopenet_core::http::download_one(http, &dl, &|_| {}).await.context("downloading update")?;
// /P = passive (progress only), /R = relaunch when done (Tauri NSIS).
std::process::Command::new(&dest).args(["/P", "/R"]).spawn().context("starting the installer")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn versions() {
assert!(is_newer("v0.2.0", "0.1.9"));
assert!(is_newer("1.0.0", "0.9.12"));
assert!(!is_newer("v0.1.0", "0.1.0"));
assert!(!is_newer("0.1.0-beta", "0.1.0"));
}
}