feat: system tray and process state controls (#687)

This commit is contained in:
LynithDev 2026-09-19 01:19:34 +02:00 • committed by GitHub
parent a8cc4e8b7f
commit 850c695f91
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1087 additions and 100 deletions

View file

@ -85,6 +85,7 @@ jobs:
libfontconfig-dev libfreetype-dev \ libfontconfig-dev libfreetype-dev \
libgl1-mesa-dev libx11-dev libxcursor-dev libxi-dev libxrandr-dev \ libgl1-mesa-dev libx11-dev libxcursor-dev libxi-dev libxrandr-dev \
libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev libdbus-1-dev \ libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev libdbus-1-dev \
libgtk-3-dev libayatana-appindicator3-dev libxdo-dev \
libfuse2 libfuse2
- name: Install cargo-packager - name: Install cargo-packager

View file

@ -216,6 +216,7 @@ jobs:
libfontconfig-dev libfreetype-dev \ libfontconfig-dev libfreetype-dev \
libgl1-mesa-dev libx11-dev libxcursor-dev libxi-dev libxrandr-dev \ libgl1-mesa-dev libx11-dev libxcursor-dev libxi-dev libxrandr-dev \
libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev libdbus-1-dev \ libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev libdbus-1-dev \
libgtk-3-dev libayatana-appindicator3-dev libxdo-dev \
libfuse2 libfuse2
- name: Install cargo-packager - name: Install cargo-packager

655
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -142,7 +142,7 @@ syn = {version = "=2.0.117"}
thiserror = {version = "=2.0.18"} thiserror = {version = "=2.0.18"}
# ui # ui
freya = {git = "https://github.com/marc2332/freya", branch = "fix/skip-basic-render-driver", features = ["engine", "winit", "router", "remote-asset", "sdk", "query", "radio", "titlebar"]} freya = {git = "https://github.com/marc2332/freya", branch = "fix/skip-basic-render-driver", features = ["engine", "winit", "router", "remote-asset", "sdk", "query", "radio", "titlebar", "tray"]}
freya-core = {git = "https://github.com/marc2332/freya", branch = "fix/skip-basic-render-driver"} freya-core = {git = "https://github.com/marc2332/freya", branch = "fix/skip-basic-render-driver"}
freya-testing = {git = "https://github.com/marc2332/freya", branch = "fix/skip-basic-render-driver"} freya-testing = {git = "https://github.com/marc2332/freya", branch = "fix/skip-basic-render-driver"}
freya-skia-safe = "=0.100.0" # required for the SKSL player renderer freya-skia-safe = "=0.100.0" # required for the SKSL player renderer

View file

@ -18,6 +18,8 @@ depends=(
'wayland' 'wayland'
'dbus' 'dbus'
'gtk3' 'gtk3'
'libayatana-appindicator'
'xdotool'
) )
provides=('oneclient') provides=('oneclient')
conflicts=('oneclient') conflicts=('oneclient')

View file

@ -17,6 +17,8 @@ depends=(
'wayland' 'wayland'
'dbus' 'dbus'
'gtk3' 'gtk3'
'libayatana-appindicator'
'xdotool'
) )
makedepends=( makedepends=(
'rust' 'rust'

View file

@ -15,6 +15,8 @@ Source0: %{url}/releases/download/oneclient-%{version}/%{_rpmfile}
ExclusiveArch: x86_64 ExclusiveArch: x86_64
BuildRequires: bsdtar BuildRequires: bsdtar
Requires: gtk3 Requires: gtk3
Requires: libayatana-appindicator-gtk3
Requires: libxdo
Requires: dbus-libs Requires: dbus-libs
Provides: oneclient = %{version}-%{release} Provides: oneclient = %{version}-%{release}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View file

@ -283,8 +283,11 @@ impl Component for QuickSettings {
let dispatch = use_dispatch(); let dispatch = use_dispatch();
let discord_on = settings.discord_enabled; let discord_on = settings.discord_enabled;
let toggle_discord = move |_| { let toggle_discord = {
dispatch.edit_settings(|s| s.discord_enabled = !s.discord_enabled); let dispatch = dispatch.clone();
move |_| {
dispatch.edit_settings(|s| s.discord_enabled = !s.discord_enabled);
}
}; };
rect() rect()
@ -296,7 +299,6 @@ impl Component for QuickSettings {
QuickTile::new(IconType::Discord, "Discord RPC", discord_on) QuickTile::new(IconType::Discord, "Discord RPC", discord_on)
.on_press(toggle_discord), .on_press(toggle_discord),
) )
.child(QuickTile::new(IconType::Moon01, "Close on launch", false).disabled())
} }
} }
@ -328,11 +330,6 @@ impl QuickTile {
} }
} }
fn disabled(mut self) -> Self {
self.enabled = false;
self
}
fn on_press(mut self, on_press: impl Into<EventHandler<Event<PressEventData>>>) -> Self { fn on_press(mut self, on_press: impl Into<EventHandler<Event<PressEventData>>>) -> Self {
self.on_press = Some(on_press.into()); self.on_press = Some(on_press.into());
self self

View file

@ -29,12 +29,7 @@ pub fn window_controls() -> impl IntoElement {
}); });
}; };
let close = |_| { let close = |_| crate::platform::close();
let platform = Platform::get();
Platform::get().with_window(Platform::window_id(), move |window| {
platform.close_window(window.id());
});
};
rect() rect()
.horizontal() .horizontal()

View file

@ -30,6 +30,8 @@ impl EventPump {
// Hovering any toast pauses every toast including ones arriving while hovering // Hovering any toast pauses every toast including ones arriving while hovering
let mut paused = false; let mut paused = false;
let mut game_flush: Option<tokio::time::Instant> = None; let mut game_flush: Option<tokio::time::Instant> = None;
let mut launched: HashSet<i64> = HashSet::new();
let mut playing: Option<i64> = None;
loop { loop {
let next_deadline = armed let next_deadline = armed
@ -96,6 +98,10 @@ impl EventPump {
if folded.saw_game_log && game_flush.is_none() { if folded.saw_game_log && game_flush.is_none() {
game_flush = Some(tokio::time::Instant::now() + GAME_LOG_FLUSH); game_flush = Some(tokio::time::Instant::now() + GAME_LOG_FLUSH);
} }
if folded.saw_stage {
launched.extend(&folded.launched);
playing = crate::platform::follow_game(&self.station, &launched, playing);
}
} }
signal = self.signals.recv() => { signal = self.signals.recv() => {
@ -139,6 +145,10 @@ impl EventPump {
folded.clusters = true; folded.clusters = true;
} }
Event::Game(GameEvent::Stage { cluster_id, stage }) => { Event::Game(GameEvent::Stage { cluster_id, stage }) => {
folded.saw_stage = true;
if stage.is_busy() {
folded.launched.push(cluster_id);
}
stages.push((cluster_id, stage)); stages.push((cluster_id, stage));
} }
Event::Game(GameEvent::Log { cluster_id, line }) => { Event::Game(GameEvent::Log { cluster_id, line }) => {
@ -148,7 +158,10 @@ impl EventPump {
Event::Game(GameEvent::Failed { Event::Game(GameEvent::Failed {
cluster_id, cluster_id,
message, message,
}) => failed = Some((cluster_id, message)), }) => {
folded.saw_stage = true;
failed = Some((cluster_id, message));
}
Event::Progress(ProgressEvent::Update { Event::Progress(ProgressEvent::Update {
id, id,
ref label, ref label,
@ -249,6 +262,8 @@ impl EventPump {
struct Folded { struct Folded {
touched_engine: bool, touched_engine: bool,
saw_game_log: bool, saw_game_log: bool,
saw_stage: bool,
launched: Vec<i64>,
clusters: bool, clusters: bool,
java: bool, java: bool,
} }

View file

@ -1,6 +1,8 @@
use std::sync::{Mutex, OnceLock};
use std::time::Duration; use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader}; use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel};
use crate::cli::Cli; use crate::cli::Cli;
@ -12,6 +14,10 @@ const REPLY_OK: &str = "OK";
pub enum IpcCommand { pub enum IpcCommand {
Launch(String), Launch(String),
Focus, Focus,
Close,
Logs,
Stop,
Quit,
} }
pub enum Claim { pub enum Claim {
@ -25,6 +31,10 @@ impl IpcCommand {
match self { match self {
Self::Launch(folder) => format!("LAUNCH {folder}"), Self::Launch(folder) => format!("LAUNCH {folder}"),
Self::Focus => "FOCUS".to_string(), Self::Focus => "FOCUS".to_string(),
Self::Close => "CLOSE".to_string(),
Self::Logs => "LOGS".to_string(),
Self::Stop => "STOP".to_string(),
Self::Quit => "QUIT".to_string(),
} }
} }
@ -34,7 +44,14 @@ impl IpcCommand {
let folder = folder.trim(); let folder = folder.trim();
return (!folder.is_empty()).then(|| Self::Launch(folder.to_string())); return (!folder.is_empty()).then(|| Self::Launch(folder.to_string()));
} }
(line == "FOCUS").then_some(Self::Focus) match line {
"FOCUS" => Some(Self::Focus),
"CLOSE" => Some(Self::Close),
"LOGS" => Some(Self::Logs),
"STOP" => Some(Self::Stop),
"QUIT" => Some(Self::Quit),
_ => None,
}
} }
} }
@ -68,15 +85,31 @@ async fn forward(request: &IpcCommand) -> bool {
imp::send(&request.encode()).await imp::send(&request.encode()).await
} }
/// The accept loop runs off the caller's thread so a stalled client cannot hold static QUEUE: OnceLock<UnboundedSender<IpcCommand>> = OnceLock::new();
/// up the next shortcut launch; `on_command` still runs here static INBOX: Mutex<Option<UnboundedReceiver<IpcCommand>>> = Mutex::new(None);
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 { fn queue() -> &'static UnboundedSender<IpcCommand> {
on_command(command); QUEUE.get_or_init(|| {
} let (commands, incoming) = unbounded_channel();
*INBOX.lock().unwrap() = Some(incoming);
commands
})
}
pub fn send(command: IpcCommand) {
let _ = queue().send(command);
}
/// The accept loop runs off the UI thread so a stalled client cannot hold up
/// the next shortcut launch
pub fn listen(listener: Listener) {
tokio::spawn(imp::serve(listener, queue().clone()));
}
#[must_use]
pub fn take_inbox() -> Option<UnboundedReceiver<IpcCommand>> {
queue();
INBOX.lock().unwrap().take()
} }
enum BindError { enum BindError {
@ -87,7 +120,7 @@ enum BindError {
Io(std::io::Error), Io(std::io::Error),
} }
async fn handle<S>(stream: S, commands: &tokio::sync::mpsc::UnboundedSender<IpcCommand>) async fn handle<S>(stream: S, commands: &UnboundedSender<IpcCommand>)
where where
S: AsyncRead + AsyncWrite + Unpin, S: AsyncRead + AsyncWrite + Unpin,
{ {
@ -315,7 +348,7 @@ mod tests {
assert_eq!(IpcCommand::decode(""), None); assert_eq!(IpcCommand::decode(""), None);
assert_eq!(IpcCommand::decode("LAUNCH"), None); assert_eq!(IpcCommand::decode("LAUNCH"), None);
assert_eq!(IpcCommand::decode("LAUNCH "), None); assert_eq!(IpcCommand::decode("LAUNCH "), None);
assert_eq!(IpcCommand::decode("QUIT"), None); assert_eq!(IpcCommand::decode("RESTART"), None);
} }
#[test] #[test]

View file

@ -0,0 +1,46 @@
use freya::prelude::*;
use freya::router::*;
use crate::hooks::use_dispatch;
use crate::ipc::{self, IpcCommand};
use crate::platform;
use crate::routes::Route;
pub fn use_ipc_commands() {
let dispatch = use_dispatch();
use_hook(move || {
let Some(mut inbox) = ipc::take_inbox() else {
return;
};
let router = RouterContext::get();
spawn_forever(async move {
while let Some(command) = inbox.recv().await {
match command {
IpcCommand::Focus => platform::focus_window(),
IpcCommand::Launch(folder) => {
platform::focus_window();
dispatch.request_launch_by_folder(folder);
}
IpcCommand::Logs => {
platform::focus_window();
let running = dispatch.station().peek().game.running_clusters().min();
if let Some(cluster_id) = running {
let _ = router.push(Route::ProcessLogs { cluster_id });
}
}
IpcCommand::Stop => {
let running: Vec<i64> =
dispatch.station().peek().game.running_clusters().collect();
for cluster_id in running {
dispatch.kill_cluster(cluster_id);
}
}
IpcCommand::Close => platform::close(),
IpcCommand::Quit => platform::quit(),
}
}
});
});
}

View file

@ -1,6 +1,7 @@
mod animated_outlet; mod animated_outlet;
mod app_shell; mod app_shell;
mod cluster_shell; mod cluster_shell;
mod ipc_commands;
mod onboarding_shell; mod onboarding_shell;
mod pending_launch; mod pending_launch;
mod root_layout; mod root_layout;
@ -10,6 +11,7 @@ pub use animated_outlet::AnimatedAppOutlet;
pub(crate) use app_shell::gradient_overlay_radial; pub(crate) use app_shell::gradient_overlay_radial;
pub use app_shell::{AppShell, HOME_BACKGROUND_ASSET, HomeArtPrefetch}; pub use app_shell::{AppShell, HOME_BACKGROUND_ASSET, HomeArtPrefetch};
pub use cluster_shell::{ClusterShell, cluster_content}; pub use cluster_shell::{ClusterShell, cluster_content};
pub use ipc_commands::use_ipc_commands;
pub use onboarding_shell::OnboardingShell; pub use onboarding_shell::OnboardingShell;
pub use pending_launch::PendingLaunchDriver; pub use pending_launch::PendingLaunchDriver;
pub use root_layout::RootLayout; pub use root_layout::RootLayout;

View file

@ -9,7 +9,7 @@ use crate::components::{
#[cfg(not(target_os = "macos"))] #[cfg(not(target_os = "macos"))]
use crate::hooks::use_start_maximized; use crate::hooks::use_start_maximized;
use crate::hooks::{SplashState, use_provide_overlay_claims, use_provide_splash}; use crate::hooks::{SplashState, use_provide_overlay_claims, use_provide_splash};
use crate::layout::{HomeArtPrefetch, PendingLaunchDriver}; use crate::layout::{HomeArtPrefetch, PendingLaunchDriver, use_ipc_commands};
use crate::motion::AnimationClockDriver; use crate::motion::AnimationClockDriver;
use crate::routes::Route; use crate::routes::Route;
use crate::theme; use crate::theme;
@ -25,6 +25,7 @@ impl Component for RootLayout {
use_provide_splash(SplashState { active, home_ready }); use_provide_splash(SplashState { active, home_ready });
use_provide_overlay_claims(); use_provide_overlay_claims();
use_provide_tooltips(); use_provide_tooltips();
use_ipc_commands();
rect() rect()
.width(Size::fill()) .width(Size::fill())

View file

@ -20,7 +20,6 @@ struct OneClientApp {
needs_location: bool, needs_location: bool,
start_maximized: bool, start_maximized: bool,
boot_launch: Cell<Option<String>>, boot_launch: Cell<Option<String>>,
ipc: Cell<Option<ipc::Listener>>,
} }
impl App for OneClientApp { impl App for OneClientApp {
@ -37,7 +36,6 @@ impl App for OneClientApp {
}); });
let boot_launch = self.boot_launch.take(); let boot_launch = self.boot_launch.take();
let ipc_listener = self.ipc.take();
let actions = use_hook(move || { let actions = use_hook(move || {
let (signals_tx, signals_rx) = tokio::sync::mpsc::unbounded_channel(); let (signals_tx, signals_rx) = tokio::sync::mpsc::unbounded_channel();
@ -76,17 +74,6 @@ impl App for OneClientApp {
actions.request_launch_by_folder(folder); 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 actions
}); });
@ -131,14 +118,11 @@ fn main() {
let needs_location = never_set_up && !has_database && !was_damaged; let needs_location = never_set_up && !has_database && !was_damaged;
let mut unprotected = None; let mut unprotected = None;
let ipc = match rt.block_on(ipc::claim(&cli)) { match rt.block_on(ipc::claim(&cli)) {
Claim::Forwarded => return, Claim::Forwarded => return,
Claim::Primary(listener) => Some(listener), Claim::Primary(listener) => ipc::listen(listener),
Claim::Solo(reason) => { Claim::Solo(reason) => unprotected = Some(reason),
unprotected = Some(reason); }
None
}
};
let settings = rt.block_on(oneclient_core::settings::store::load_settings(None)); let settings = rt.block_on(oneclient_core::settings::store::load_settings(None));
@ -189,12 +173,12 @@ fn main() {
oneclient_app::platform::macos::loop_memory_collector(); oneclient_app::platform::macos::loop_memory_collector();
let start_maximized = settings.start_maximized; let start_maximized = settings.start_maximized;
let show_tray_icon = settings.show_tray_icon;
let window_config = WindowConfig::new_app(OneClientApp { let window_config = WindowConfig::new_app(OneClientApp {
needs_location, needs_location,
start_maximized, start_maximized,
boot_launch: Cell::new(cli.launch), boot_launch: Cell::new(cli.launch),
ipc: Cell::new(ipc),
}) })
.with_title(constants::WINDOW_TITLE) .with_title(constants::WINDOW_TITLE)
.with_app_id(constants::WINDOW_APP_ID) .with_app_id(constants::WINDOW_APP_ID)
@ -210,10 +194,10 @@ fn main() {
.with_on_close(|_, _| { .with_on_close(|_, _| {
if oneclient_core::relocate::in_progress() { if oneclient_core::relocate::in_progress() {
tracing::warn!("close request ignored, the data folder is still being moved"); tracing::warn!("close request ignored, the data folder is still being moved");
CloseDecision::KeepOpen
} else { } else {
CloseDecision::Close ipc::send(ipc::IpcCommand::Close);
} }
CloseDecision::KeepOpen
}); });
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
@ -246,6 +230,10 @@ fn main() {
) )
.with_default_font(theme::DEFAULT_FONT); .with_default_font(theme::DEFAULT_FONT);
if show_tray_icon {
launch_config = launch_config.with_tray(platform::tray::build, platform::tray::handle);
}
for (font, bytes) in theme::load_fonts() { for (font, bytes) in theme::load_fonts() {
launch_config = launch_config.with_font(font, bytes); launch_config = launch_config.with_font(font, bytes);
} }

View file

@ -1,14 +1,89 @@
use std::collections::HashSet;
use freya::prelude::{Platform, WinitPlatformExt, spawn_forever};
use freya::radio::RadioStation;
use oneclient_core::settings::LaunchBehaviour;
use crate::state::{AppChannel, AppState};
const ALLOWED_URL_SCHEMES: [&str; 3] = ["http", "https", "mailto"]; const ALLOWED_URL_SCHEMES: [&str; 3] = ["http", "https", "mailto"];
pub fn focus_window() { pub fn focus_window() {
use freya::prelude::{Platform, WinitPlatformExt};
Platform::get().with_window(Platform::window_id(), |win| { Platform::get().with_window(Platform::window_id(), |win| {
#[cfg(target_os = "macos")]
macos::set_menu_bar_only(false);
win.set_visible(true);
win.set_minimized(false); win.set_minimized(false);
win.focus_window(); win.focus_window();
}); });
} }
pub fn hide_window() {
Platform::get().with_window(Platform::window_id(), |win| {
#[cfg(target_os = "macos")]
if !tray::is_active() {
win.set_minimized(true);
return;
}
win.set_visible(false);
#[cfg(target_os = "macos")]
macos::set_menu_bar_only(true);
});
}
pub fn close() {
let background =
crate::launcher::state().is_ok_and(|state| state.settings.read().run_in_background);
if background { hide_window() } else { quit() }
}
pub fn quit() {
let platform = Platform::get();
spawn_forever(async move {
if let Ok(state) = crate::launcher::state() {
oneclient_core::shutdown(&state).await;
}
drop(platform.post_callback(|_, ctx| ctx.exit()));
});
}
pub fn follow_game(
station: &RadioStation<AppState, AppChannel>,
launched: &HashSet<i64>,
previous: Option<i64>,
) -> Option<i64> {
let (current, any, behaviour) = {
let state = station.peek();
(
state
.game
.running_clusters()
.filter(|id| launched.contains(id))
.min(),
state.game.running_clusters().next().is_some(),
state.settings.settings.launch_behaviour,
)
};
tray::set_game_running(any);
if current == previous {
return previous;
}
match (behaviour, current.is_some()) {
(LaunchBehaviour::HideWhilePlaying, true) => hide_window(),
(LaunchBehaviour::HideWhilePlaying, false) => focus_window(),
(LaunchBehaviour::CloseLauncher, true) => quit(),
_ => {}
}
current
}
pub fn open_url(url: &str) { pub fn open_url(url: &str) {
if !has_allowed_scheme(url) { if !has_allowed_scheme(url) {
tracing::warn!("refused to open url with a disallowed scheme: {url}"); tracing::warn!("refused to open url with a disallowed scheme: {url}");
@ -65,6 +140,115 @@ pub fn copy_image_to_clipboard(path: std::path::PathBuf) {
}); });
} }
pub mod tray {
use std::cell::RefCell;
use std::sync::atomic::{AtomicBool, Ordering};
use freya::prelude::{LaunchConfig, RendererContext};
use freya::tray::menu::{Menu, MenuItem, PredefinedMenuItem};
use freya::tray::{TrayEvent, TrayIcon, TrayIconBuilder};
use crate::constants;
use crate::ipc::{self, IpcCommand};
const OPEN_ID: &str = "oneclient.open";
const PLAY_ID: &str = "oneclient.play";
const STOP_ID: &str = "oneclient.stop";
const LOGS_ID: &str = "oneclient.logs";
const QUIT_ID: &str = "oneclient.quit";
#[cfg(target_os = "macos")]
const ICON: &[u8] = include_bytes!("../icons/tray.png");
#[cfg(not(target_os = "macos"))]
const ICON: &[u8] = include_bytes!("../icons/64x64.png");
static ACTIVE: AtomicBool = AtomicBool::new(false);
thread_local! {
static GAME_ITEMS: RefCell<Vec<MenuItem>> = const { RefCell::new(Vec::new()) };
}
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
pub(super) fn is_active() -> bool {
ACTIVE.load(Ordering::Relaxed)
}
pub(super) fn set_game_running(running: bool) {
GAME_ITEMS.with_borrow(|items| {
for item in items {
item.set_enabled(running);
}
});
}
pub fn build() -> TrayIcon {
let live_on_linux = cfg!(target_os = "linux");
let stop = MenuItem::with_id(STOP_ID, "Stop game", live_on_linux, None);
let logs = MenuItem::with_id(LOGS_ID, "Show logs", live_on_linux, None);
let menu = Menu::with_items(&[
&MenuItem::with_id(OPEN_ID, "Open OneClient", true, None),
&MenuItem::with_id(PLAY_ID, "Play last version", true, None),
&PredefinedMenuItem::separator(),
&stop,
&logs,
&PredefinedMenuItem::separator(),
&MenuItem::with_id(QUIT_ID, "Quit OneClient", true, None),
])
.expect("failed to build the tray menu");
GAME_ITEMS.with_borrow_mut(|items| *items = vec![stop, logs]);
ACTIVE.store(true, Ordering::Relaxed);
TrayIconBuilder::new()
.with_menu(Box::new(menu))
.with_tooltip(constants::WINDOW_TITLE)
.with_icon(LaunchConfig::tray_icon(ICON))
.with_icon_as_template(cfg!(target_os = "macos"))
.with_menu_on_left_click(!cfg!(target_os = "windows"))
.build()
.expect("failed to create the tray icon")
}
pub fn handle(event: TrayEvent, _ctx: RendererContext<'_>) {
let command = match event {
TrayEvent::Menu(menu) => match menu.id.0.as_str() {
OPEN_ID => IpcCommand::Focus,
PLAY_ID => return play_last(),
STOP_ID => IpcCommand::Stop,
LOGS_ID => IpcCommand::Logs,
QUIT_ID => IpcCommand::Quit,
_ => return,
},
#[cfg(target_os = "windows")]
TrayEvent::Icon(freya::tray::TrayIconEvent::Click {
button: freya::tray::MouseButton::Left,
button_state: freya::tray::MouseButtonState::Up,
..
}) => IpcCommand::Focus,
TrayEvent::Icon(_) => return,
};
ipc::send(command);
}
fn play_last() {
tokio::spawn(async {
let Ok(state) = crate::launcher::state() else {
return;
};
let Ok(clusters) = state.clusters.list().await else {
return;
};
if let Some(cluster) = crate::utils::sort_clusters_for_home(clusters)
.into_iter()
.next()
{
ipc::send(IpcCommand::Launch(cluster.folder_name));
}
});
}
}
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
pub mod macos { pub mod macos {
use std::time::Duration; use std::time::Duration;
@ -88,4 +272,39 @@ pub mod macos {
malloc_zone_pressure_relief(core::ptr::null_mut(), 0); malloc_zone_pressure_relief(core::ptr::null_mut(), 0);
} }
} }
pub fn set_menu_bar_only(menu_bar_only: bool) {
const REGULAR: isize = 0;
const ACCESSORY: isize = 1;
type Id = *mut core::ffi::c_void;
unsafe {
unsafe extern "C" {
fn objc_getClass(name: *const core::ffi::c_char) -> Id;
fn sel_registerName(name: *const core::ffi::c_char) -> Id;
fn objc_msgSend();
}
let class = objc_getClass(c"NSApplication".as_ptr());
if class.is_null() {
return;
}
let send: unsafe extern "C" fn() = objc_msgSend;
let shared: unsafe extern "C" fn(Id, Id) -> Id = core::mem::transmute(send);
let app = shared(class, sel_registerName(c"sharedApplication".as_ptr()));
if app.is_null() {
return;
}
let set_policy: unsafe extern "C" fn(Id, Id, isize) -> bool =
core::mem::transmute(send);
set_policy(
app,
sel_registerName(c"setActivationPolicy:".as_ptr()),
if menu_bar_only { ACCESSORY } else { REGULAR },
);
}
}
} }

View file

@ -214,6 +214,13 @@ impl GameState {
self.stage(cluster_id).is_some_and(LaunchStage::is_busy) self.stage(cluster_id).is_some_and(LaunchStage::is_busy)
} }
pub fn running_clusters(&self) -> impl Iterator<Item = i64> + '_ {
self.stages
.iter()
.filter(|(_, stage)| **stage == LaunchStage::Running)
.map(|(id, _)| *id)
}
#[must_use] #[must_use]
pub fn is_running(&self, cluster_id: i64) -> bool { pub fn is_running(&self, cluster_id: i64) -> bool {
self.stage(cluster_id) == Some(LaunchStage::Running) self.stage(cluster_id) == Some(LaunchStage::Running)

View file

@ -3,12 +3,14 @@ use std::path::{Path, PathBuf};
use freya::prelude::*; use freya::prelude::*;
use freya::router::RouterContext; use freya::router::RouterContext;
use oneclient_core::relocate::RelocationPlan; use oneclient_core::relocate::RelocationPlan;
use oneclient_core::settings::data_dir; use oneclient_core::settings::{LaunchBehaviour, data_dir};
use oneclient_core::storage::format_bytes; use oneclient_core::storage::format_bytes;
use super::{section_header, settings_page, settings_row}; use super::{section_header, settings_page, settings_row};
use crate::Route; use crate::Route;
use crate::components::{Button, Icon, IconType, OverlayPopup, open_folder_button, toggle}; use crate::components::{
Button, Dropdown, Icon, IconType, OverlayPopup, open_folder_button, toggle,
};
use crate::hooks::{ use crate::hooks::{
Actions, DiscardLeftoversKeys, mutation_error, mutation_is_running, try_leftovers, Actions, DiscardLeftoversKeys, mutation_error, mutation_is_running, try_leftovers,
use_discard_leftovers, use_dispatch, use_launcher, use_leftovers, use_settings_snapshot, use_discard_leftovers, use_dispatch, use_launcher, use_leftovers, use_settings_snapshot,
@ -39,22 +41,36 @@ impl Component for SettingsLauncher {
move || v move || v
}); });
let run_in_background = use_state({
let v = settings.run_in_background;
move || v
});
let show_tray_icon = use_state({
let v = settings.show_tray_icon;
move || v
});
let mut first = use_state(|| true); let mut first = use_state(|| true);
{ {
let settings = settings.clone(); let dispatch = dispatch.clone();
use_side_effect(move || { use_side_effect(move || {
let discord = *discord_rpc.read(); let discord = *discord_rpc.read();
let crash = *crash_reporting.read(); let crash = *crash_reporting.read();
let maximized = *start_maximized.read(); let maximized = *start_maximized.read();
let background = *run_in_background.read();
let tray = *show_tray_icon.read();
if *first.peek() { if *first.peek() {
first.set(false); first.set(false);
return; return;
} }
let mut next = settings.clone(); dispatch.edit_settings(|next| {
next.discord_enabled = discord; next.discord_enabled = discord;
next.crash_reporting = crash; next.crash_reporting = crash;
next.start_maximized = maximized; next.start_maximized = maximized;
dispatch.set_settings(next); next.run_in_background = background;
next.show_tray_icon = tray;
});
}); });
} }
@ -98,12 +114,47 @@ impl Component for SettingsLauncher {
consent_summary, consent_summary,
review_terms, review_terms,
)) ))
.child(section_header("SYSTEM"))
.child(settings_row(
IconType::Eye,
"While Playing",
"What the launcher window does once a game is running.",
launch_behaviour_field(settings.launch_behaviour, dispatch),
))
.child(settings_row(
IconType::Moon01,
"Run in Background",
"Closing the window keeps OneClient running. Open it again from the tray icon or by launching it.",
toggle(run_in_background),
))
.child(settings_row(
IconType::LayoutTop,
"Show Tray Icon",
"Show OneClient in the system tray or menu bar. Applies on restart.",
toggle(show_tray_icon),
))
.child(section_header("FOLDERS AND FILES")) .child(section_header("FOLDERS AND FILES"))
.child(DataFolder.into_element()) .child(DataFolder.into_element())
.into_element() .into_element()
} }
} }
fn launch_behaviour_field(selected: LaunchBehaviour, dispatch: Actions) -> impl IntoElement {
let options: Vec<String> = LaunchBehaviour::ALL
.iter()
.map(|behaviour| behaviour.label().to_string())
.collect();
Dropdown::new(selected.label(), options)
.width(Size::px(220.))
.height(Size::px(34.))
.on_select(move |idx: usize| {
if let Some(behaviour) = LaunchBehaviour::ALL.get(idx).copied() {
dispatch.edit_settings(|settings| settings.launch_behaviour = behaviour);
}
})
}
#[derive(PartialEq)] #[derive(PartialEq)]
struct DataFolder; struct DataFolder;

View file

@ -114,12 +114,7 @@ fn moved(outcome: &RelocationOutcome, actions: Actions) -> Element {
buttons().child(back_button(actions)).child( buttons().child(back_button(actions)).child(
Button::new() Button::new()
.primary() .primary()
.on_press(|_| { .on_press(|_| crate::platform::quit())
let platform = Platform::get();
Platform::get().with_window(Platform::window_id(), move |window| {
platform.close_window(window.id());
});
})
.text("Quit OneClient"), .text("Quit OneClient"),
), ),
) )

View file

@ -65,6 +65,7 @@ pub use settings::ProfileUpdate;
pub use state::LauncherServices; pub use state::LauncherServices;
pub use state::LauncherState; pub use state::LauncherState;
pub use state::run_startup_tasks; pub use state::run_startup_tasks;
pub use state::shutdown;
pub use tos::{TermsDocument, fetch_terms}; pub use tos::{TermsDocument, fetch_terms};
pub use verify::{ClusterVerifyReport, verify_cluster_files}; pub use verify::{ClusterVerifyReport, verify_cluster_files};
pub use versions::{ pub use versions::{

View file

@ -13,6 +13,32 @@ pub enum ViewLayout {
List, List,
} }
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum LaunchBehaviour {
#[default]
KeepVisible,
HideWhilePlaying,
CloseLauncher,
}
impl LaunchBehaviour {
pub const ALL: [Self; 3] = [
Self::KeepVisible,
Self::HideWhilePlaying,
Self::CloseLauncher,
];
#[must_use]
pub fn label(self) -> &'static str {
match self {
Self::KeepVisible => "Keep visible",
Self::HideWhilePlaying => "Hide while playing",
Self::CloseLauncher => "Close completely",
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Default)] #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Default)]
#[serde(default)] #[serde(default)]
pub struct ViewState { pub struct ViewState {
@ -33,6 +59,9 @@ pub struct LauncherSettings {
pub max_concurrent_requests: usize, pub max_concurrent_requests: usize,
pub global_game_settings: GameSettingsProfile, pub global_game_settings: GameSettingsProfile,
pub allow_parallel_running_clusters: bool, pub allow_parallel_running_clusters: bool,
pub launch_behaviour: LaunchBehaviour,
pub run_in_background: bool,
pub show_tray_icon: bool,
pub dynamic_background_enabled: bool, pub dynamic_background_enabled: bool,
pub start_maximized: bool, pub start_maximized: bool,
pub animations_enabled: bool, pub animations_enabled: bool,
@ -75,6 +104,9 @@ impl Default for LauncherSettings {
max_concurrent_requests: 25, max_concurrent_requests: 25,
global_game_settings: GameSettingsProfile::default_global_profile(), global_game_settings: GameSettingsProfile::default_global_profile(),
allow_parallel_running_clusters: false, allow_parallel_running_clusters: false,
launch_behaviour: LaunchBehaviour::default(),
run_in_background: false,
show_tray_icon: true,
dynamic_background_enabled: true, dynamic_background_enabled: true,
start_maximized: false, start_maximized: false,
animations_enabled: true, animations_enabled: true,

View file

@ -2,7 +2,7 @@ mod launcher;
pub mod data_dir; pub mod data_dir;
pub use launcher::{LauncherSettings, ViewLayout, ViewState}; pub use launcher::{LaunchBehaviour, LauncherSettings, ViewLayout, ViewState};
pub use oneclient_cluster::{ pub use oneclient_cluster::{
GameSettingsProfile, PackageUpdateMode, ProfileUpdate, SettingsOsExtra, GameSettingsProfile, PackageUpdateMode, ProfileUpdate, SettingsOsExtra,
}; };

View file

@ -184,3 +184,9 @@ pub fn run_startup_tasks(state: &Arc<LauncherState>) {
.signal(oneclient_events::Signal::SyncComplete); .signal(oneclient_events::Signal::SyncComplete);
}); });
} }
pub async fn shutdown(state: &LauncherState) {
tracing::info!("shutting the launcher down");
state.discord.shutdown();
state.services.db.close().await;
}