Add launcher preferences and modular landing studio [skip ci]

This commit is contained in:
SCOPEDD committed 2026-09-30 06:48:03 -04:00
1 parent 454620f21e
commit 6deff62a3a
25 files changed
+822 -682

No files matched your search

+1 -1
View File
@@ -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"))?;
+25 -9
View File
@@ -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);
+41 -12
View File
@@ -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);
});
+1
View File
@@ -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,
+11
View File
@@ -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(),
}
}
+4 -3
View File
@@ -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,
}
}
+1 -1
View File
@@ -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>
+14 -14
View File
@@ -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; }
+1 -1
View File
@@ -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}
>
+11 -3
View File
@@ -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 };
+7 -8
View File
@@ -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;
});
}
+7 -2
View File
@@ -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;
}
+1 -1
View File
@@ -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}
+26 -5
View File
@@ -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); }