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,
}
}