diff --git a/Cargo.lock b/Cargo.lock index d3bb39e1..1c5b783f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4472,6 +4472,8 @@ dependencies = [ "bytes 1.11.1", "cargo-packager-updater", "chrono", + "directories", + "dunce", "freya", "freya-skia-safe", "image", @@ -4490,6 +4492,7 @@ dependencies = [ "oneclient_net", "oneclient_polyplus", "open", + "percent-encoding", "polyio", "regex", "rfd", @@ -4499,6 +4502,7 @@ dependencies = [ "tracing", "tracing-subscriber", "uuid 1.23.1", + "windows 0.62.2", "winresource", ] diff --git a/Cargo.toml b/Cargo.toml index ab0d1c44..b20ac503 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -124,6 +124,7 @@ serde_json = {version = "=1.0.150"} serde_repr = {version = "=0.1.20"} sysinfo = {version = "=0.39.5"} trash = {version = "=5.2.6"} +percent-encoding = {version = "=2.3.2"} url = {version = "=2.5.8", features = ["serde"]} uuid = {version = "=1.23.1", features = ["serde", "v4"]} diff --git a/packages/oneclient_app/Cargo.toml b/packages/oneclient_app/Cargo.toml index 1c5c9b86..eb3d9989 100644 --- a/packages/oneclient_app/Cargo.toml +++ b/packages/oneclient_app/Cargo.toml @@ -42,6 +42,9 @@ notify.workspace = true anyhow.workspace = true regex.workspace = true +directories.workspace = true +dunce.workspace = true +percent-encoding.workspace = true cargo-packager-updater = "=0.2.3" @@ -58,6 +61,12 @@ freya.workspace = true [target.'cfg(target_os = "macos")'.dependencies] mimalloc = "=0.1.52" +[target.'cfg(windows)'.dependencies] +windows = { version = "=0.62.2", features = [ + "Win32_Foundation", + "Win32_System_Registry", +] } + [target.'cfg(windows)'.build-dependencies] winresource = "=0.1.31" @@ -71,6 +80,7 @@ homepage = "https://polyfrost.org/projects/oneclient" long-description = "Next-generation open source Minecraft launcher" formats = ["nsis", "app", "dmg", "deb", "appimage"] binaries = [{ path = "oneclient_app", main = true }] +deep-link-protocols = [{ schemes = ["oneclient"] }] icons = [ "icons/32x32.png", "icons/64x64.png", diff --git a/packages/oneclient_app/src/cli.rs b/packages/oneclient_app/src/cli.rs new file mode 100644 index 00000000..d7a41ec3 --- /dev/null +++ b/packages/oneclient_app/src/cli.rs @@ -0,0 +1,84 @@ +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct Cli { + pub launch: Option, +} + +impl Cli { + fn from_args(args: impl IntoIterator) -> Self { + let mut cli = Self::default(); + let mut args = args.into_iter(); + + while let Some(arg) = args.next() { + if let Some(value) = arg.strip_prefix("--launch=") { + cli.launch = non_empty(value.to_string()); + } else if arg == "--launch" { + cli.launch = args.next().and_then(non_empty); + } else if let Some(folder) = crate::protocol::parse_launch_url(&arg) { + cli.launch = Some(folder); + } + } + + cli + } +} + +fn non_empty(value: String) -> Option { + let trimmed = value.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) +} + +#[must_use] +pub fn parse() -> Cli { + Cli::from_args(std::env::args().skip(1)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(args: &[&str]) -> Cli { + Cli::from_args(args.iter().map(|s| (*s).to_string())) + } + + #[test] + fn no_arguments_is_an_ordinary_start() { + assert_eq!(parse(&[]).launch, None); + } + + #[test] + fn both_spellings_carry_the_folder() { + assert_eq!(parse(&["--launch", "fabric-1-20"]).launch.as_deref(), Some("fabric-1-20")); + assert_eq!(parse(&["--launch=fabric-1-20"]).launch.as_deref(), Some("fabric-1-20")); + } + + #[test] + fn a_blank_folder_is_no_request_at_all() { + assert_eq!(parse(&["--launch", " "]).launch, None); + assert_eq!(parse(&["--launch="]).launch, None); + assert_eq!(parse(&["--launch"]).launch, None); + } + + #[test] + fn spaces_survive_the_round_trip() { + assert_eq!( + parse(&["--launch", "My Pack (1.8.9)"]).launch.as_deref(), + Some("My Pack (1.8.9)"), + ); + } + + #[test] + fn unknown_flags_are_ignored_not_fatal() { + assert_eq!(parse(&["--verbose", "--launch", "x", "leftover"]).launch.as_deref(), Some("x")); + } + + #[test] + fn a_launch_url_is_accepted_as_a_bare_argument() { + let url = crate::protocol::launch_url("26.1.2 Fabric"); + assert_eq!(parse(&[&url]).launch.as_deref(), Some("26.1.2 Fabric")); + } + + #[test] + fn a_url_that_is_not_ours_is_not_a_launch() { + assert_eq!(parse(&["https://polyfrost.org"]).launch, None); + } +} diff --git a/packages/oneclient_app/src/file_content.rs b/packages/oneclient_app/src/file_content.rs new file mode 100644 index 00000000..624e0c69 --- /dev/null +++ b/packages/oneclient_app/src/file_content.rs @@ -0,0 +1,233 @@ +#![allow(dead_code)] + +use std::path::Path; + +pub fn single_line(value: &str) -> String { + let cleaned: String = value + .chars() + .map(|c| if c.is_control() { ' ' } else { c }) + .collect(); + cleaned.split_whitespace().collect::>().join(" ") +} + +pub fn desktop_exec_arg(value: &str) -> String { + let mut out = String::with_capacity(value.len() + 2); + out.push('"'); + for c in value.chars() { + match c { + '"' | '\\' | '$' | '`' => { + out.push('\\'); + out.push(c); + } + '%' => out.push_str("%%"), + _ => out.push(c), + } + } + out.push('"'); + out +} + +pub fn desktop_entry(name: &str, exe: &Path, folder: &str, icon: &str) -> String { + let name = single_line(name); + let exec = format!( + "{} --launch {}", + desktop_exec_arg(&exe.to_string_lossy()), + desktop_exec_arg(folder), + ); + + format!( + "[Desktop Entry]\n\ + Type=Application\n\ + Version=1.0\n\ + Name={name}\n\ + Comment=Launch {name} with OneClient\n\ + Exec={exec}\n\ + Icon={icon}\n\ + Terminal=false\n\ + Categories=Game;\n\ + StartupNotify=true\n" + ) +} + +pub fn url_handler_entry(exe: &Path, scheme: &str, icon: &str) -> String { + let exec = desktop_exec_arg(&exe.to_string_lossy()); + + format!( + "[Desktop Entry]\n\ + Type=Application\n\ + Name=OneClient\n\ + Exec={exec} %u\n\ + Icon={icon}\n\ + Terminal=false\n\ + NoDisplay=true\n\ + MimeType=x-scheme-handler/{scheme};\n" + ) +} + +pub fn sh_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', r"'\''")) +} + +pub fn shell_script(exe: &Path, folder: &str) -> String { + format!( + "#!/bin/sh\nexec {} --launch {}\n", + sh_quote(&exe.to_string_lossy()), + sh_quote(folder), + ) +} + +fn xml_escape(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +pub fn bundle_slug(folder: &str) -> String { + let slug: String = folder + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect(); + let trimmed = slug.trim_matches('-').to_string(); + if trimmed.is_empty() { + "cluster".to_string() + } else { + trimmed + } +} + +pub fn info_plist(name: &str, executable: &str, folder: &str, icon: Option<&str>) -> String { + let display = xml_escape(&single_line(name)); + let icon_entry = icon.map_or_else(String::new, |icon| { + format!("\tCFBundleIconFile\n\t{}\n", xml_escape(icon)) + }); + + format!( + "\n\ + \n\ + \n\ + \n\ + \tCFBundleName\n\t{display}\n\ + \tCFBundleDisplayName\n\t{display}\n\ + \tCFBundleIdentifier\n\torg.polyfrost.OneClient.shortcut.{slug}\n\ + \tCFBundleExecutable\n\t{executable}\n\ + {icon_entry}\ + \tCFBundlePackageType\n\tAPPL\n\ + \tCFBundleInfoDictionaryVersion\n\t6.0\n\ + \tCFBundleShortVersionString\n\t1.0\n\ + \tLSUIElement\n\t\n\ + \n\ + \n", + executable = xml_escape(executable), + slug = bundle_slug(folder), + ) +} + +pub fn url_shortcut(url: &str, icon: &Path) -> String { + format!( + "[InternetShortcut]\r\nURL={url}\r\nIconFile={}\r\nIconIndex=0\r\n", + icon.display(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn a_newline_in_a_cluster_name_cannot_forge_a_second_key() { + let entry = desktop_entry( + "Pack\nExec=/bin/sh", + &PathBuf::from("/usr/bin/oneclient_app"), + "pack", + "oneclient_app", + ); + assert!(!entry.contains("Exec=/bin/sh")); + assert!(entry.contains("Name=Pack Exec=/bin/sh")); + } + + #[test] + fn a_space_in_the_install_path_stays_one_argument() { + let entry = desktop_entry( + "Pack", + &PathBuf::from("/opt/One Client/oneclient_app"), + "my pack", + "oneclient_app", + ); + assert!(entry.contains(r#"Exec="/opt/One Client/oneclient_app" --launch "my pack""#)); + } + + #[test] + fn the_url_handler_quotes_an_install_path_with_a_space() { + let entry = url_handler_entry( + &PathBuf::from("/opt/One Client/oneclient_app"), + "oneclient", + "oneclient_app", + ); + assert!(entry.contains(r#"Exec="/opt/One Client/oneclient_app" %u"#)); + assert!(entry.contains("MimeType=x-scheme-handler/oneclient;")); + } + + #[test] + fn the_url_handler_keeps_the_field_code_outside_the_quoted_path() { + let entry = url_handler_entry( + &PathBuf::from("/usr/bin/oneclient_app"), + "oneclient", + "oneclient_app", + ); + assert!(entry.contains(r#"Exec="/usr/bin/oneclient_app" %u"#)); + assert!(!entry.contains("%%u")); + } + + #[test] + fn percent_is_doubled_for_the_desktop_spec() { + assert_eq!(desktop_exec_arg("100%"), r#""100%%""#); + } + + #[test] + fn desktop_reserved_characters_are_escaped() { + assert_eq!(desktop_exec_arg("a$b`c\"d"), r#""a\$b\`c\"d""#); + } + + #[test] + fn the_mac_wrapper_survives_a_quote_in_the_path() { + let script = shell_script(&PathBuf::from("/Users/o'brien/OneClient"), "pack"); + assert!(script.contains(r"'/Users/o'\''brien/OneClient'")); + assert!(script.starts_with("#!/bin/sh\n")); + } + + #[test] + fn the_plist_escapes_a_name_that_looks_like_markup() { + let plist = info_plist("A & ", "launch", "pack", Some("icon")); + assert!(plist.contains("A & <B>")); + assert!(!plist.contains("")); + } + + #[test] + fn the_plist_omits_the_icon_key_when_there_is_no_icon() { + let plist = info_plist("Pack", "launch", "pack", None); + assert!(!plist.contains("CFBundleIconFile")); + } + + #[test] + fn a_bundle_id_keeps_only_what_it_is_allowed() { + assert_eq!(bundle_slug("My Pack (1.8.9)"), "My-Pack--1-8-9"); + assert_eq!(bundle_slug("---"), "cluster"); + } + + #[test] + fn the_url_shortcut_is_a_single_ini_section() { + let file = url_shortcut( + "oneclient://launch/My%20Pack", + &PathBuf::from(r"C:\Program Files\OneClient\oneclient_app.exe"), + ); + + assert!(file.starts_with("[InternetShortcut]\r\n")); + assert!(file.contains("\r\nURL=oneclient://launch/My%20Pack\r\n")); + assert!(file.contains(r"IconFile=C:\Program Files\OneClient\oneclient_app.exe")); + assert!(file.ends_with("IconIndex=0\r\n")); + } +} diff --git a/packages/oneclient_app/src/hooks/actions.rs b/packages/oneclient_app/src/hooks/actions.rs index ca4b644d..93868ebb 100644 --- a/packages/oneclient_app/src/hooks/actions.rs +++ b/packages/oneclient_app/src/hooks/actions.rs @@ -623,8 +623,6 @@ impl Actions { .progress(id, "Downloading assets", current, total); } - /// The pending flag is raised synchronously before the first `await` the - /// claim itself is the guard against a double-click spawning two games pub fn launch_cluster(&self, cluster_id: ClusterId) { let claimed = self .station diff --git a/packages/oneclient_app/src/hooks/mod.rs b/packages/oneclient_app/src/hooks/mod.rs index a106dd94..4ff4b936 100644 --- a/packages/oneclient_app/src/hooks/mod.rs +++ b/packages/oneclient_app/src/hooks/mod.rs @@ -2,6 +2,7 @@ mod active_cluster; mod debounce; mod actions; mod queries; +mod shortcut_actions; mod view_state; pub use debounce::use_debounced; @@ -108,6 +109,13 @@ pub fn use_installs_snapshot() -> InstallState { use_radio(AppChannel::Installs).read().installs.clone() } +pub fn use_pending_launch() -> Option { + use_radio(AppChannel::PendingLaunch) + .read() + .pending_launch + .clone() +} + pub fn use_microsoft_login_status() -> Option { use_radio(AppChannel::MicrosoftLogin) .read() diff --git a/packages/oneclient_app/src/hooks/shortcut_actions.rs b/packages/oneclient_app/src/hooks/shortcut_actions.rs new file mode 100644 index 00000000..ca0e3094 --- /dev/null +++ b/packages/oneclient_app/src/hooks/shortcut_actions.rs @@ -0,0 +1,108 @@ +use freya::prelude::spawn_forever; +use oneclient_db::models::ClusterId; + +use super::actions::Actions; +use crate::components::IconType; +use crate::launcher; +use crate::shortcut::{self, ShortcutRequest}; +use crate::state::{AppChannel, LaunchBlock}; + +impl Actions { + pub fn request_launch_by_folder(&self, folder: String) { + self.station() + .write_channel(AppChannel::PendingLaunch) + .pending_launch = Some(folder); + } + + pub fn take_pending_launch(&self) -> Option { + let waiting = self.station().peek().pending_launch.is_some(); + if !waiting { + return None; + } + + self.station() + .write_channel(AppChannel::PendingLaunch) + .pending_launch + .take() + } + + #[must_use] + pub fn launch_block(&self, cluster_id: ClusterId) -> Option { + let station = self.station(); + let snapshot = station.peek(); + let parallel = snapshot.settings.settings.allow_parallel_running_clusters; + snapshot.game.launch_block(cluster_id, parallel) + } + + pub fn report_missing_shortcut_target(&self, folder: &str) { + self.notify("Shortcut is out of date") + .body(format!( + "No version folder named \"{folder}\" is installed any more. The shortcut can be recreated from the version's page." + )) + .error() + .icon(IconType::LinkExternal01) + .send(); + } + + pub fn create_cluster_shortcut(&self, cluster_id: ClusterId) { + let actions = self.clone(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + + let cluster = match state.clusters.get(cluster_id).await { + Ok(cluster) => cluster, + Err(err) => { + actions.report_shortcut_failure(&format!("{err:#}")); + return; + } + }; + + let Some(dir) = pick_shortcut_dir().await else { + return; + }; + + let request = ShortcutRequest { + cluster_name: cluster.name.clone(), + folder_name: cluster.folder_name.clone(), + dir: dir.clone(), + }; + + match tokio::task::spawn_blocking(move || shortcut::create(&request)).await { + Ok(Ok(path)) => { + let name = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| cluster.name.clone()); + + actions + .notify("Shortcut created") + .body(format!("Saved as {name} in {}", dir.display())) + .icon(IconType::LinkExternal01) + .send(); + } + Ok(Err(err)) => actions.report_shortcut_failure(&format!("{err:#}")), + Err(err) => actions.report_shortcut_failure(&err.to_string()), + } + }); + } + + fn report_shortcut_failure(&self, reason: &str) { + tracing::error!("could not create a cluster shortcut: {reason}"); + self.notify("Couldn't create the shortcut") + .body(reason.to_string()) + .error() + .send(); + } +} + +async fn pick_shortcut_dir() -> Option { + let mut dialog = rfd::AsyncFileDialog::new().set_title("Where should the shortcut go?"); + if let Some(desktop) = shortcut::default_dir() { + dialog = dialog.set_directory(desktop); + } + + dialog + .pick_folder() + .await + .map(|handle| handle.path().to_path_buf()) +} diff --git a/packages/oneclient_app/src/ipc.rs b/packages/oneclient_app/src/ipc.rs new file mode 100644 index 00000000..06c32d85 --- /dev/null +++ b/packages/oneclient_app/src/ipc.rs @@ -0,0 +1,296 @@ +use std::time::Duration; + +use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader}; + +use crate::cli::Cli; + +const IO_TIMEOUT: Duration = Duration::from_secs(3); +const CLAIM_ATTEMPTS: usize = 3; +const REPLY_OK: &str = "OK"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum IpcCommand { + Launch(String), + Focus, +} + +pub enum Claim { + Primary(Listener), + Forwarded, + Solo(String), +} + +impl IpcCommand { + fn encode(&self) -> String { + match self { + Self::Launch(folder) => format!("LAUNCH {folder}"), + Self::Focus => "FOCUS".to_string(), + } + } + + fn decode(line: &str) -> Option { + let line = line.trim_end_matches(['\r', '\n']); + if let Some(folder) = line.strip_prefix("LAUNCH ") { + let folder = folder.trim(); + return (!folder.is_empty()).then(|| Self::Launch(folder.to_string())); + } + (line == "FOCUS").then_some(Self::Focus) + } +} + +#[must_use] +pub fn request_for(cli: &Cli) -> IpcCommand { + match &cli.launch { + Some(folder) => IpcCommand::Launch(folder.clone()), + None => IpcCommand::Focus, + } +} + +pub async fn claim(cli: &Cli) -> Claim { + let request = request_for(cli); + + for _ in 0..CLAIM_ATTEMPTS { + if forward(&request).await { + return Claim::Forwarded; + } + + match imp::bind().await { + Ok(listener) => return Claim::Primary(listener), + Err(BindError::Taken) => continue, + Err(BindError::Io(err)) => return Claim::Solo(err.to_string()), + } + } + + Claim::Solo(format!("the endpoint changed hands {CLAIM_ATTEMPTS} times")) +} + +async fn forward(request: &IpcCommand) -> bool { + imp::send(&request.encode()).await +} + +pub async fn serve(listener: Listener, on_command: impl Fn(IpcCommand)) { + imp::serve(listener, on_command).await; +} + +enum BindError { + Taken, + Io(std::io::Error), +} + +async fn handle(stream: S, on_command: &impl Fn(IpcCommand)) +where + S: AsyncRead + AsyncWrite + Unpin, +{ + let mut stream = BufReader::new(stream); + let mut line = String::new(); + + let read = tokio::time::timeout(IO_TIMEOUT, stream.read_line(&mut line)).await; + if !matches!(read, Ok(Ok(n)) if n > 0) { + return; + } + + let reply = match IpcCommand::decode(&line) { + Some(command) => { + on_command(command); + format!("{REPLY_OK}\n") + } + None => "ERR unknown request\n".to_string(), + }; + + let _ = tokio::time::timeout(IO_TIMEOUT, stream.write_all(reply.as_bytes())).await; + let _ = stream.flush().await; +} + +async fn exchange(stream: S, request: &str) -> bool +where + S: AsyncRead + AsyncWrite + Unpin, +{ + let mut stream = BufReader::new(stream); + + let wrote = tokio::time::timeout(IO_TIMEOUT, async { + stream.write_all(request.as_bytes()).await?; + stream.write_all(b"\n").await?; + stream.flush().await + }) + .await; + if !matches!(wrote, Ok(Ok(()))) { + return false; + } + + let mut reply = String::new(); + let read = tokio::time::timeout(IO_TIMEOUT, stream.read_line(&mut reply)).await; + matches!(read, Ok(Ok(n)) if n > 0) && reply.trim_end_matches(['\r', '\n']) == REPLY_OK +} + +#[cfg(windows)] +mod imp { + use super::{BindError, IpcCommand, exchange, handle}; + use std::time::Duration; + use tokio::net::windows::named_pipe::{ClientOptions, NamedPipeServer, ServerOptions}; + + #[cfg(not(debug_assertions))] + const ENDPOINT: &str = r"\\.\pipe\org.polyfrost.OneClient.ipc"; + #[cfg(debug_assertions)] + const ENDPOINT: &str = r"\\.\pipe\org.polyfrost.OneClient-dev.ipc"; + + const ERROR_ACCESS_DENIED: i32 = 5; + const ERROR_PIPE_BUSY: i32 = 231; + + const BUSY_RETRIES: usize = 5; + const BUSY_BACKOFF: Duration = Duration::from_millis(60); + + pub struct Listener { + server: NamedPipeServer, + } + + pub async fn bind() -> Result { + match ServerOptions::new().first_pipe_instance(true).create(ENDPOINT) { + Ok(server) => Ok(Listener { server }), + Err(err) if err.raw_os_error() == Some(ERROR_ACCESS_DENIED) => Err(BindError::Taken), + Err(err) => Err(BindError::Io(err)), + } + } + + pub async fn send(request: &str) -> bool { + for _ in 0..BUSY_RETRIES { + match ClientOptions::new().open(ENDPOINT) { + Ok(client) => return exchange(client, request).await, + Err(err) if err.raw_os_error() == Some(ERROR_PIPE_BUSY) => { + tokio::time::sleep(BUSY_BACKOFF).await; + } + Err(_) => return false, + } + } + false + } + + pub async fn serve(listener: Listener, on_command: impl Fn(IpcCommand)) { + let mut server = listener.server; + + loop { + if server.connect().await.is_err() { + return; + } + + let next = match ServerOptions::new().create(ENDPOINT) { + Ok(next) => next, + Err(_) => return, + }; + let connected = std::mem::replace(&mut server, next); + + handle(connected, &on_command).await; + } + } +} + +#[cfg(unix)] +mod imp { + use super::{BindError, IpcCommand, exchange, handle}; + use std::path::PathBuf; + use tokio::net::{UnixListener, UnixStream}; + + fn endpoint() -> Option { + oneclient_common::paths::launcher_dir() + .ok() + .map(|dir| dir.join("ipc.sock")) + } + + pub struct Listener { + listener: UnixListener, + path: PathBuf, + } + + impl Drop for Listener { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } + } + + pub async fn bind() -> Result { + let Some(path) = endpoint() else { + return Err(BindError::Io(std::io::Error::other("no data directory"))); + }; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(BindError::Io)?; + } + + match UnixListener::bind(&path) { + Ok(listener) => Ok(Listener { listener, path }), + Err(err) if err.kind() == std::io::ErrorKind::AddrInUse => { + let _ = std::fs::remove_file(&path); + UnixListener::bind(&path) + .map(|listener| Listener { listener, path }) + .map_err(BindError::Io) + } + Err(err) => Err(BindError::Io(err)), + } + } + + pub async fn send(request: &str) -> bool { + let Some(path) = endpoint() else { + return false; + }; + let Ok(stream) = UnixStream::connect(&path).await else { + return false; + }; + exchange(stream, request).await + } + + pub async fn serve(listener: Listener, on_command: impl Fn(IpcCommand)) { + loop { + match listener.listener.accept().await { + Ok((stream, _)) => handle(stream, &on_command).await, + Err(_) => return, + } + } + } +} + +pub use imp::Listener; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_launch_request_survives_the_wire() { + let command = IpcCommand::Launch("fabric-1-20".into()); + assert_eq!(IpcCommand::decode(&command.encode()), Some(command)); + } + + #[test] + fn focus_survives_the_wire() { + assert_eq!(IpcCommand::decode(&IpcCommand::Focus.encode()), Some(IpcCommand::Focus)); + } + + #[test] + fn a_folder_with_spaces_is_not_split() { + let command = IpcCommand::Launch("My Pack (1.8.9)".into()); + assert_eq!(IpcCommand::decode(&command.encode()), Some(command)); + } + + #[test] + fn line_endings_are_stripped() { + assert_eq!( + IpcCommand::decode("LAUNCH pack\r\n"), + Some(IpcCommand::Launch("pack".into())), + ); + } + + #[test] + fn junk_decodes_to_nothing() { + assert_eq!(IpcCommand::decode(""), None); + assert_eq!(IpcCommand::decode("LAUNCH"), None); + assert_eq!(IpcCommand::decode("LAUNCH "), None); + assert_eq!(IpcCommand::decode("QUIT"), None); + } + + #[test] + fn a_bare_start_asks_only_for_the_window() { + assert_eq!(request_for(&Cli::default()), IpcCommand::Focus); + assert_eq!( + request_for(&Cli { launch: Some("pack".into()) }), + IpcCommand::Launch("pack".into()), + ); + } +} diff --git a/packages/oneclient_app/src/layout/mod.rs b/packages/oneclient_app/src/layout/mod.rs index ac882c8b..7f2cb598 100644 --- a/packages/oneclient_app/src/layout/mod.rs +++ b/packages/oneclient_app/src/layout/mod.rs @@ -2,6 +2,7 @@ mod animated_outlet; mod app_shell; mod cluster_shell; mod onboarding_shell; +mod pending_launch; mod root_layout; mod settings_shell; @@ -10,5 +11,6 @@ pub(crate) use app_shell::gradient_overlay_radial; pub use app_shell::{AppShell, HOME_BACKGROUND_ASSET, HomeArtPrefetch}; pub use cluster_shell::{ClusterShell, cluster_content}; pub use onboarding_shell::OnboardingShell; +pub use pending_launch::PendingLaunchDriver; pub use root_layout::RootLayout; pub use settings_shell::SettingsShell; diff --git a/packages/oneclient_app/src/layout/pending_launch.rs b/packages/oneclient_app/src/layout/pending_launch.rs new file mode 100644 index 00000000..db28ab66 --- /dev/null +++ b/packages/oneclient_app/src/layout/pending_launch.rs @@ -0,0 +1,133 @@ +use freya::prelude::*; +use freya::router::*; +use oneclient_cluster::Cluster; + +use crate::Actions; +use crate::components::IconType; +use crate::hooks::{use_dispatch, use_launcher, use_pending_launch, use_settings_snapshot}; +use crate::launcher; +use crate::routes::Route; +use crate::state::LaunchBlock; + +#[derive(PartialEq)] +pub struct PendingLaunchDriver; + +impl Component for PendingLaunchDriver { + fn render(&self) -> impl IntoElement { + let dispatch = use_dispatch(); + let waiting = use_pending_launch().is_some(); + let ready = use_launcher().ready; + let onboarded = use_settings_snapshot().settings.seen_onboarding; + + let router = RouterContext::get(); + + let armed = waiting && ready && onboarded; + use_side_effect_with_deps(&armed, move |&armed| { + if !armed { + return; + } + + let Some(folder) = dispatch.take_pending_launch() else { + return; + }; + + let actions = dispatch.clone(); + spawn(async move { launch_shortcut(actions, router, folder).await }); + }); + + rect().into_element() + } +} + +async fn launch_shortcut(actions: Actions, router: RouterContext, folder: String) { + let Ok(state) = launcher::state() else { return }; + + let found = match state.clusters.find_by_folder_name(&folder).await { + Ok(found) => found, + Err(err) => { + tracing::error!(folder, "shortcut lookup failed: {err:#}"); + actions + .notify("Couldn't open that version") + .body(format!("{err:#}")) + .error() + .send(); + return; + } + }; + + let Some(cluster) = found else { + tracing::warn!(folder, "shortcut names a cluster that no longer exists"); + actions.report_missing_shortcut_target(&folder); + return; + }; + + let _ = router.replace(Route::ClusterOverview { + cluster_id: cluster.id, + }); + + let Some(block) = actions.launch_block(cluster.id) else { + actions.launch_cluster(cluster.id); + return; + }; + + let blocker = blocker_name(&cluster, block).await; + report_already_open(&actions, &cluster, block, blocker.as_deref()); +} + +async fn blocker_name(cluster: &Cluster, block: LaunchBlock) -> Option { + if block.cluster_id() == cluster.id { + return None; + } + + let Ok(state) = launcher::state() else { + return Some("Another version".to_string()); + }; + + Some( + state + .clusters + .get(block.cluster_id()) + .await + .map_or_else(|_| "Another version".to_string(), |other| other.name), + ) +} + +fn report_already_open( + actions: &Actions, + cluster: &Cluster, + block: LaunchBlock, + blocker: Option<&str>, +) { + let running = matches!(block, LaunchBlock::Running(_)); + + tracing::info!( + cluster_id = cluster.id, + blocking = block.cluster_id(), + running, + "refusing a shortcut launch, a game is already open" + ); + + let title = if running { + "Minecraft is already running" + } else { + "Minecraft is already starting" + }; + + let body = match (blocker, running) { + (None, true) => format!("{} is open already.", cluster.name), + (None, false) => format!("{} is on its way up.", cluster.name), + (Some(other), true) => { + format!("{other} is open. Close it before launching {}.", cluster.name) + } + (Some(other), false) => format!( + "{other} is on its way up. Wait for it before launching {}.", + cluster.name + ), + }; + + actions + .notify(title) + .body(body) + .icon(IconType::Rocket02) + .send(); +} diff --git a/packages/oneclient_app/src/layout/root_layout.rs b/packages/oneclient_app/src/layout/root_layout.rs index 06f0c851..e2caf26a 100644 --- a/packages/oneclient_app/src/layout/root_layout.rs +++ b/packages/oneclient_app/src/layout/root_layout.rs @@ -7,7 +7,7 @@ use crate::components::{ UpdatePromptOverlay, }; use crate::hooks::{SplashState, use_provide_splash}; -use crate::layout::HomeArtPrefetch; +use crate::layout::{HomeArtPrefetch, PendingLaunchDriver}; use crate::motion::AnimationClockDriver; use crate::routes::Route; use crate::theme; @@ -78,5 +78,6 @@ impl Component for RootLayout { .child(SplashCurtain) .child(AnimationClockDriver) .child(HomeArtPrefetch) + .child(PendingLaunchDriver) } } diff --git a/packages/oneclient_app/src/lib.rs b/packages/oneclient_app/src/lib.rs index 9882f478..67413770 100644 --- a/packages/oneclient_app/src/lib.rs +++ b/packages/oneclient_app/src/lib.rs @@ -4,10 +4,13 @@ #![recursion_limit = "256"] mod assets; +pub mod cli; mod components; pub mod hooks; pub mod events; +pub(crate) mod file_content; mod install; +pub mod ipc; mod launcher; pub mod state; mod transfer; @@ -15,8 +18,10 @@ mod layout; mod motion; mod notifications; pub mod platform; +pub mod protocol; pub mod recovery; mod routes; +pub mod shortcut; pub mod theme; mod ui; pub mod updater; diff --git a/packages/oneclient_app/src/main.rs b/packages/oneclient_app/src/main.rs index 272e2ef3..033701b8 100644 --- a/packages/oneclient_app/src/main.rs +++ b/packages/oneclient_app/src/main.rs @@ -7,25 +7,31 @@ use freya::prelude::*; use freya::radio::use_init_radio_station; use oneclient_app::state::{AppChannel, AppState}; +use oneclient_app::ipc::{self, Claim}; use oneclient_app::{ - Actions, ConfirmLinkOverlay, EventPump, LinkConfirmState, constants, events, router, theme, - use_provide_actions, use_provide_link_confirm, + Actions, ConfirmLinkOverlay, EventPump, LinkConfirmState, cli, constants, events, platform, + router, theme, use_provide_actions, use_provide_link_confirm, }; +use std::cell::Cell; use tokio::runtime::Builder; -struct OneClientApp; +struct OneClientApp { + boot_launch: Cell>, + ipc: Cell>, +} impl App for OneClientApp { fn render(&self) -> impl IntoElement { - // Radio state is `!Send` so every writer runs on the UI thread `spawn_forever` let station = use_init_radio_station::(AppState::default); + let boot_launch = self.boot_launch.take(); + let ipc_listener = self.ipc.take(); + let actions = use_hook(move || { let (signals_tx, signals_rx) = tokio::sync::mpsc::unbounded_channel(); let (events_bus, events_rx) = oneclient_events::EventBus::channel(); let actions = Actions::new(station, signals_tx, events_bus.clone()); - // Started first so nothing emitted during startup waits on a consumer spawn_forever( EventPump { events: events_rx, @@ -39,8 +45,6 @@ impl App for OneClientApp { let rescue_bus = events_bus.clone(); spawn_forever(async move { match events::start_launcher(station, events_bus).await { - // Must follow startup `sync_bundles` needs the launcher handle and firing - // it early leaves `syncing_bundles` stuck disabling every launch button Ok(()) => startup.sync_bundles(), Err(err) => { events::report_startup_failure(&station, &err); @@ -49,6 +53,21 @@ impl App for OneClientApp { } }); + if let Some(folder) = boot_launch { + actions.request_launch_by_folder(folder); + } + + if let Some(listener) = ipc_listener { + let served = actions.clone(); + spawn_forever(ipc::serve(listener, move |command| match command { + ipc::IpcCommand::Launch(folder) => { + platform::focus_window(); + served.request_launch_by_folder(folder); + } + ipc::IpcCommand::Focus => platform::focus_window(), + })); + } + actions }); @@ -66,6 +85,8 @@ impl App for OneClientApp { } fn main() { + let cli = cli::parse(); + let mut builder = Builder::new_multi_thread(); builder.enable_all().max_blocking_threads(64); @@ -76,6 +97,16 @@ fn main() { let rt = builder.build().unwrap(); let _tokio_guard = rt.enter(); + let mut unprotected = None; + let ipc = match rt.block_on(ipc::claim(&cli)) { + Claim::Forwarded => return, + Claim::Primary(listener) => Some(listener), + Claim::Solo(reason) => { + unprotected = Some(reason); + None + } + }; + let settings = rt.block_on(oneclient_core::settings::store::load_settings(None)); if settings.log_debug { @@ -85,13 +116,30 @@ fn main() { } .expect("Failed to initialize logger"); + if let Some(reason) = unprotected { + tracing::warn!("no single-instance endpoint, a second launcher can start: {reason}"); + } + + match oneclient_app::shortcut::launcher_exe() + .and_then(|exe| oneclient_app::protocol::register(&exe)) + { + Ok(()) => {} + Err(err) => tracing::warn!( + "could not register the {}:// handler: {err:#}", + oneclient_app::protocol::SCHEME + ), + } + let _sentry_guard = oneclient_core::reporting::init(settings.crash_reporting); #[cfg(target_os = "macos")] oneclient_app::platform::macos::loop_memory_collector(); - let window_config = WindowConfig::new_app(OneClientApp) + let window_config = WindowConfig::new_app(OneClientApp { + boot_launch: Cell::new(cli.launch), + ipc: Cell::new(ipc), + }) .with_title(constants::WINDOW_TITLE) .with_app_id(constants::WINDOW_APP_ID) .with_icon(LaunchConfig::window_icon(include_bytes!( diff --git a/packages/oneclient_app/src/platform.rs b/packages/oneclient_app/src/platform.rs index d6cc40a1..df8899c9 100644 --- a/packages/oneclient_app/src/platform.rs +++ b/packages/oneclient_app/src/platform.rs @@ -1,3 +1,12 @@ +pub fn focus_window() { + use freya::prelude::{Platform, WinitPlatformExt}; + + Platform::get().with_window(None, |win| { + win.set_minimized(false); + win.focus_window(); + }); +} + pub fn open_url(url: &str) { if let Err(err) = open::that_detached(url) { tracing::warn!("failed to open url {url}: {err}"); diff --git a/packages/oneclient_app/src/protocol/linux.rs b/packages/oneclient_app/src/protocol/linux.rs new file mode 100644 index 00000000..61971fe5 --- /dev/null +++ b/packages/oneclient_app/src/protocol/linux.rs @@ -0,0 +1,50 @@ +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; + +use super::SCHEME; +use crate::file_content::url_handler_entry; + +const ICON: &str = "oneclient_app"; + +fn handler_name() -> String { + format!("{SCHEME}-url-handler.desktop") +} + +fn applications_dir() -> Option { + directories::BaseDirs::new().map(|dirs| dirs.data_dir().join("applications")) +} + +pub fn register(exe: &Path) -> Result<()> { + let dir = applications_dir().context("no user data directory")?; + let path = dir.join(handler_name()); + let entry = url_handler_entry(exe, SCHEME, ICON); + + if std::fs::read_to_string(&path).is_ok_and(|existing| existing == entry) { + return Ok(()); + } + + std::fs::create_dir_all(&dir)?; + std::fs::write(&path, entry)?; + announce(&dir); + + tracing::info!(scheme = SCHEME, "registered the url scheme"); + Ok(()) +} + +fn announce(dir: &Path) { + let _ = std::process::Command::new("update-desktop-database") + .arg(dir) + .output(); + let _ = std::process::Command::new("xdg-mime") + .args([ + "default", + &handler_name(), + &format!("x-scheme-handler/{SCHEME}"), + ]) + .output(); +} + +pub fn is_registered() -> bool { + applications_dir().is_some_and(|dir| dir.join(handler_name()).is_file()) +} diff --git a/packages/oneclient_app/src/protocol/macos.rs b/packages/oneclient_app/src/protocol/macos.rs new file mode 100644 index 00000000..a4e2a6c4 --- /dev/null +++ b/packages/oneclient_app/src/protocol/macos.rs @@ -0,0 +1,11 @@ +use std::path::Path; + +use anyhow::Result; + +pub fn register(_exe: &Path) -> Result<()> { + Ok(()) +} + +pub fn is_registered() -> bool { + false +} diff --git a/packages/oneclient_app/src/protocol/mod.rs b/packages/oneclient_app/src/protocol/mod.rs new file mode 100644 index 00000000..a95d0698 --- /dev/null +++ b/packages/oneclient_app/src/protocol/mod.rs @@ -0,0 +1,128 @@ +#[cfg(all(unix, not(target_os = "macos")))] +mod linux; +#[cfg(target_os = "macos")] +mod macos; +#[cfg(windows)] +mod registry; +#[cfg(windows)] +mod windows; + +#[cfg(all(unix, not(target_os = "macos")))] +use linux as imp; +#[cfg(target_os = "macos")] +use macos as imp; +#[cfg(windows)] +use windows as imp; + +use std::path::Path; + +use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, percent_decode_str, utf8_percent_encode}; + +#[cfg(debug_assertions)] +pub const SCHEME: &str = "oneclient-dev"; +#[cfg(not(debug_assertions))] +pub const SCHEME: &str = "oneclient"; + +const LAUNCH_HOST: &str = "launch"; + +const FOLDER: &AsciiSet = &NON_ALPHANUMERIC + .remove(b'-') + .remove(b'.') + .remove(b'_') + .remove(b'~'); + +#[must_use] +pub fn launch_url(folder: &str) -> String { + format!( + "{SCHEME}://{LAUNCH_HOST}/{}", + utf8_percent_encode(folder, FOLDER) + ) +} + +#[must_use] +pub fn parse_launch_url(raw: &str) -> Option { + let (scheme, rest) = raw.trim().split_once("://")?; + if !scheme.eq_ignore_ascii_case(SCHEME) { + return None; + } + + let (host, path) = rest.trim_end_matches('/').split_once('/')?; + if !host.eq_ignore_ascii_case(LAUNCH_HOST) { + return None; + } + + let folder = percent_decode_str(path).decode_utf8().ok()?; + let folder = folder.trim(); + (!folder.is_empty()).then(|| folder.to_string()) +} + +pub fn register(exe: &Path) -> anyhow::Result<()> { + imp::register(exe) +} + +#[must_use] +pub fn is_registered() -> bool { + imp::is_registered() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_folder_survives_the_round_trip() { + let folder = "26.1.2 Fabric"; + assert_eq!(parse_launch_url(&launch_url(folder)).as_deref(), Some(folder)); + } + + #[test] + fn a_space_is_encoded_rather_than_left_to_split_the_url() { + assert!(launch_url("My Pack").ends_with("/My%20Pack")); + } + + #[test] + fn punctuation_in_a_folder_name_round_trips() { + for folder in ["My Pack (1.8.9)", "a/b", "100%", "zażółć gęślą jaźń"] { + assert_eq!( + parse_launch_url(&launch_url(folder)).as_deref(), + Some(folder), + "failed for {folder}", + ); + } + } + + #[test] + fn a_trailing_slash_is_tolerated() { + assert_eq!( + parse_launch_url(&format!("{SCHEME}://launch/pack/")).as_deref(), + Some("pack"), + ); + } + + #[test] + fn the_scheme_and_host_are_matched_case_insensitively() { + assert_eq!( + parse_launch_url(&format!("{}://LAUNCH/pack", SCHEME.to_uppercase())).as_deref(), + Some("pack"), + ); + } + + #[test] + fn a_foreign_url_is_not_ours() { + assert_eq!(parse_launch_url("https://polyfrost.org/pack"), None); + assert_eq!(parse_launch_url("file:///etc/passwd"), None); + assert_eq!(parse_launch_url(""), None); + } + + #[test] + fn an_unknown_host_is_refused() { + assert_eq!(parse_launch_url(&format!("{SCHEME}://install/pack")), None); + } + + #[test] + fn an_empty_folder_is_not_a_request() { + assert_eq!(parse_launch_url(&format!("{SCHEME}://launch/")), None); + assert_eq!(parse_launch_url(&format!("{SCHEME}://launch/%20")), None); + assert_eq!(parse_launch_url(&format!("{SCHEME}://launch")), None); + } +} diff --git a/packages/oneclient_app/src/protocol/registry.rs b/packages/oneclient_app/src/protocol/registry.rs new file mode 100644 index 00000000..a5920116 --- /dev/null +++ b/packages/oneclient_app/src/protocol/registry.rs @@ -0,0 +1,106 @@ +use anyhow::{Result, bail}; +use windows::Win32::Foundation::{ERROR_SUCCESS, WIN32_ERROR}; +use windows::Win32::System::Registry::{ + HKEY, HKEY_CURRENT_USER, KEY_READ, KEY_WRITE, REG_OPTION_NON_VOLATILE, REG_SZ, RegCloseKey, + RegCreateKeyExW, RegOpenKeyExW, RegQueryValueExW, RegSetValueExW, +}; +use windows::core::{HSTRING, PCWSTR}; + +fn check(status: WIN32_ERROR, what: &str) -> Result<()> { + if status == ERROR_SUCCESS { + return Ok(()); + } + bail!("{what} failed with Windows error {}", status.0) +} + +fn close(key: HKEY) { + unsafe { + let _ = RegCloseKey(key); + }; +} + +fn name_ptr(name: Option<&HSTRING>) -> PCWSTR { + name.map_or_else(PCWSTR::null, |name| PCWSTR(name.as_ptr())) +} + +pub fn write_string(subkey: &str, name: Option<&str>, value: &str) -> Result<()> { + let mut key = HKEY::default(); + let status = unsafe { + RegCreateKeyExW( + HKEY_CURRENT_USER, + &HSTRING::from(subkey), + None, + PCWSTR::null(), + REG_OPTION_NON_VOLATILE, + KEY_WRITE, + None, + &raw mut key, + None, + ) + }; + check(status, "creating the registry key")?; + + let wide: Vec = value.encode_utf16().chain(std::iter::once(0)).collect(); + let bytes = unsafe { std::slice::from_raw_parts(wide.as_ptr().cast::(), wide.len() * 2) }; + + let name = name.map(HSTRING::from); + let status = + unsafe { RegSetValueExW(key, name_ptr(name.as_ref()), None, REG_SZ, Some(bytes)) }; + close(key); + + check(status, "writing the registry value") +} + +pub fn read_string(subkey: &str, name: Option<&str>) -> Option { + let mut key = HKEY::default(); + let status = unsafe { + RegOpenKeyExW( + HKEY_CURRENT_USER, + &HSTRING::from(subkey), + None, + KEY_READ, + &raw mut key, + ) + }; + if status != ERROR_SUCCESS { + return None; + } + + let name = name.map(HSTRING::from); + let name = name_ptr(name.as_ref()); + + let mut size = 0u32; + let status = unsafe { RegQueryValueExW(key, name, None, None, None, Some(&raw mut size)) }; + if status != ERROR_SUCCESS || size == 0 { + close(key); + return None; + } + + let mut buffer = vec![0u8; size as usize]; + let status = unsafe { + RegQueryValueExW( + key, + name, + None, + None, + Some(buffer.as_mut_ptr()), + Some(&raw mut size), + ) + }; + close(key); + if status != ERROR_SUCCESS { + return None; + } + + buffer.truncate(size as usize); + let wide: Vec = buffer + .chunks_exact(2) + .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) + .collect(); + + Some( + String::from_utf16_lossy(&wide) + .trim_end_matches('\0') + .to_string(), + ) +} diff --git a/packages/oneclient_app/src/protocol/windows.rs b/packages/oneclient_app/src/protocol/windows.rs new file mode 100644 index 00000000..382d6eab --- /dev/null +++ b/packages/oneclient_app/src/protocol/windows.rs @@ -0,0 +1,36 @@ +use std::path::Path; + +use anyhow::Result; + +use super::SCHEME; +use super::registry::{read_string, write_string}; + +fn root_key() -> String { + format!(r"Software\Classes\{SCHEME}") +} + +fn command_key() -> String { + format!(r"{}\shell\open\command", root_key()) +} + +pub fn register(exe: &Path) -> Result<()> { + let exe = exe.display().to_string(); + let command = format!("\"{exe}\" \"%1\""); + + if read_string(&command_key(), None).as_deref() == Some(command.as_str()) { + return Ok(()); + } + + let root = root_key(); + write_string(&root, None, &format!("URL:{SCHEME} Protocol"))?; + write_string(&root, Some("URL Protocol"), "")?; + write_string(&format!(r"{root}\DefaultIcon"), None, &format!("{exe},0"))?; + write_string(&command_key(), None, &command)?; + + tracing::info!(scheme = SCHEME, "registered the url scheme"); + Ok(()) +} + +pub fn is_registered() -> bool { + read_string(&command_key(), None).is_some_and(|value| !value.is_empty()) +} diff --git a/packages/oneclient_app/src/shortcut/linux.rs b/packages/oneclient_app/src/shortcut/linux.rs new file mode 100644 index 00000000..cd7cc23c --- /dev/null +++ b/packages/oneclient_app/src/shortcut/linux.rs @@ -0,0 +1,19 @@ +use std::os::unix::fs::PermissionsExt; +use std::path::Path; + +use anyhow::Result; + +use super::ShortcutRequest; +use crate::file_content::desktop_entry; + +pub const EXTENSION: &str = "desktop"; + +const ICON: &str = "oneclient_app"; + +pub fn write(request: &ShortcutRequest, exe: &Path, path: &Path) -> Result<()> { + let entry = desktop_entry(&request.cluster_name, exe, &request.folder_name, ICON); + std::fs::write(path, entry)?; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755))?; + + Ok(()) +} diff --git a/packages/oneclient_app/src/shortcut/macos.rs b/packages/oneclient_app/src/shortcut/macos.rs new file mode 100644 index 00000000..c022126b --- /dev/null +++ b/packages/oneclient_app/src/shortcut/macos.rs @@ -0,0 +1,88 @@ +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; + +use anyhow::Result; + +use super::ShortcutRequest; +use crate::file_content::{info_plist, shell_script}; + +pub const EXTENSION: &str = "app"; + +const EXECUTABLE: &str = "launch"; + +pub fn write(request: &ShortcutRequest, exe: &Path, path: &Path) -> Result<()> { + let result = build(request, exe, path); + if result.is_err() { + let _ = std::fs::remove_dir_all(path); + } + result +} + +fn build(request: &ShortcutRequest, exe: &Path, path: &Path) -> Result<()> { + let contents = path.join("Contents"); + let macos = contents.join("MacOS"); + let resources = contents.join("Resources"); + std::fs::create_dir_all(&macos)?; + std::fs::create_dir_all(&resources)?; + + let script = macos.join(EXECUTABLE); + std::fs::write(&script, shell_script(exe, &request.folder_name))?; + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755))?; + + let icon = copy_icon(exe, &resources); + std::fs::write( + contents.join("Info.plist"), + info_plist( + &request.cluster_name, + EXECUTABLE, + &request.folder_name, + icon.as_deref(), + ), + )?; + + Ok(()) +} + +fn copy_icon(exe: &Path, resources: &Path) -> Option { + let source = bundle_root(exe)? + .join("Contents") + .join("Resources") + .join("icon.icns"); + + std::fs::copy(&source, resources.join("icon.icns")).ok()?; + Some("icon".to_string()) +} + +fn bundle_root(exe: &Path) -> Option { + let macos = exe.parent()?; + let contents = macos.parent()?; + let app = contents.parent()?; + + (macos.file_name()? == "MacOS" + && contents.file_name()? == "Contents" + && app.extension()? == "app") + .then(|| app.to_path_buf()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_installed_binary_finds_its_bundle() { + let exe = PathBuf::from("/Applications/OneClient.app/Contents/MacOS/oneclient_app"); + assert_eq!( + bundle_root(&exe), + Some(PathBuf::from("/Applications/OneClient.app")), + ); + } + + #[test] + fn a_bare_binary_has_no_bundle() { + assert_eq!(bundle_root(&PathBuf::from("/usr/local/bin/oneclient_app")), None); + assert_eq!( + bundle_root(&PathBuf::from("/tmp/target/debug/oneclient_app")), + None, + ); + } +} diff --git a/packages/oneclient_app/src/shortcut/mod.rs b/packages/oneclient_app/src/shortcut/mod.rs new file mode 100644 index 00000000..cbbb1b0e --- /dev/null +++ b/packages/oneclient_app/src/shortcut/mod.rs @@ -0,0 +1,187 @@ +#[cfg(all(unix, not(target_os = "macos")))] +mod linux; +#[cfg(target_os = "macos")] +mod macos; +#[cfg(windows)] +mod windows; + +#[cfg(all(unix, not(target_os = "macos")))] +use linux as imp; +#[cfg(target_os = "macos")] +use macos as imp; +#[cfg(windows)] +use windows as imp; + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; + +const MAX_STEM: usize = 96; +const FALLBACK_STEM: &str = "OneClient"; +const MAX_COLLISIONS: usize = 20; +const FORBIDDEN: &[char] = &['<', '>', ':', '"', '/', '\\', '|', '?', '*']; + +const RESERVED: &[&str] = &[ + "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", + "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", +]; + +pub struct ShortcutRequest { + pub cluster_name: String, + pub folder_name: String, + pub dir: PathBuf, +} + +pub fn create(request: &ShortcutRequest) -> Result { + let exe = launcher_exe()?; + + std::fs::create_dir_all(&request.dir) + .with_context(|| format!("couldn't open {}", request.dir.display()))?; + + let path = unique_path(&request.dir, &file_stem(&request.cluster_name), imp::EXTENSION)?; + imp::write(request, &exe, &path) + .with_context(|| format!("couldn't write {}", path.display()))?; + + tracing::info!(path = %path.display(), folder = request.folder_name, "wrote cluster shortcut"); + Ok(path) +} + +#[must_use] +pub fn default_dir() -> Option { + directories::UserDirs::new() + .and_then(|dirs| dirs.desktop_dir().map(Path::to_path_buf)) + .filter(|dir| dir.is_dir()) +} + +pub fn launcher_exe() -> Result { + #[cfg(all(unix, not(target_os = "macos")))] + if let Some(appimage) = std::env::var_os("APPIMAGE") { + let path = PathBuf::from(appimage); + if path.is_file() { + return Ok(path); + } + } + + let exe = std::env::current_exe().context("couldn't locate the OneClient executable")?; + Ok(dunce::canonicalize(&exe).unwrap_or(exe)) +} + +fn file_stem(name: &str) -> String { + let replaced: String = name + .chars() + .map(|c| { + if FORBIDDEN.contains(&c) || c.is_control() { + '-' + } else { + c + } + }) + .collect(); + + let trimmed = replaced.trim().trim_end_matches(['.', ' ']).trim(); + if trimmed.is_empty() { + return FALLBACK_STEM.to_string(); + } + + let truncated: String = trimmed.chars().take(MAX_STEM).collect(); + let truncated = truncated.trim_end().trim_end_matches('.').to_string(); + if truncated.is_empty() { + return FALLBACK_STEM.to_string(); + } + + if RESERVED.iter().any(|r| r.eq_ignore_ascii_case(&truncated)) { + return format!("{truncated}-shortcut"); + } + + truncated +} + +fn unique_path(dir: &Path, stem: &str, extension: &str) -> Result { + for attempt in 1..=MAX_COLLISIONS { + let name = if attempt == 1 { + format!("{stem}.{extension}") + } else { + format!("{stem} ({attempt}).{extension}") + }; + + let candidate = dir.join(name); + if !candidate.exists() { + return Ok(candidate); + } + } + + bail!("there are already {MAX_COLLISIONS} shortcuts named \"{stem}\" in that folder") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_ordinary_name_is_left_alone() { + assert_eq!(file_stem("Fabric 1.20"), "Fabric 1.20"); + } + + #[test] + fn path_separators_cannot_escape_the_chosen_folder() { + assert_eq!(file_stem("../../etc/passwd"), "..-..-etc-passwd"); + assert_eq!(file_stem(r"a\b"), "a-b"); + } + + #[test] + fn a_name_that_is_all_illegal_falls_back() { + assert_eq!(file_stem(" "), FALLBACK_STEM); + assert_eq!(file_stem(""), FALLBACK_STEM); + assert_eq!(file_stem("..."), FALLBACK_STEM); + } + + #[test] + fn reserved_device_names_are_pushed_out_of_the_way() { + assert_eq!(file_stem("NUL"), "NUL-shortcut"); + assert_eq!(file_stem("com1"), "com1-shortcut"); + assert_eq!(file_stem("CONSOLE"), "CONSOLE"); + } + + #[test] + fn a_trailing_dot_is_dropped_before_windows_drops_it_silently() { + assert_eq!(file_stem("Pack."), "Pack"); + assert_eq!(file_stem("Pack "), "Pack"); + } + + #[test] + fn a_very_long_name_is_cut_to_a_usable_length() { + let stem = file_stem(&"ą".repeat(400)); + assert_eq!(stem.chars().count(), MAX_STEM); + } + + #[test] + fn a_control_character_cannot_reach_the_file_name() { + assert_eq!(file_stem("Pack\nEvil"), "Pack-Evil"); + } + + #[test] + fn the_first_shortcut_gets_the_plain_name() { + let dir = std::env::temp_dir().join("oneclient-shortcut-plain"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + + let path = unique_path(&dir, "Pack", "url").unwrap(); + assert_eq!(path.file_name().unwrap(), "Pack.url"); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_second_shortcut_does_not_replace_the_first() { + let dir = std::env::temp_dir().join("oneclient-shortcut-collide"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("Pack.url"), b"existing").unwrap(); + + let path = unique_path(&dir, "Pack", "url").unwrap(); + assert_eq!(path.file_name().unwrap(), "Pack (2).url"); + assert_eq!(std::fs::read(dir.join("Pack.url")).unwrap(), b"existing"); + + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/packages/oneclient_app/src/shortcut/windows.rs b/packages/oneclient_app/src/shortcut/windows.rs new file mode 100644 index 00000000..26c5b4d3 --- /dev/null +++ b/packages/oneclient_app/src/shortcut/windows.rs @@ -0,0 +1,23 @@ +use std::path::Path; + +use anyhow::{Result, bail}; + +use super::ShortcutRequest; +use crate::file_content::url_shortcut; +use crate::protocol; + +pub const EXTENSION: &str = "url"; + +pub fn write(request: &ShortcutRequest, exe: &Path, path: &Path) -> Result<()> { + if !protocol::is_registered() { + bail!( + "the {} :// handler is not registered on this machine", + protocol::SCHEME + ); + } + + let url = protocol::launch_url(&request.folder_name); + std::fs::write(path, url_shortcut(&url, exe))?; + + Ok(()) +} diff --git a/packages/oneclient_app/src/state.rs b/packages/oneclient_app/src/state.rs index eae4ac29..7bcd61bb 100644 --- a/packages/oneclient_app/src/state.rs +++ b/packages/oneclient_app/src/state.rs @@ -21,6 +21,7 @@ pub enum AppChannel { AccountSwitcher, MicrosoftLogin, Installs, + PendingLaunch, } impl RadioChannel for AppChannel {} @@ -40,6 +41,7 @@ pub struct AppState { pub account_switcher_open: bool, pub microsoft_login: Option, pub installs: InstallState, + pub pending_launch: Option, } /// In-flight installs so the button that started one stays disabled until it lands @@ -103,6 +105,21 @@ pub struct LoginProgress { pub total: u64, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LaunchBlock { + Starting(i64), + Running(i64), +} + +impl LaunchBlock { + #[must_use] + pub fn cluster_id(self) -> i64 { + match self { + Self::Starting(id) | Self::Running(id) => id, + } + } +} + #[derive(Clone, Debug, Default, PartialEq)] pub struct GameState { pub stages: HashMap, @@ -121,13 +138,41 @@ impl GameState { /// Returns false if a launch is already in flight the re-entrancy guard pub fn begin_launch(&mut self, cluster_id: i64) -> bool { - if self.is_active(cluster_id) || self.is_launch_pending(cluster_id) { + if self.block_for(cluster_id).is_some() { return false; } self.pending.insert(cluster_id); true } + fn block_for(&self, cluster_id: i64) -> Option { + if self.is_running(cluster_id) { + Some(LaunchBlock::Running(cluster_id)) + } else if self.is_active(cluster_id) || self.is_launch_pending(cluster_id) { + Some(LaunchBlock::Starting(cluster_id)) + } else { + None + } + } + + #[must_use] + pub fn launch_block(&self, cluster_id: i64, parallel: bool) -> Option { + if let Some(block) = self.block_for(cluster_id) { + return Some(block); + } + + if parallel { + return None; + } + + self.stages + .keys() + .chain(self.pending.iter()) + .copied() + .filter(|id| *id != cluster_id) + .find_map(|id| self.block_for(id)) + } + pub fn finish_launch(&mut self, cluster_id: i64) { self.pending.remove(&cluster_id); } @@ -189,6 +234,43 @@ mod tests { assert!(game.begin_launch(1)); } + #[test] + fn a_shortcut_is_told_which_cluster_is_in_the_way() { + let mut game = GameState::default(); + game.stages.insert(1, LaunchStage::Running); + + assert_eq!(game.launch_block(1, false), Some(LaunchBlock::Running(1))); + assert_eq!(game.launch_block(2, false), Some(LaunchBlock::Running(1))); + assert_eq!(game.launch_block(2, true), None); + } + + #[test] + fn a_game_that_is_still_coming_up_still_blocks() { + let mut game = GameState::default(); + game.stages.insert(1, LaunchStage::Downloading); + + assert_eq!(game.launch_block(1, false), Some(LaunchBlock::Starting(1))); + assert_eq!(game.launch_block(2, false), Some(LaunchBlock::Starting(1))); + } + + #[test] + fn a_claim_with_no_stage_yet_blocks_too() { + let mut game = GameState::default(); + game.begin_launch(1); + + assert_eq!(game.launch_block(1, false), Some(LaunchBlock::Starting(1))); + assert_eq!(game.launch_block(2, false), Some(LaunchBlock::Starting(1))); + } + + #[test] + fn an_exited_game_is_out_of_the_way() { + let mut game = GameState::default(); + game.stages.insert(1, LaunchStage::Exited); + + assert_eq!(game.launch_block(2, false), None); + assert_eq!(game.launch_block(1, false), None); + } + #[test] fn the_button_disables_on_the_claim_alone() { let mut game = GameState::default(); diff --git a/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs b/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs index 17381e31..7933e2cd 100644 --- a/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs +++ b/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs @@ -70,6 +70,8 @@ impl Component for ClusterSettings { .width(Size::fill()) .height(Size::fill()) .spacing(4.) + .child(section_header("SHORTCUT")) + .child(ShortcutRow { cluster_id }.into_element()) .child(section_header("LOADER")) .child( LoaderRow { @@ -252,6 +254,32 @@ impl Component for ToggleRow { } } +#[derive(PartialEq)] +struct ShortcutRow { + cluster_id: i64, +} + +impl Component for ShortcutRow { + fn render(&self) -> impl IntoElement { + let cluster_id = self.cluster_id; + let dispatch = use_dispatch(); + + let button = Button::new() + .small() + .secondary() + .on_press(move |_| dispatch.create_cluster_shortcut(cluster_id)) + .text("Create Shortcut"); + + settings_row( + IconType::Rocket02, + "Desktop Shortcut", + "Save a shortcut that starts this version straight from your desktop, \ + without opening the launcher first.", + button, + ) + } +} + #[derive(PartialEq)] struct VerifyFilesRow { cluster_id: i64, diff --git a/packages/oneclient_cluster/src/manager.rs b/packages/oneclient_cluster/src/manager.rs index 9b77379d..489d625d 100644 --- a/packages/oneclient_cluster/src/manager.rs +++ b/packages/oneclient_cluster/src/manager.rs @@ -54,6 +54,14 @@ impl ClusterManager { Cluster::try_from_row(row) } + #[tracing::instrument(level = "debug", skip(self))] + pub async fn find_by_folder_name(&self, folder_name: &str) -> ClusterResult> { + cluster_dao::get_by_folder_name(&self.db, folder_name) + .await? + .map(Cluster::try_from_row) + .transpose() + } + #[tracing::instrument(level = "debug", skip(self))] pub async fn list(&self) -> ClusterResult> { let rows = cluster_dao::list_all(&self.db).await?; diff --git a/packages/oneclient_core/src/game/launch.rs b/packages/oneclient_core/src/game/launch.rs index 0d42e1fd..8bfb63ff 100644 --- a/packages/oneclient_core/src/game/launch.rs +++ b/packages/oneclient_core/src/game/launch.rs @@ -44,11 +44,30 @@ pub async fn launch_cluster( tracing::info!(cluster_id, search_for_java, "launching cluster"); let parallel = state.settings.read().allow_parallel_running_clusters; - if !parallel && state.games.is_running(cluster_id) { - tracing::warn!(cluster_id, "cluster already running; refusing launch"); + if !parallel && state.games.is_active(cluster_id) { + tracing::warn!(cluster_id, "cluster already launching or running; refusing launch"); return Err(GameError::AlreadyRunning(cluster_id).into()); } + let result = start(state, cluster_id, account, search_for_java).await; + + if result.is_err() { + state.games.remove(cluster_id); + state + .services + .events + .game_stage(cluster_id, LaunchStage::Exited); + } + + result +} + +async fn start( + state: &Arc, + cluster_id: i64, + account: &MinecraftAccount, + search_for_java: bool, +) -> LauncherResult { let events = state.services.events.clone(); let stage = |s: LaunchStage| { state.games.set_stage(cluster_id, s); @@ -76,6 +95,8 @@ pub async fn launch_cluster( return Err(GameError::DirectoryInUse(other).into()); } + state.games.set_dir(cluster_id, game_dir.clone()); + let progress = GroupedProgressSession::start( &state.services.events, format!("Launching {}", existing.name), @@ -338,7 +359,6 @@ pub async fn launch_cluster( stage(LaunchStage::Running); state.games.set_pid(cluster_id, pid); - state.games.set_dir(cluster_id, cwd.clone()); state.discord.set_presence(Presence::Playing { cluster: cluster.name.clone(), mc_version: cluster.mc_version.clone(), diff --git a/packages/oneclient_db/src/pool.rs b/packages/oneclient_db/src/pool.rs index 1cab41c2..7c807620 100644 --- a/packages/oneclient_db/src/pool.rs +++ b/packages/oneclient_db/src/pool.rs @@ -1,12 +1,15 @@ use sqlx::SqlitePool; -use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; +use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions}; use std::path::Path; use std::str::FromStr; +use std::time::Duration; use crate::DbError; pub type DbPool = SqlitePool; +const BUSY_TIMEOUT: Duration = Duration::from_secs(5); + #[tracing::instrument( skip(database_path), fields(database_path = %database_path.as_ref().display()) @@ -23,7 +26,9 @@ pub async fn connect(database_path: impl AsRef) -> Result .unwrap_or_else(|_| database_path.to_path_buf()) .display() ))? - .create_if_missing(true); + .create_if_missing(true) + .journal_mode(SqliteJournalMode::Wal) + .busy_timeout(BUSY_TIMEOUT); let pool = SqlitePoolOptions::new() .max_connections(4)