feat: shortcuts (#773)

This commit is contained in:
Maciek 2026-09-04 18:19:44 +02:00 • committed by LynithDev
parent 86f3049564
commit acd2c1d183
30 changed files with 1800 additions and 25 deletions

4
Cargo.lock generated
View file

@ -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",
]

View file

@ -125,6 +125,7 @@ serde_repr = {version = "=0.1.20"}
sysinfo = {version = "=0.39.5"}
toml = {version = "=1.1.4", default-features = false, features = ["parse", "serde"]}
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"]}

View file

@ -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"

View file

@ -4,18 +4,6 @@
<dict>
<key>CFBundleIconName</key>
<string>OneClient</string>
<!-- registers our uri handler on macos -->
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>OneClient</string>
<key>CFBundleURLSchemes</key>
<array>
<string>oneclient</string>
</array>
</dict>
</array>
<!-- allows us to request camera permissions (for things like voice chat) -->
<key>NSCameraUsageDescription</key>
<string>A Minecraft mod is requesting access to your camera.</string>

View file

@ -0,0 +1,84 @@
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Cli {
pub launch: Option<String>,
}
impl Cli {
fn from_args(args: impl IntoIterator<Item = String>) -> 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<String> {
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);
}
}

View file

@ -0,0 +1,248 @@
#![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::<Vec<_>>().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 {
// The value-level unescape runs before the Exec quoting one, so the
// backslash the shell layer wants has to survive doubled
'"' | '$' | '`' => {
out.push_str(r"\\");
out.push(c);
}
'\\' => out.push_str(r"\\\\"),
'%' => 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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
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!("\t<key>CFBundleIconFile</key>\n\t<string>{}</string>\n", xml_escape(icon))
});
format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
<plist version=\"1.0\">\n\
<dict>\n\
\t<key>CFBundleName</key>\n\t<string>{display}</string>\n\
\t<key>CFBundleDisplayName</key>\n\t<string>{display}</string>\n\
\t<key>CFBundleIdentifier</key>\n\t<string>org.polyfrost.OneClient.shortcut.{slug}</string>\n\
\t<key>CFBundleExecutable</key>\n\t<string>{executable}</string>\n\
{icon_entry}\
\t<key>CFBundlePackageType</key>\n\t<string>APPL</string>\n\
\t<key>CFBundleInfoDictionaryVersion</key>\n\t<string>6.0</string>\n\
\t<key>CFBundleShortVersionString</key>\n\t<string>1.0</string>\n\
\t<key>LSUIElement</key>\n\t<true/>\n\
</dict>\n\
</plist>\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.lines().any(|line| line.starts_with("Exec=/bin/sh")));
assert!(entry.contains(r#"Exec="/usr/bin/oneclient_app" --launch "pack""#));
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 an_install_path_with_a_backslash_survives_both_unescape_passes() {
let entry = desktop_entry(
"Pack",
&PathBuf::from(r"/opt/one\client/$app"),
"pack",
"oneclient_app",
);
assert!(entry.contains(r#"Exec="/opt/one\\\\client/\\$app" --launch "pack""#));
}
#[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 & <B>", "launch", "pack", Some("icon"));
assert!(plist.contains("<string>A &amp; &lt;B&gt;</string>"));
assert!(!plist.contains("<B>"));
}
#[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"));
}
}

View file

@ -864,8 +864,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

View file

@ -3,6 +3,7 @@ mod debounce;
mod actions;
mod overlay_claims;
mod queries;
mod shortcut_actions;
mod view_state;
pub use debounce::use_debounced;
@ -121,6 +122,13 @@ pub fn use_installs_snapshot() -> InstallState {
use_radio(AppChannel::Installs).read().installs.clone()
}
pub fn use_pending_launch() -> Option<String> {
use_radio(AppChannel::PendingLaunch)
.read()
.pending_launch
.clone()
}
pub fn use_microsoft_login_status() -> Option<LoginProgress> {
use_radio(AppChannel::MicrosoftLogin)
.read()

View file

@ -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<String> {
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<LaunchBlock> {
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<std::path::PathBuf> {
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())
}

View file

@ -0,0 +1,320 @@
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<Self> {
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
}
/// The accept loop runs off the caller's thread so a stalled client cannot hold
/// up the next shortcut launch; `on_command` still runs here
pub async fn serve(listener: Listener, on_command: impl Fn(IpcCommand)) {
let (commands, mut incoming) = tokio::sync::mpsc::unbounded_channel();
tokio::spawn(imp::serve(listener, commands));
while let Some(command) = incoming.recv().await {
on_command(command);
}
}
enum BindError {
/// Only Windows reports a held endpoint; the unix path clears a stale
/// socket and rebinds instead of surfacing this
#[cfg_attr(not(windows), allow(dead_code))]
Taken,
Io(std::io::Error),
}
async fn handle<S>(stream: S, commands: &tokio::sync::mpsc::UnboundedSender<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) => match commands.send(command) {
Ok(()) => format!("{REPLY_OK}\n"),
Err(_) => "ERR the launcher is shutting down\n".to_string(),
},
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<S>(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};
use tokio::sync::mpsc::UnboundedSender;
#[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<Listener, BindError> {
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, commands: UnboundedSender<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);
let commands = commands.clone();
tokio::spawn(async move { handle(connected, &commands).await });
}
}
}
#[cfg(unix)]
mod imp {
use super::{BindError, IpcCommand, exchange, handle};
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use tokio::net::{UnixListener, UnixStream};
use tokio::sync::mpsc::UnboundedSender;
fn endpoint() -> Option<PathBuf> {
oneclient_common::paths::config_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<Listener, BindError> {
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 bound(&path) {
Ok(listener) => Ok(listener),
Err(err) if err.kind() == std::io::ErrorKind::AddrInUse => {
let _ = std::fs::remove_file(&path);
bound(&path).map_err(BindError::Io)
}
Err(err) => Err(BindError::Io(err)),
}
}
/// The launcher directory is not private, so the endpoint itself has to
/// keep other local users from sending a launch of their choosing
fn bound(path: &Path) -> std::io::Result<Listener> {
let listener = UnixListener::bind(path)?;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
Ok(Listener { listener, path: path.to_path_buf() })
}
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, commands: UnboundedSender<IpcCommand>) {
loop {
match listener.listener.accept().await {
Ok((stream, _)) => {
let commands = commands.clone();
tokio::spawn(async move { handle(stream, &commands).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()),
);
}
}

View file

@ -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;

View file

@ -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<String> {
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();
}

View file

@ -7,7 +7,7 @@ use crate::components::{
UpdatePromptOverlay,
};
use crate::hooks::{SplashState, use_provide_overlay_claims, use_provide_splash};
use crate::layout::HomeArtPrefetch;
use crate::layout::{HomeArtPrefetch, PendingLaunchDriver};
use crate::motion::AnimationClockDriver;
use crate::routes::Route;
use crate::theme;
@ -80,5 +80,6 @@ impl Component for RootLayout {
.child(SplashCurtain)
.child(AnimationClockDriver)
.child(HomeArtPrefetch)
.child(PendingLaunchDriver)
}
}

View file

@ -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;

View file

@ -6,15 +6,19 @@
use freya::prelude::*;
use freya::radio::use_init_radio_station;
use oneclient_app::ipc::{self, Claim};
use oneclient_app::state::{AppChannel, AppState, LauncherInit};
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 {
needs_location: bool,
boot_launch: Cell<Option<String>>,
ipc: Cell<Option<ipc::Listener>>,
}
impl App for OneClientApp {
@ -30,12 +34,14 @@ impl App for OneClientApp {
..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,
@ -61,6 +67,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
});
@ -78,6 +99,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);
@ -100,6 +123,16 @@ fn main() {
.unwrap_or(false);
let needs_location = never_set_up && !has_database && !was_damaged;
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));
oneclient_common::consent::init(settings.declined_tos);
@ -122,6 +155,20 @@ 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
),
}
if let Some(dir) = abandoned_move {
tracing::error!(
path = %dir.display(),
@ -135,7 +182,11 @@ fn main() {
#[cfg(target_os = "macos")]
oneclient_app::platform::macos::loop_memory_collector();
let window_config = WindowConfig::new_app(OneClientApp { needs_location })
let window_config = WindowConfig::new_app(OneClientApp {
needs_location,
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!(

View file

@ -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}");

View file

@ -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<PathBuf> {
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())
}

View file

@ -0,0 +1,11 @@
use std::path::Path;
use anyhow::Result;
pub fn register(_exe: &Path) -> Result<()> {
Ok(())
}
pub fn is_registered() -> bool {
false
}

View file

@ -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<String> {
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);
}
}

View file

@ -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<u16> = value.encode_utf16().chain(std::iter::once(0)).collect();
let bytes = unsafe { std::slice::from_raw_parts(wide.as_ptr().cast::<u8>(), 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<String> {
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<u16> = 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(),
)
}

View file

@ -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())
}

View file

@ -0,0 +1,30 @@
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))?;
trust(path);
Ok(())
}
/// GNOME shows a desktop file without this attribute as plain text rather than
/// launching it
fn trust(path: &Path) {
let _ = std::process::Command::new("gio")
.arg("set")
.arg(path)
.args(["metadata::trusted", "true"])
.output();
}

View file

@ -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<String> {
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<PathBuf> {
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,
);
}
}

View file

@ -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<PathBuf> {
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<PathBuf> {
directories::UserDirs::new()
.and_then(|dirs| dirs.desktop_dir().map(Path::to_path_buf))
.filter(|dir| dir.is_dir())
}
pub fn launcher_exe() -> Result<PathBuf> {
#[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<PathBuf> {
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);
}
}

View file

@ -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(())
}

View file

@ -23,6 +23,7 @@ pub enum AppChannel {
MicrosoftLogin,
Installs,
Relocation,
PendingLaunch,
}
impl RadioChannel<AppState> for AppChannel {}
@ -43,6 +44,7 @@ pub struct AppState {
pub microsoft_login: Option<LoginProgress>,
pub installs: InstallState,
pub relocation: RelocationState,
pub pending_launch: Option<String>,
}
/// A move of the data folder owns the whole window while it runs: the router
@ -119,6 +121,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<i64, LaunchStage>,
@ -137,13 +154,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<LaunchBlock> {
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<LaunchBlock> {
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);
}
@ -205,6 +250,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();

View file

@ -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 {
@ -254,6 +256,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,

View file

@ -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<Option<Cluster>> {
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<Vec<Cluster>> {
let rows = cluster_dao::list_all(&self.db).await?;

View file

@ -44,11 +44,34 @@ 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());
}
// A parallel attempt shares its entry with the session already under way, so
// clearing it here would drop that game's kill sender and pid
let adopted = state.games.is_active(cluster_id);
let result = start(state, cluster_id, account, search_for_java).await;
if result.is_err() && !adopted {
state.games.remove(cluster_id);
state
.services
.events
.game_stage(cluster_id, LaunchStage::Exited);
}
result
}
async fn start(
state: &Arc<LauncherState>,
cluster_id: i64,
account: &MinecraftAccount,
search_for_java: bool,
) -> LauncherResult<LaunchedGame> {
let events = state.services.events.clone();
let stage = |s: LaunchStage| {
state.games.set_stage(cluster_id, s);
@ -76,6 +99,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),
@ -343,7 +368,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(),

View file

@ -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<Path>) -> Result<DbPool, DbError>
.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)