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 \
libgl1-mesa-dev libx11-dev libxcursor-dev libxi-dev libxrandr-dev \
libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev libdbus-1-dev \
libgtk-3-dev libayatana-appindicator3-dev libxdo-dev \
libfuse2
- name: Install cargo-packager

View file

@ -216,6 +216,7 @@ jobs:
libfontconfig-dev libfreetype-dev \
libgl1-mesa-dev libx11-dev libxcursor-dev libxi-dev libxrandr-dev \
libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev libdbus-1-dev \
libgtk-3-dev libayatana-appindicator3-dev libxdo-dev \
libfuse2
- 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"}
# 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-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

View file

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

View file

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

View file

@ -15,6 +15,8 @@ Source0: %{url}/releases/download/oneclient-%{version}/%{_rpmfile}
ExclusiveArch: x86_64
BuildRequires: bsdtar
Requires: gtk3
Requires: libayatana-appindicator-gtk3
Requires: libxdo
Requires: dbus-libs
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 discord_on = settings.discord_enabled;
let toggle_discord = move |_| {
let toggle_discord = {
let dispatch = dispatch.clone();
move |_| {
dispatch.edit_settings(|s| s.discord_enabled = !s.discord_enabled);
}
};
rect()
@ -296,7 +299,6 @@ impl Component for QuickSettings {
QuickTile::new(IconType::Discord, "Discord RPC", discord_on)
.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 {
self.on_press = Some(on_press.into());
self

View file

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

View file

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

View file

@ -1,6 +1,8 @@
use std::sync::{Mutex, OnceLock};
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel};
use crate::cli::Cli;
@ -12,6 +14,10 @@ const REPLY_OK: &str = "OK";
pub enum IpcCommand {
Launch(String),
Focus,
Close,
Logs,
Stop,
Quit,
}
pub enum Claim {
@ -25,6 +31,10 @@ impl IpcCommand {
match self {
Self::Launch(folder) => format!("LAUNCH {folder}"),
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();
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
}
/// 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));
static QUEUE: OnceLock<UnboundedSender<IpcCommand>> = OnceLock::new();
static INBOX: Mutex<Option<UnboundedReceiver<IpcCommand>>> = Mutex::new(None);
while let Some(command) = incoming.recv().await {
on_command(command);
fn queue() -> &'static UnboundedSender<IpcCommand> {
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 {
@ -87,7 +120,7 @@ enum BindError {
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
S: AsyncRead + AsyncWrite + Unpin,
{
@ -315,7 +348,7 @@ mod tests {
assert_eq!(IpcCommand::decode(""), 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]

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 app_shell;
mod cluster_shell;
mod ipc_commands;
mod onboarding_shell;
mod pending_launch;
mod root_layout;
@ -10,6 +11,7 @@ pub use animated_outlet::AnimatedAppOutlet;
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 ipc_commands::use_ipc_commands;
pub use onboarding_shell::OnboardingShell;
pub use pending_launch::PendingLaunchDriver;
pub use root_layout::RootLayout;

View file

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

View file

@ -20,7 +20,6 @@ struct OneClientApp {
needs_location: bool,
start_maximized: bool,
boot_launch: Cell<Option<String>>,
ipc: Cell<Option<ipc::Listener>>,
}
impl App for OneClientApp {
@ -37,7 +36,6 @@ impl App for OneClientApp {
});
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();
@ -76,17 +74,6 @@ impl App for OneClientApp {
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
});
@ -131,14 +118,11 @@ fn main() {
let needs_location = never_set_up && !has_database && !was_damaged;
let mut unprotected = None;
let ipc = match rt.block_on(ipc::claim(&cli)) {
match rt.block_on(ipc::claim(&cli)) {
Claim::Forwarded => return,
Claim::Primary(listener) => Some(listener),
Claim::Solo(reason) => {
unprotected = Some(reason);
None
Claim::Primary(listener) => ipc::listen(listener),
Claim::Solo(reason) => unprotected = Some(reason),
}
};
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();
let start_maximized = settings.start_maximized;
let show_tray_icon = settings.show_tray_icon;
let window_config = WindowConfig::new_app(OneClientApp {
needs_location,
start_maximized,
boot_launch: Cell::new(cli.launch),
ipc: Cell::new(ipc),
})
.with_title(constants::WINDOW_TITLE)
.with_app_id(constants::WINDOW_APP_ID)
@ -210,10 +194,10 @@ fn main() {
.with_on_close(|_, _| {
if oneclient_core::relocate::in_progress() {
tracing::warn!("close request ignored, the data folder is still being moved");
CloseDecision::KeepOpen
} else {
CloseDecision::Close
ipc::send(ipc::IpcCommand::Close);
}
CloseDecision::KeepOpen
});
#[cfg(target_os = "macos")]
@ -246,6 +230,10 @@ fn main() {
)
.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() {
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"];
pub fn focus_window() {
use freya::prelude::{Platform, WinitPlatformExt};
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.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) {
if !has_allowed_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")]
pub mod macos {
use std::time::Duration;
@ -88,4 +272,39 @@ pub mod macos {
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)
}
pub fn running_clusters(&self) -> impl Iterator<Item = i64> + '_ {
self.stages
.iter()
.filter(|(_, stage)| **stage == LaunchStage::Running)
.map(|(id, _)| *id)
}
#[must_use]
pub fn is_running(&self, cluster_id: i64) -> bool {
self.stage(cluster_id) == Some(LaunchStage::Running)

View file

@ -3,12 +3,14 @@ use std::path::{Path, PathBuf};
use freya::prelude::*;
use freya::router::RouterContext;
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 super::{section_header, settings_page, settings_row};
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::{
Actions, DiscardLeftoversKeys, mutation_error, mutation_is_running, try_leftovers,
use_discard_leftovers, use_dispatch, use_launcher, use_leftovers, use_settings_snapshot,
@ -39,22 +41,36 @@ impl Component for SettingsLauncher {
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 settings = settings.clone();
let dispatch = dispatch.clone();
use_side_effect(move || {
let discord = *discord_rpc.read();
let crash = *crash_reporting.read();
let maximized = *start_maximized.read();
let background = *run_in_background.read();
let tray = *show_tray_icon.read();
if *first.peek() {
first.set(false);
return;
}
let mut next = settings.clone();
dispatch.edit_settings(|next| {
next.discord_enabled = discord;
next.crash_reporting = crash;
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,
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(DataFolder.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)]
struct DataFolder;

View file

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

View file

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

View file

@ -13,6 +13,32 @@ pub enum ViewLayout {
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)]
#[serde(default)]
pub struct ViewState {
@ -33,6 +59,9 @@ pub struct LauncherSettings {
pub max_concurrent_requests: usize,
pub global_game_settings: GameSettingsProfile,
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 start_maximized: bool,
pub animations_enabled: bool,
@ -75,6 +104,9 @@ impl Default for LauncherSettings {
max_concurrent_requests: 25,
global_game_settings: GameSettingsProfile::default_global_profile(),
allow_parallel_running_clusters: false,
launch_behaviour: LaunchBehaviour::default(),
run_in_background: false,
show_tray_icon: true,
dynamic_background_enabled: true,
start_maximized: false,
animations_enabled: true,

View file

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

View file

@ -184,3 +184,9 @@ pub fn run_startup_tasks(state: &Arc<LauncherState>) {
.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;
}