Add launcher preferences and modular landing studio [skip ci]
This commit is contained in:
1 parent
454620f21e
commit
6deff62a3a
25 files changed
+822
-682
No files matched your search
@@ -135,7 +135,7 @@ fn save_panel_account(state: &AppState, panel: &str, auth: AuthResponse) -> Resu
|
||||
}
|
||||
|
||||
pub async fn rename_panel(state: &AppState, username: &str, password: &str) -> Result<Account> {
|
||||
if state.game.lock().unwrap().is_some() || state.task.lock().unwrap().as_ref().is_some_and(|t| !t.is_finished()) {
|
||||
if !state.game.lock().unwrap().is_empty() || state.task.lock().unwrap().as_ref().is_some_and(|t| !t.is_finished()) {
|
||||
bail!("close Minecraft and wait for any installation to finish before changing your username");
|
||||
}
|
||||
let panel = state.panel_url().ok_or_else(|| anyhow!("no panel configured"))?;
|
||||
|
||||
@@ -52,9 +52,12 @@ pub struct Bootstrap {
|
||||
accounts: Vec<Account>,
|
||||
active_account: Option<String>,
|
||||
manifest: Option<LauncherManifest>,
|
||||
game_running: Option<String>,
|
||||
game_running: Vec<RunningGameInfo>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RunningGameInfo { run_id: String, instance_id: String }
|
||||
|
||||
#[tauri::command]
|
||||
pub fn bootstrap(state: State<'_, AppState>) -> Bootstrap {
|
||||
let accounts = state.accounts.read().unwrap().clone();
|
||||
@@ -74,7 +77,7 @@ pub fn bootstrap(state: State<'_, AppState>) -> Bootstrap {
|
||||
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()),
|
||||
game_running: state.game.lock().unwrap().iter().map(|g| RunningGameInfo { run_id: g.run_id.clone(), instance_id: g.instance_id.clone() }).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,11 +122,24 @@ pub async fn set_panel_url(state: State<'_, AppState>, url: String) -> Res<Launc
|
||||
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 };
|
||||
let captured = current.instance_game_options.clone();
|
||||
*current = Settings { panel_url: panel, instance_game_options: captured, ..settings };
|
||||
drop(current);
|
||||
state.save_settings().map_err(aerr)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_instance_options(state: State<'_, AppState>, instance_id: String) -> Res<std::collections::BTreeMap<String, String>> {
|
||||
if state.game.lock().unwrap().iter().any(|g| g.instance_id == instance_id) {
|
||||
return Err("close this instance before saving its game settings".into());
|
||||
}
|
||||
let options = scopenet_core::options::read_vanilla_preferences(&state.layout.instance_dir(&instance_id)).map_err(aerr)?;
|
||||
if options.is_empty() { return Err("no vanilla options.txt settings found yet".into()); }
|
||||
state.settings.write().unwrap().instance_game_options.insert(instance_id, options.clone());
|
||||
state.save_settings().map_err(aerr)?;
|
||||
Ok(options)
|
||||
}
|
||||
|
||||
// ---- accounts ----
|
||||
|
||||
#[tauri::command]
|
||||
@@ -345,8 +361,8 @@ pub fn repair_instance(app: AppHandle, state: State<'_, AppState>, instance_id:
|
||||
}
|
||||
|
||||
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());
|
||||
if deep && state.game.lock().unwrap().iter().any(|g| g.instance_id == instance_id) {
|
||||
return Err("Close this instance's game windows before repairing it".into());
|
||||
}
|
||||
let mut task = state.task.lock().unwrap();
|
||||
if task.as_ref().is_some_and(|t| !t.is_finished()) {
|
||||
@@ -373,9 +389,9 @@ pub fn cancel_launch(app: AppHandle, state: State<'_, AppState>, instance_id: St
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn kill_game(state: State<'_, AppState>) {
|
||||
if let Some(g) = state.game.lock().unwrap().take() {
|
||||
g.kill.send(()).ok();
|
||||
pub fn kill_game(state: State<'_, AppState>, run_id: String) {
|
||||
if let Some(g) = state.game.lock().unwrap().iter_mut().find(|g| g.run_id == run_id) {
|
||||
if let Some(kill) = g.kill.take() { kill.send(()).ok(); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,7 +460,7 @@ pub async fn storage_info(state: State<'_, AppState>) -> Res<Storage> {
|
||||
|
||||
#[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) {
|
||||
if state.game.lock().unwrap().iter().any(|g| g.instance_id == instance_id) {
|
||||
return Err("close the game first".into());
|
||||
}
|
||||
let dir = state.layout.instance_dir(&instance_id);
|
||||
|
||||
@@ -19,6 +19,7 @@ use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct StatePayload {
|
||||
pub instance_id: String,
|
||||
pub run_id: Option<String>,
|
||||
/// "preparing" | "running" | "idle" | "error"
|
||||
pub state: String,
|
||||
pub message: Option<String>,
|
||||
@@ -27,6 +28,7 @@ pub struct StatePayload {
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct ExitPayload {
|
||||
pub instance_id: String,
|
||||
pub run_id: String,
|
||||
pub code: Option<i32>,
|
||||
pub crashed: bool,
|
||||
pub tail: Vec<String>,
|
||||
@@ -34,7 +36,7 @@ pub struct ExitPayload {
|
||||
}
|
||||
|
||||
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();
|
||||
app.emit("launch://state", StatePayload { instance_id: id.into(), run_id: None, state: state.into(), message }).ok();
|
||||
}
|
||||
|
||||
/// Fetch the instance definition, falling back to the last copy we saw so
|
||||
@@ -119,7 +121,19 @@ pub async fn run(app: AppHandle, instance_id: String, start: bool, deep: bool) -
|
||||
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?;
|
||||
let already_running = state.game.lock().unwrap().iter().any(|g| g.instance_id == instance_id);
|
||||
// The on-disk file may be newer when the launcher was closed before Minecraft.
|
||||
let current_options = options::read_vanilla_preferences(&game_dir).unwrap_or_default();
|
||||
let mut saved_options = if current_options.is_empty() {
|
||||
settings.instance_game_options.get(&instance_id).cloned().unwrap_or_default()
|
||||
} else {
|
||||
current_options
|
||||
};
|
||||
// Do not rewrite pack files or options while another copy is using this directory.
|
||||
if !already_running {
|
||||
sync::sync(&state.http, &panel, &game_dir, inst.revision, &manifest.files, spec.concurrency, deep, &report).await?;
|
||||
options::apply(&game_dir, &mut saved_options)?;
|
||||
}
|
||||
|
||||
if let Some(server) = &inst.server {
|
||||
if server.inject && !server.address.is_empty() {
|
||||
@@ -127,7 +141,13 @@ pub async fn run(app: AppHandle, instance_id: String, start: bool, deep: bool) -
|
||||
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() {
|
||||
if !already_running && settings.vanilla_controls_enabled {
|
||||
let mut controls = std::collections::BTreeMap::new();
|
||||
controls.insert("autoJump".to_string(), settings.auto_jump.to_string());
|
||||
controls.insert("mouseSensitivity".to_string(), settings.sensitivity.clamp(0.0, 1.0).to_string());
|
||||
options::apply(&game_dir, &mut controls)?;
|
||||
}
|
||||
if !already_running && settings.keybinds_enabled && installed.version.is_modern() && !settings.keybinds.is_empty() {
|
||||
options::apply_keybinds(&game_dir, &settings.keybinds).ok();
|
||||
}
|
||||
|
||||
@@ -203,8 +223,9 @@ pub async fn run(app: AppHandle, instance_id: String, start: bool, deep: bool) -
|
||||
}
|
||||
|
||||
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);
|
||||
let run_id = uuid::Uuid::new_v4().to_string();
|
||||
state.game.lock().unwrap().push(RunningGame { run_id: run_id.clone(), instance_id: instance_id.clone(), kill: Some(kill_tx) });
|
||||
app.emit("launch://state", StatePayload { instance_id: instance_id.clone(), run_id: Some(run_id.clone()), state: "running".into(), message: None }).ok();
|
||||
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
match settings.after_launch.as_str() {
|
||||
@@ -241,6 +262,7 @@ pub async fn run(app: AppHandle, instance_id: String, start: bool, deep: bool) -
|
||||
|
||||
let app2 = app.clone();
|
||||
let id2 = instance_id.clone();
|
||||
let run_id2 = run_id.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut tail: VecDeque<String> = VecDeque::with_capacity(200);
|
||||
let mut batch: Vec<String> = Vec::new();
|
||||
@@ -297,15 +319,22 @@ pub async fn run(app: AppHandle, instance_id: String, start: bool, deep: bool) -
|
||||
},
|
||||
&id2,
|
||||
);
|
||||
*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();
|
||||
let preferences = options::read_vanilla_preferences(&st.layout.instance_dir(&id2)).unwrap_or_default();
|
||||
if !preferences.is_empty() {
|
||||
st.settings.write().unwrap().instance_game_options.insert(id2.clone(), preferences);
|
||||
st.save_settings().ok();
|
||||
}
|
||||
st.game.lock().unwrap().retain(|g| g.run_id != run_id2);
|
||||
if st.game.lock().unwrap().is_empty() {
|
||||
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 })
|
||||
app2.emit("game://exit", ExitPayload { instance_id: id2.clone(), run_id: run_id2, code, crashed, tail: tail.into_iter().collect(), crash_report })
|
||||
.ok();
|
||||
emit_state(&app2, &id2, "idle", None);
|
||||
});
|
||||
|
||||
@@ -51,6 +51,7 @@ pub fn run() {
|
||||
commands::refresh_manifest,
|
||||
commands::set_panel_url,
|
||||
commands::save_settings,
|
||||
commands::save_instance_options,
|
||||
commands::login_panel,
|
||||
commands::register_panel,
|
||||
commands::add_offline,
|
||||
|
||||
@@ -57,6 +57,12 @@ pub struct Settings {
|
||||
// ---- Controls ----
|
||||
pub keybinds_enabled: bool,
|
||||
pub keybinds: BTreeMap<String, String>,
|
||||
pub vanilla_controls_enabled: bool,
|
||||
pub auto_jump: bool,
|
||||
pub sensitivity: f64,
|
||||
pub show_news: bool,
|
||||
/// Vanilla options captured from each instance after the game exits.
|
||||
pub instance_game_options: HashMap<String, BTreeMap<String, String>>,
|
||||
|
||||
pub instance_overrides: HashMap<String, InstanceOverride>,
|
||||
}
|
||||
@@ -88,6 +94,11 @@ impl Default for Settings {
|
||||
send_stats: true,
|
||||
keybinds_enabled: false,
|
||||
keybinds: BTreeMap::new(),
|
||||
vanilla_controls_enabled: false,
|
||||
auto_jump: true,
|
||||
sensitivity: 0.5,
|
||||
show_news: true,
|
||||
instance_game_options: HashMap::new(),
|
||||
instance_overrides: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,8 +21,9 @@ pub mod build {
|
||||
}
|
||||
|
||||
pub struct RunningGame {
|
||||
pub run_id: String,
|
||||
pub instance_id: String,
|
||||
pub kill: tokio::sync::oneshot::Sender<()>,
|
||||
pub kill: Option<tokio::sync::oneshot::Sender<()>>,
|
||||
}
|
||||
|
||||
pub struct AppState {
|
||||
@@ -35,7 +36,7 @@ pub struct AppState {
|
||||
pub manifest: RwLock<Option<LauncherManifest>>,
|
||||
/// Install/launch pipeline in progress (abortable).
|
||||
pub task: Mutex<Option<tokio::task::AbortHandle>>,
|
||||
pub game: Mutex<Option<RunningGame>>,
|
||||
pub game: Mutex<Vec<RunningGame>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -52,7 +53,7 @@ impl AppState {
|
||||
accounts: RwLock::new(accounts),
|
||||
manifest: RwLock::new(manifest),
|
||||
task: Mutex::new(None),
|
||||
game: Mutex::new(None),
|
||||
game: Mutex::new(Vec::new()),
|
||||
data_dir,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
const b = $derived(app.manifest?.branding);
|
||||
const inst = $derived(selectedInstance());
|
||||
const image = $derived(abs(inst?.banner_url) ?? (b?.background.kind === 'image' ? abs(b.background.url) : null));
|
||||
const video = $derived(!inst?.banner_url && b?.background.kind === 'video' && app.settings?.background_video !== false && !app.running ? abs(b.background.url) : null);
|
||||
const video = $derived(!inst?.banner_url && b?.background.kind === 'video' && app.settings?.background_video !== false && app.running.length === 0 ? abs(b.background.url) : null);
|
||||
const blur = $derived(b?.background.blur ?? 0);
|
||||
const dim = $derived((b?.background.dim ?? 55) / 100);
|
||||
let hidden = $state(false);
|
||||
|
||||
@@ -68,6 +68,19 @@
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
<div class="card col">
|
||||
<strong>Vanilla game settings</strong>
|
||||
<span class="help">Brightness, graphics, FOV, view distance, sound and other vanilla options are saved automatically when the game closes, then restored after instance updates.</span>
|
||||
<span class="tiny muted">{Object.keys(app.settings.instance_game_options?.[inst.id] ?? {}).length} saved options for this instance</span>
|
||||
<button class="sm" disabled={app.running.some((g) => g.instance_id === inst.id)} onclick={async () => {
|
||||
try {
|
||||
const options = await invoke<Record<string, string>>('save_instance_options', { instanceId: inst.id });
|
||||
app.settings!.instance_game_options[inst.id] = options;
|
||||
toast('Vanilla game settings saved');
|
||||
} catch (e) { toast(errorText(e), 'error'); }
|
||||
}}>Save options.txt now</button>
|
||||
</div>
|
||||
|
||||
<div class="danger-zone">
|
||||
<span class="tiny muted">{local?.installed ? `Uses ${bytes(local.size)} on disk` : 'Not downloaded yet'}</span>
|
||||
<span class="spacer"></span>
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
const l = $derived(app.launch);
|
||||
const mine = $derived(app.launchingId === inst.id);
|
||||
const busy = $derived(mine && l.state === 'preparing');
|
||||
const running = $derived(app.running === inst.id);
|
||||
const otherRunning = $derived(!!app.running && app.running !== inst.id);
|
||||
const running = $derived(app.running.filter((g) => g.instance_id === inst.id));
|
||||
const preparing = $derived(l.state === 'preparing');
|
||||
const pct = $derived(l.total > 0 ? Math.min(100, (l.done / l.total) * 100) : 0);
|
||||
const eta = $derived(l.speed > 0 && l.total > l.done ? Math.ceil((l.total - l.done) / l.speed) : 0);
|
||||
const stageNames: Record<string, string> = { preparing: 'Preparing', java: 'Java', game: 'Game files', loader: 'Mod loader', files: 'Instance files', launching: 'Launching' };
|
||||
@@ -18,7 +18,7 @@
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={(e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter' && !busy && !running && !otherRunning && !noAccount && app.view === 'home') play(inst.id);
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter' && !preparing && !noAccount && app.view === 'home') play(inst.id);
|
||||
}} />
|
||||
|
||||
<div class="wrap">
|
||||
@@ -37,18 +37,19 @@
|
||||
<span>{#if l.total}{bytes(l.done)} / {bytes(l.total)}{#if l.speed > 0} · {bytes(l.speed)}/s{#if eta} · {eta < 60 ? `${eta}s` : `${Math.ceil(eta / 60)}m`} left{/if}{/if}{/if}</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else if running}
|
||||
<button class="play running" onclick={kill} title="Force-close Minecraft">
|
||||
<span class="pulse"></span>
|
||||
<span class="lbl">Playing</span>
|
||||
<span class="stop"><Square size={13} fill="currentColor" /> Stop</span>
|
||||
</button>
|
||||
{:else}
|
||||
<button class="play" onclick={() => play(inst.id)} disabled={otherRunning || noAccount} title={noAccount ? 'Sign in first' : otherRunning ? 'Another instance is running' : 'Ctrl + Enter'}>
|
||||
<button class="play" onclick={() => play(inst.id)} disabled={preparing || noAccount} title={noAccount ? 'Sign in first' : 'Ctrl + Enter'}>
|
||||
<Play size={18} fill="currentColor" />
|
||||
<span class="lbl">Play</span>
|
||||
<span class="lbl">{running.length ? 'Launch another window' : 'Play'}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if running.length}
|
||||
<div class="sessions">
|
||||
{#each running as game, index (game.run_id)}
|
||||
<button class="sm session" onclick={() => kill(game.run_id)} title="Stop this Minecraft window"><span class="pulse"></span> Window {index + 1} running <Square size={12} /> Stop</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if mine && l.state === 'error'}
|
||||
<div class="error glass selectable">
|
||||
<TriangleAlert size={17} />
|
||||
@@ -60,6 +61,8 @@
|
||||
|
||||
<style>
|
||||
.wrap { display: flex; flex-direction: column; align-items: flex-start; gap: 0.6rem; }
|
||||
.sessions { display: flex; flex-wrap: wrap; gap: 0.4rem; }
|
||||
.session { gap: 0.4rem; }
|
||||
.play {
|
||||
height: 3.4rem; min-width: 14.5rem; padding: 0 2rem; border: none; border-radius: var(--radius);
|
||||
background: linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 80%, black));
|
||||
@@ -71,11 +74,8 @@
|
||||
.play:active:not(:disabled) { transform: translateY(0); box-shadow: 0 2px 10px color-mix(in srgb, var(--accent) 30%, transparent); }
|
||||
.play:disabled { background: color-mix(in srgb, var(--text) 10%, transparent); color: var(--muted); box-shadow: none; transform: none; }
|
||||
.lbl { position: relative; }
|
||||
.running { background: color-mix(in srgb, var(--success) 16%, var(--surface)); color: var(--text); border: 1px solid color-mix(in srgb, var(--success) 35%, transparent); padding: 0 0.6rem 0 1.4rem; box-shadow: 0 4px 16px color-mix(in srgb, var(--success) 15%, transparent); }
|
||||
.running:hover:not(:disabled) { background: color-mix(in srgb, var(--success) 22%, var(--surface)); }
|
||||
.pulse { width: 0.55rem; height: 0.55rem; border-radius: 50%; background: var(--success); box-shadow: 0 0 8px var(--success); animation: pulse-dot 1.5s ease-in-out infinite; }
|
||||
@keyframes pulse-dot { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.6; transform: scale(0.85); } }
|
||||
.stop { font-size: 0.8rem; font-weight: 520; letter-spacing: 0; display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.4rem 0.7rem; border-radius: var(--radius-sm); background: color-mix(in srgb, var(--text) 8%, transparent); color: color-mix(in srgb, var(--text) 80%, transparent); margin-left: 0.6rem; }
|
||||
.progress { width: 26rem; max-width: 100%; padding: 0.85rem 1rem 0.8rem; display: flex; flex-direction: column; gap: 0.55rem; background: var(--surface); border-radius: var(--radius); }
|
||||
.top { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; }
|
||||
.stage { display: flex; align-items: center; gap: 0.5rem; font-weight: 560; font-size: 0.9rem; }
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<button
|
||||
class="inst"
|
||||
class:active={app.view === 'home' && app.selected === inst.id}
|
||||
class:running={app.running === inst.id}
|
||||
class:running={app.running.some((g) => g.instance_id === inst.id)}
|
||||
onclick={() => { selectInstance(inst.id); app.view = 'home'; }}
|
||||
aria-label={inst.name}
|
||||
>
|
||||
|
||||
@@ -30,6 +30,11 @@ const settings: Settings = {
|
||||
send_stats: true,
|
||||
keybinds_enabled: false,
|
||||
keybinds: {},
|
||||
vanilla_controls_enabled: false,
|
||||
auto_jump: true,
|
||||
sensitivity: 0.5,
|
||||
show_news: true,
|
||||
instance_game_options: {},
|
||||
instance_overrides: {},
|
||||
};
|
||||
|
||||
@@ -59,6 +64,7 @@ const manifest: Manifest = {
|
||||
{ label: 'Website', url: 'https://example.com', icon: 'website' },
|
||||
{ label: 'Store', url: 'https://example.com/store', icon: 'store' },
|
||||
],
|
||||
about: { title: 'SCOPENET Launcher', icon_url: null, body: 'Your worlds, one click away.', links: [] },
|
||||
features: { news: true, server_status: true, allow_user_theme: true, allow_advanced_java: true },
|
||||
custom_css: '',
|
||||
},
|
||||
@@ -107,7 +113,7 @@ async function fakeLaunch(id: string) {
|
||||
mockEmit('launch://progress', { type: 'stage', stage: 'launching', label: 'Starting Minecraft' });
|
||||
await sleep(500);
|
||||
running = true;
|
||||
mockEmit('launch://state', { instance_id: id, state: 'running', message: null });
|
||||
mockEmit('launch://state', { instance_id: id, run_id: 'mock-run', state: 'running', message: null });
|
||||
const lines = ['[main/INFO]: Loading Minecraft 1.21.1 with Fabric Loader 0.16.9', '[Render thread/INFO]: Setting user: Notch', '[Render thread/INFO]: Backend library: LWJGL version 3.3.3', '[Render thread/INFO]: Reloading ResourceManager: vanilla, fabric', '[Render thread/INFO]: Sound engine started', '[Render thread/INFO]: Connecting to play.scopenet.gg, 25565'];
|
||||
for (const l of lines) {
|
||||
await sleep(300);
|
||||
@@ -126,7 +132,7 @@ export async function mockInvoke(cmd: string, args: Record<string, any>): Promis
|
||||
accounts,
|
||||
active_account: active,
|
||||
manifest: scenario === 'setup' ? null : manifest,
|
||||
game_running: running ? 'survival-smp' : null,
|
||||
game_running: running ? [{ run_id: 'mock-run', instance_id: 'survival-smp' }] : [],
|
||||
};
|
||||
return structuredClone(b);
|
||||
}
|
||||
@@ -167,9 +173,11 @@ export async function mockInvoke(cmd: string, args: Record<string, any>): Promis
|
||||
return null;
|
||||
case 'kill_game':
|
||||
running = false;
|
||||
mockEmit('game://exit', { instance_id: 'survival-smp', code: 0, crashed: false, tail: [], crash_report: null });
|
||||
mockEmit('game://exit', { instance_id: 'survival-smp', run_id: args?.runId ?? 'mock-run', code: 0, crashed: false, tail: [], crash_report: null });
|
||||
mockEmit('launch://state', { instance_id: 'survival-smp', state: 'idle', message: null });
|
||||
return null;
|
||||
case 'save_instance_options':
|
||||
return { fov: '0.8', gamma: '1.0', graphicsMode: '1', autoJump: 'true' };
|
||||
case 'ping_server':
|
||||
await sleep(200);
|
||||
return { online: true, version: 'Paper 1.21.1', players_online: 42, players_max: 100, sample: ['Notch', 'jeb_', 'Dinnerbone'], motd: '§b§lSCOPENET §r§7» §fSeason 4 is live!', favicon: null, latency_ms: 23 };
|
||||
|
||||
@@ -14,7 +14,7 @@ export interface LaunchState {
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
export interface ExitInfo { instance_id: string; code: number | null; crashed: boolean; tail: string[]; crash_report: string | null }
|
||||
export interface ExitInfo { instance_id: string; run_id: string; code: number | null; crashed: boolean; tail: string[]; crash_report: string | null }
|
||||
|
||||
const idle = (): LaunchState => ({ state: 'idle', stage: null, label: '', done: 0, total: 0, filesDone: 0, filesTotal: 0, speed: 0, message: null });
|
||||
|
||||
@@ -33,7 +33,7 @@ export const app = $state({
|
||||
selected: null as string | null,
|
||||
launch: idle(),
|
||||
launchingId: null as string | null,
|
||||
running: null as string | null,
|
||||
running: [] as { run_id: string; instance_id: string }[],
|
||||
logs: [] as string[],
|
||||
consoleOpen: false,
|
||||
crash: null as ExitInfo | null,
|
||||
@@ -182,8 +182,8 @@ export async function cancel() {
|
||||
if (app.launchingId) await invoke('cancel_launch', { instanceId: app.launchingId });
|
||||
}
|
||||
|
||||
export async function kill() {
|
||||
await invoke('kill_game');
|
||||
export async function kill(runId: string) {
|
||||
await invoke('kill_game', { runId });
|
||||
}
|
||||
|
||||
let subscribed = false;
|
||||
@@ -218,15 +218,14 @@ function subscribe() {
|
||||
}
|
||||
});
|
||||
|
||||
listen<{ instance_id: string; state: LaunchState['state']; message: string | null }>('launch://state', (ev) => {
|
||||
listen<{ instance_id: string; run_id: string | null; state: LaunchState['state']; message: string | null }>('launch://state', (ev) => {
|
||||
if (ev.state === 'running') {
|
||||
app.running = ev.instance_id;
|
||||
if (ev.run_id && !app.running.some((g) => g.run_id === ev.run_id)) app.running.push({ run_id: ev.run_id, instance_id: ev.instance_id });
|
||||
app.launch = { ...idle(), state: 'running' };
|
||||
if (app.settings?.show_console) app.consoleOpen = true;
|
||||
} else if (ev.state === 'error') {
|
||||
app.launch = { ...idle(), state: 'error', message: ev.message };
|
||||
} else if (ev.state === 'idle') {
|
||||
if (app.running === ev.instance_id) app.running = null;
|
||||
if (app.launch.state !== 'error') app.launch = idle();
|
||||
if (ev.message && ev.message !== 'Cancelled') toast(ev.message);
|
||||
}
|
||||
@@ -235,7 +234,7 @@ function subscribe() {
|
||||
listen<string[]>('game://log', (lines) => pushLogs(lines));
|
||||
|
||||
listen<ExitInfo>('game://exit', (ev) => {
|
||||
app.running = null;
|
||||
app.running = app.running.filter((g) => g.run_id !== ev.run_id);
|
||||
if (ev.crashed) app.crash = ev;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ export interface Branding {
|
||||
version_label: string;
|
||||
news: NewsItem[];
|
||||
links: { label: string; url: string; icon: string }[];
|
||||
about: { title: string; icon_url: string | null; body: string; links: { label: string; url: string; icon: string }[] };
|
||||
features: { news: boolean; server_status: boolean; allow_user_theme: boolean; allow_advanced_java: boolean };
|
||||
custom_css: string;
|
||||
}
|
||||
@@ -88,6 +89,11 @@ export interface Settings {
|
||||
send_stats: boolean;
|
||||
keybinds_enabled: boolean;
|
||||
keybinds: Record<string, string>;
|
||||
vanilla_controls_enabled: boolean;
|
||||
auto_jump: boolean;
|
||||
sensitivity: number;
|
||||
show_news: boolean;
|
||||
instance_game_options: Record<string, Record<string, string>>;
|
||||
instance_overrides: Record<string, InstanceOverride>;
|
||||
}
|
||||
|
||||
@@ -98,7 +104,7 @@ export interface Bootstrap {
|
||||
accounts: Account[];
|
||||
active_account: string | null;
|
||||
manifest: Manifest | null;
|
||||
game_running: string | null;
|
||||
game_running: { run_id: string; instance_id: string }[];
|
||||
}
|
||||
|
||||
export type Stage = 'preparing' | 'java' | 'game' | 'loader' | 'files' | 'launching';
|
||||
@@ -360,4 +366,3 @@ export interface BaltopEntry {
|
||||
username: string;
|
||||
balance: number;
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
{/if}
|
||||
|
||||
<aside class="side">
|
||||
{#if b.features.news && b.news.length}<NewsFeed news={b.news} />{:else}<div></div>{/if}
|
||||
{#if b.features.news && app.settings?.show_news !== false && b.news.length}<NewsFeed news={b.news} />{:else}<div></div>{/if}
|
||||
{#if b.links.length}
|
||||
<div class="links">
|
||||
{#each b.links as l}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import Modal from '../components/Modal.svelte';
|
||||
import SkinView from '../components/SkinView.svelte';
|
||||
import Toggle from '../components/Toggle.svelte';
|
||||
import { activeAccount, app, connect, instances, refresh, removeAccount, saveSettings, switchAccount, toast } from '../lib/store.svelte';
|
||||
import { abs, activeAccount, app, connect, instances, refresh, removeAccount, saveSettings, switchAccount, toast } from '../lib/store.svelte';
|
||||
import { BIND_GROUPS, defaults, fromKeyboard, fromMouse, keyLabel } from '../lib/keys';
|
||||
import { bytes, gb } from '../lib/format';
|
||||
import { errorText, invoke, openUrl, pickFile } from '../lib/tauri';
|
||||
@@ -330,7 +330,7 @@
|
||||
<p class="muted small">Your UUID, inventory and server progress stay with your account. Close Minecraft first. Previous names remain reserved to you.</p>
|
||||
<label>New username<input bind:value={newUsername} required minlength="3" maxlength="16" pattern="[A-Za-z0-9_]+" autocomplete="username" /></label>
|
||||
<label>Current password<input bind:value={renamePassword} required type="password" autocomplete="current-password" /></label>
|
||||
<button disabled={renaming || !!app.running} type="submit">{renaming ? 'Updating…' : 'Change username'}</button>
|
||||
<button disabled={renaming || app.running.length > 0} type="submit">{renaming ? 'Updating…' : 'Change username'}</button>
|
||||
</form>
|
||||
<p class="lead">Stored on {b?.name ?? 'the server'} — visible to everyone on servers that use its sign-in.</p>
|
||||
{#if profileError}
|
||||
@@ -558,7 +558,16 @@
|
||||
</div>
|
||||
{:else if app.settingsTab === 'controls'}
|
||||
<h1>Controls</h1>
|
||||
<p class="lead">Set your Minecraft keybinds once — the launcher applies them to every instance before it starts.</p>
|
||||
<p class="lead">Customize keyboard, mouse, and movement controls across your instances.</p>
|
||||
<div class="card glass col">
|
||||
<Toggle bind:checked={s.vanilla_controls_enabled} onchange={saveSettings} label="Apply movement and mouse settings" help="Sets vanilla Auto-Jump and mouse sensitivity before launch." />
|
||||
{#if s.vanilla_controls_enabled}
|
||||
<Toggle bind:checked={s.auto_jump} onchange={saveSettings} label="Auto-Jump" />
|
||||
<label class="field"><span class="row">Mouse sensitivity <span class="spacer"></span><strong>{Math.round(s.sensitivity * 200)}%</strong></span>
|
||||
<input type="range" min="0" max="1" step="0.01" bind:value={s.sensitivity} oninput={saveSettings} />
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="card glass col">
|
||||
<Toggle bind:checked={s.keybinds_enabled} onchange={saveSettings} label="Apply these keybinds to the game" help="Writes to options.txt before launch (Minecraft 1.13 and newer)." />
|
||||
</div>
|
||||
@@ -631,6 +640,7 @@
|
||||
</div>
|
||||
<div class="card glass col">
|
||||
<Toggle bind:checked={s.check_updates} onchange={saveSettings} label="Check for launcher updates" />
|
||||
<Toggle bind:checked={s.show_news} onchange={saveSettings} label="Show news and announcements" help="Hide the news feed on the launcher home screen." />
|
||||
<p class="help">Official server accounts report launcher activity and gameplay to the panel, linked to your permanent UUID. Chat is counted; message text, passwords and command arguments are not collected.</p>
|
||||
</div>
|
||||
{:else if app.settingsTab === 'storage'}
|
||||
@@ -671,9 +681,17 @@
|
||||
<h1>About</h1>
|
||||
<div class="card glass col about">
|
||||
<div class="row">
|
||||
<div class="mark"></div>
|
||||
<div><strong class="big">{b?.name ?? 'SCOPENET'} Launcher</strong><p class="muted small">Version {app.boot?.app.version} · {app.boot?.app.os} {app.boot?.app.arch}</p></div>
|
||||
{#if b?.about?.icon_url}<img class="about-icon" src={abs(b.about.icon_url)} alt="" />{:else}<div class="mark"></div>{/if}
|
||||
<div><strong class="big">{b?.about?.title || `${b?.name ?? 'SCOPENET'} Launcher`}</strong><p class="muted small">Version {app.boot?.app.version} · {app.boot?.app.os} {app.boot?.app.arch}</p></div>
|
||||
</div>
|
||||
{#if b?.about?.body}<p class="about-body selectable">{b.about.body}</p>{/if}
|
||||
{#if b?.about?.links?.length}
|
||||
<div class="row about-links">
|
||||
{#each b.about.links as link}
|
||||
{#if link.label && link.url}<button class="sm" onclick={() => openUrl(link.url)}>{link.label}</button>{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if app.update}
|
||||
<div class="update">
|
||||
<Download size={20} />
|
||||
@@ -708,6 +726,9 @@
|
||||
.lead { color: var(--muted); margin-top: -0.5rem; line-height: 1.5; font-size: 0.9rem; margin-bottom: 0.3rem; }
|
||||
.card { padding: 1rem 1.1rem; background: var(--surface); }
|
||||
.card.col { gap: 1rem; }
|
||||
.about-icon { width: 3rem; height: 3rem; object-fit: contain; border-radius: 0.6rem; }
|
||||
.about-body { white-space: pre-line; line-height: 1.6; }
|
||||
.about-links { flex-wrap: wrap; }
|
||||
.list { display: flex; flex-direction: column; gap: 0.4rem; padding: 0.6rem; }
|
||||
.acc { display: flex; align-items: center; gap: 0.8rem; padding: 0.6rem 0.7rem; border-radius: var(--radius-sm); }
|
||||
.acc.current { background: color-mix(in srgb, var(--text) 5%, transparent); }
|
||||
|
||||
Reference in new issue
Block a user