feat: shortcuts (#773)
This commit is contained in:
parent
86f3049564
commit
acd2c1d183
30 changed files with 1800 additions and 25 deletions
4
Cargo.lock
generated
4
Cargo.lock
generated
|
|
@ -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",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
84
packages/oneclient_app/src/cli.rs
Normal file
84
packages/oneclient_app/src/cli.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
248
packages/oneclient_app/src/file_content.rs
Normal file
248
packages/oneclient_app/src/file_content.rs
Normal 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('&', "&")
|
||||
.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!("\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 & <B></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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
108
packages/oneclient_app/src/hooks/shortcut_actions.rs
Normal file
108
packages/oneclient_app/src/hooks/shortcut_actions.rs
Normal 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())
|
||||
}
|
||||
320
packages/oneclient_app/src/ipc.rs
Normal file
320
packages/oneclient_app/src/ipc.rs
Normal 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()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
133
packages/oneclient_app/src/layout/pending_launch.rs
Normal file
133
packages/oneclient_app/src/layout/pending_launch.rs
Normal 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();
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
|
|
|
|||
50
packages/oneclient_app/src/protocol/linux.rs
Normal file
50
packages/oneclient_app/src/protocol/linux.rs
Normal 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())
|
||||
}
|
||||
11
packages/oneclient_app/src/protocol/macos.rs
Normal file
11
packages/oneclient_app/src/protocol/macos.rs
Normal 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
|
||||
}
|
||||
128
packages/oneclient_app/src/protocol/mod.rs
Normal file
128
packages/oneclient_app/src/protocol/mod.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
106
packages/oneclient_app/src/protocol/registry.rs
Normal file
106
packages/oneclient_app/src/protocol/registry.rs
Normal 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(),
|
||||
)
|
||||
}
|
||||
36
packages/oneclient_app/src/protocol/windows.rs
Normal file
36
packages/oneclient_app/src/protocol/windows.rs
Normal 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())
|
||||
}
|
||||
30
packages/oneclient_app/src/shortcut/linux.rs
Normal file
30
packages/oneclient_app/src/shortcut/linux.rs
Normal 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();
|
||||
}
|
||||
88
packages/oneclient_app/src/shortcut/macos.rs
Normal file
88
packages/oneclient_app/src/shortcut/macos.rs
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
187
packages/oneclient_app/src/shortcut/mod.rs
Normal file
187
packages/oneclient_app/src/shortcut/mod.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
23
packages/oneclient_app/src/shortcut/windows.rs
Normal file
23
packages/oneclient_app/src/shortcut/windows.rs
Normal 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(())
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue