feat: system tray and process state controls (#687)
This commit is contained in:
parent
a8cc4e8b7f
commit
850c695f91
24 changed files with 1087 additions and 100 deletions
1
.github/workflows/oneclient_nightly.yml
vendored
1
.github/workflows/oneclient_nightly.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
1
.github/workflows/oneclient_release.yml
vendored
1
.github/workflows/oneclient_release.yml
vendored
|
|
@ -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
655
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ depends=(
|
|||
'wayland'
|
||||
'dbus'
|
||||
'gtk3'
|
||||
'libayatana-appindicator'
|
||||
'xdotool'
|
||||
)
|
||||
provides=('oneclient')
|
||||
conflicts=('oneclient')
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ depends=(
|
|||
'wayland'
|
||||
'dbus'
|
||||
'gtk3'
|
||||
'libayatana-appindicator'
|
||||
'xdotool'
|
||||
)
|
||||
makedepends=(
|
||||
'rust'
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
BIN
packages/oneclient_app/icons/tray.png
Normal file
BIN
packages/oneclient_app/icons/tray.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
|
|
@ -283,8 +283,11 @@ impl Component for QuickSettings {
|
|||
let dispatch = use_dispatch();
|
||||
|
||||
let discord_on = settings.discord_enabled;
|
||||
let toggle_discord = move |_| {
|
||||
dispatch.edit_settings(|s| s.discord_enabled = !s.discord_enabled);
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
46
packages/oneclient_app/src/layout/ipc_commands.rs
Normal file
46
packages/oneclient_app/src/layout/ipc_commands.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
next.discord_enabled = discord;
|
||||
next.crash_reporting = crash;
|
||||
next.start_maximized = maximized;
|
||||
dispatch.set_settings(next);
|
||||
dispatch.edit_settings(|next| {
|
||||
next.discord_enabled = discord;
|
||||
next.crash_reporting = crash;
|
||||
next.start_maximized = maximized;
|
||||
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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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::{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue