Stay in the tray on Wayland when the window closes

winit cannot hide a Wayland window, so closing to the tray cancelled the
close and then did nothing. Where the window cannot hide, the close now
goes through and the launcher keeps running with only the tray; a tray
click or a second launch opens a new window in the same process. Other
platforms keep the single hidden window. The header's own close button
(no decorations) takes the same path as the window manager's.

With no window, settings that change (the last check, updates announced)
are saved to eframe's app.ron as they change, so quitting from the tray
no longer loses them. A --hidden start at login on Wayland now starts in
the tray without mapping a window first, so none flashes up.

Rebased onto main from the original PR: the Launcher/Shell split now
carries main's custom title bar, has_desktops, macOS "show on the current
desktop", wgpu options and theme-token clear colour. The settings saving
with no window and the windowless start at login were added here.

Fixes #4

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
RealtaNuaandClaude Opus 5.5 authored and Brandon Thomas committed 2026-10-10 12:32:53 -04:00
1 parent f464b8145e
commit 7a193042fd
6 files changed
+752 -80

No files matched your search

Generated
+2
View File
@@ -290,6 +290,8 @@ dependencies = [
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"ron",
"serde",
"tray-icon",
"winresource",
]
+4
View File
@@ -22,6 +22,10 @@ log = { workspace = true }
env_logger = { version = "0.11", default-features = false, features = ["auto-color", "humantime"] }
image = { version = "0.25", default-features = false, features = ["png"] }
notify-rust = "4"
# Read and write eframe's settings file while there is no window (settings_file.rs). Already in
# Cargo.lock through eframe's "persistence" feature.
ron = "0.12.2"
serde = { workspace = true }
# The tray (menu bar) icon. On Linux and the BSDs the StatusNotifierItem backend talks D-Bus
# directly, so no GTK or libappindicator is needed.
+576 -80
View File
@@ -5,18 +5,23 @@
#![cfg_attr(windows, windows_subsystem = "windows")]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod settings_file;
#[cfg(target_os = "macos")]
mod spaces;
mod tray;
use std::path::PathBuf;
use std::process::ExitCode;
use std::sync::mpsc::Receiver;
use std::sync::{Arc, OnceLock};
use std::sync::mpsc::{Receiver, RecvTimeoutError, SyncSender, channel, sync_channel};
use std::sync::{Arc, Mutex, OnceLock, PoisonError};
use std::time::Duration;
use artcraft_launcher_core::protocol::Backend;
use artcraft_launcher_native::single_instance::{self, Instance};
use artcraft_launcher_native::{NativeBackend, login};
use artcraft_launcher_ui_egui::theme::Tokens;
use artcraft_launcher_ui_egui::{Host, LauncherUi, Settings, TitleBar};
use eframe::wgpu::rwh::{HasDisplayHandle, RawDisplayHandle};
const APP_ID: &str = "ai.storyteller.artcraft-launcher";
const SETTINGS_KEY: &str = "settings";
@@ -30,14 +35,64 @@ fn main() -> ExitCode {
println!("artcraft-launcher {}", env!("CARGO_PKG_VERSION"));
return ExitCode::SUCCESS;
}
let hidden = args.iter().any(|a| a == login::HIDDEN_FLAG);
let mut hidden = args.iter().any(|a| a == login::HIDDEN_FLAG);
let wake: Wake = Arc::new(OnceLock::new());
let show_requests = match single_instance::claim(wake.clone()) {
Instance::Secondary => return ExitCode::SUCCESS,
Instance::Primary(rx) => rx,
};
let (tray_loop, woken) = sync_channel(1);
let waker = Arc::new(Waker { window: Mutex::new(None), tray_loop });
{
let waker = waker.clone();
let _ = wake.set(Box::new(move || waker.wake()));
}
let backend_waker = waker.clone();
let settings_path = settings_file::path(APP_ID);
let mut launcher = Launcher::new(Box::new(NativeBackend::new(move || backend_waker.wake())), show_requests, settings_path.clone());
// Started hidden at login where a window can't hide: start in the tray without mapping one,
// so no window flashes up. Without a tray to come back from, the window opens after all.
let mut window = true;
if hidden && !window_can_hide_here(|name| std::env::var_os(name)) {
let settings = settings_path.as_deref().and_then(|p| settings_file::load(p, SETTINGS_KEY));
launcher.start(settings, &waker);
window = launcher.tray.is_none();
}
// One pass per window: where a window can't hide, closing to the tray ends its pass and the
// launcher carries on without one until the window is asked for again.
loop {
if window {
let result = {
let (waker, launcher) = (waker.clone(), &mut launcher);
eframe::run_native(
"ArtCraft Launcher",
native_options(hidden),
Box::new(move |cc| {
launcher.open(cc, &waker);
Ok(Box::new(Shell::new(cc, launcher, hidden)))
}),
)
};
waker.set_window(None);
if let Err(e) = result {
log::error!("{e}");
return ExitCode::FAILURE;
}
if !std::mem::take(&mut launcher.closed_to_tray) {
return ExitCode::SUCCESS;
}
}
if !launcher.wait_in_tray(&woken) {
return ExitCode::SUCCESS;
}
hidden = false;
window = true;
}
}
fn native_options(hidden: bool) -> eframe::NativeOptions {
let icon =
eframe::icon_data::from_png_bytes(include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.artcraft-launcher.png")).ok();
let mut viewport = egui::ViewportBuilder::default()
@@ -55,23 +110,7 @@ fn main() -> ExitCode {
if let Some(icon) = icon {
viewport = viewport.with_icon(icon);
}
let options = eframe::NativeOptions { viewport, persist_window: true, wgpu_options: wgpu_options(), ..Default::default() };
let result = eframe::run_native(
"ArtCraft Launcher",
options,
Box::new(move |cc| {
let ctx = cc.egui_ctx.clone();
let _ = wake.set(Box::new(move || ctx.request_repaint()));
Ok(Box::new(Shell::new(cc, show_requests, hidden)))
}),
);
match result {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
log::error!("{e}");
ExitCode::FAILURE
}
}
eframe::NativeOptions { viewport, persist_window: true, wgpu_options: wgpu_options(), ..Default::default() }
}
/// wgpu's backend list. On Windows that is Direct3D 12 with GL as the fallback, not Vulkan:
@@ -94,6 +133,89 @@ fn wgpu_options() -> eframe::WgpuConfiguration {
eframe::WgpuConfiguration::default()
}
/// Wakes whatever is running the launcher: the window while there is one, the tray loop otherwise.
struct Waker {
window: Mutex<Option<egui::Context>>,
tray_loop: SyncSender<()>,
}
impl Waker {
fn wake(&self) {
if let Some(ctx) = self.window.lock().unwrap_or_else(PoisonError::into_inner).as_ref() {
ctx.request_repaint();
}
let _ = self.tray_loop.try_send(());
}
fn set_window(&self, ctx: Option<egui::Context>) {
*self.window.lock().unwrap_or_else(PoisonError::into_inner) = ctx;
}
}
/// Whether the window system can hide a window and show it again. Wayland can't.
fn window_can_hide(display: Option<RawDisplayHandle>) -> bool {
!matches!(display, Some(RawDisplayHandle::Wayland(_)))
}
/// [`window_can_hide`] before there is a window, from the environment `env` reads: winit opens a
/// Wayland window wherever `WAYLAND_DISPLAY` or `WAYLAND_SOCKET` is set (Linux and the BSDs).
fn window_can_hide_here(env: impl Fn(&str) -> Option<std::ffi::OsString>) -> bool {
let wayland = || ["WAYLAND_DISPLAY", "WAYLAND_SOCKET"].iter().any(|name| env(name).is_some_and(|v| !v.is_empty()));
!(cfg!(all(unix, not(target_os = "macos"))) && wayland())
}
/// What a close request does.
#[derive(Debug, PartialEq, Eq)]
enum OnClose {
/// Keep the window and hide it.
Hide,
/// Let the window close and carry on in the tray without one.
ToTray,
Quit,
}
fn on_close(has_tray: bool, close_to_tray: bool, window_hides: bool) -> OnClose {
if !(has_tray && close_to_tray) {
OnClose::Quit
} else if window_hides {
OnClose::Hide
} else {
OnClose::ToTray
}
}
/// What the user asked for, from the tray or by starting the launcher again.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Request {
Toggle,
Show,
Quit,
}
/// What the launcher does next while it has no window.
#[derive(Debug, PartialEq, Eq)]
enum TrayStep {
Stay,
OpenWindow,
Quit,
}
fn tray_step(requests: &[Request]) -> TrayStep {
if requests.contains(&Request::Quit) {
TrayStep::Quit
} else if requests.is_empty() {
TrayStep::Stay
} else {
TrayStep::OpenWindow
}
}
/// What `LauncherUi::logic` is told about its window while there is none: it isn't focused.
fn no_window() -> egui::RawInput {
let viewport = egui::ViewportInfo { focused: Some(false), ..Default::default() };
egui::RawInput { focused: false, viewports: std::iter::once((egui::ViewportId::ROOT, viewport)).collect(), ..Default::default() }
}
struct DesktopHost {
tray: bool,
quit: bool,
@@ -141,11 +263,160 @@ impl Host for DesktopHost {
}
}
struct Shell {
/// Everything that outlives a window: the UI state and its backend, the tray icon, and the work
/// that goes on with or without a window.
struct Launcher {
ui: LauncherUi,
tray: Option<tray::Tray>,
host: DesktopHost,
show_requests: Receiver<()>,
/// Stands in for a window's context while only the tray exists. A window can't reuse it: a
/// new window's renderer has none of the textures a used context has already handed out.
windowless: egui::Context,
/// How soon `LauncherUi::logic` asked to run again, while there is no window.
run_again: Receiver<Duration>,
/// The saved settings are in and the tray icon is up.
started: bool,
/// The window closed to carry on in the tray, not to quit.
closed_to_tray: bool,
/// eframe's settings file, which the launcher writes itself while there is no window.
settings_path: Option<PathBuf>,
}
impl Launcher {
fn new(backend: Box<dyn Backend>, show_requests: Receiver<()>, settings_path: Option<PathBuf>) -> Self {
let windowless = egui::Context::default();
let (asked, run_again) = channel();
windowless.set_request_repaint_callback(move |info| {
let _ = asked.send(info.delay);
});
Self {
ui: LauncherUi::new(&windowless, backend, Settings::default()),
tray: None,
host: DesktopHost { tray: false, quit: false },
show_requests,
windowless,
run_again,
started: false,
closed_to_tray: false,
settings_path,
}
}
/// Takes in the saved settings and puts up the tray icon. On macOS the tray icon needs the
/// event loop running, so this waits for the first window there.
fn start(&mut self, settings: Option<Settings>, waker: &Arc<Waker>) {
self.started = true;
if let Some(settings) = settings {
self.ui.settings = settings;
}
let waker = waker.clone();
match tray::Tray::new(move || waker.wake()) {
Ok(t) => self.tray = Some(t),
Err(e) => log::warn!("no tray icon: {e}"),
}
self.host.tray = self.tray.is_some();
}
/// Readies the launcher for a new window. Unless the launcher started in the tray, the first
/// one brings the saved settings and the tray icon.
fn open(&mut self, cc: &eframe::CreationContext<'_>, waker: &Arc<Waker>) {
waker.set_window(Some(cc.egui_ctx.clone()));
if !self.started {
self.start(cc.storage.and_then(|s| eframe::get_value(s, SETTINGS_KEY)), waker);
}
self.ui.attach(&cc.egui_ctx);
}
/// Saves the settings with no window, where eframe would on a window's behalf.
fn save_settings(&self) {
let Some(path) = self.settings_path.as_deref() else { return };
if let Err(e) = settings_file::save(path, SETTINGS_KEY, &self.ui.settings) {
log::warn!("couldn't save the settings: {e}");
}
}
/// The work that goes on with or without a window. Returns what the user asked for.
fn tick(&mut self, ctx: &egui::Context) -> Vec<Request> {
self.ui.logic(ctx, &mut self.host);
let mut requests = Vec::new();
if self.show_requests.try_iter().count() > 0 {
requests.push(Request::Show);
}
let actions = self.tray.as_ref().map(tray::Tray::actions).unwrap_or_default();
for action in actions {
match action {
tray::TrayAction::Toggle => requests.push(Request::Toggle),
tray::TrayAction::Show => requests.push(Request::Show),
tray::TrayAction::Check => self.ui.check_now(),
// Straight to the browser: works while the window is hidden in the tray.
tray::TrayAction::Open(url) => {
if let Err(e) = artcraft_launcher_native::process::open(std::ffi::OsStr::new(url)) {
log::warn!("couldn't open {url}: {e}");
}
}
tray::TrayAction::Quit => requests.push(Request::Quit),
}
}
if let Some(t) = self.tray.as_mut() {
t.set_updates(self.ui.update_count());
}
if self.host.quit {
self.host.quit = false;
requests.push(Request::Quit);
}
requests
}
/// One [`Self::tick`] with no window.
fn tick_in_tray(&mut self) -> Vec<Request> {
let ctx = self.windowless.clone();
// Only what this tick asks for says when the next one is due.
self.run_again.try_iter().for_each(drop);
let mut requests = Vec::new();
let _ = ctx.run_logic(&no_window(), |ctx| requests = self.tick(ctx));
requests
}
/// Carries on with no window until the user asks for it back (`true`) or quits (`false`).
/// Settings that change meanwhile (the last check, updates announced, tray toggles) are saved
/// as they change, as no window is there to save them.
fn wait_in_tray(&mut self, woken: &Receiver<()>) -> bool {
log::debug!("no window: carrying on in the tray");
// What eframe saved as the window closed, or what was just read in at a start in the tray.
let mut saved = self.ui.settings.clone();
loop {
let step = tray_step(&self.tick_in_tray());
if self.ui.settings != saved {
self.save_settings();
saved = self.ui.settings.clone();
}
match step {
TrayStep::OpenWindow => return true,
TrayStep::Quit => return false,
TrayStep::Stay => {}
}
// Sleep until woken, or until `LauncherUi::logic` wants its next scheduled look.
match self.run_again.try_iter().min() {
Some(wait) => {
if woken.recv_timeout(wait) == Err(RecvTimeoutError::Disconnected) {
std::thread::sleep(wait);
}
}
None => {
if woken.recv().is_err() {
return false;
}
}
}
}
}
}
/// One window onto the launcher.
struct Shell<'a> {
launcher: &'a mut Launcher,
window_hides: bool,
visible: bool,
quitting: bool,
/// The "show on the current desktop" setting last applied to the window (macOS).
@@ -153,29 +424,18 @@ struct Shell {
applied_follow: Option<bool>,
}
impl Shell {
fn new(cc: &eframe::CreationContext<'_>, show_requests: Receiver<()>, hidden: bool) -> Self {
let ctx = cc.egui_ctx.clone();
let settings: Settings = cc.storage.and_then(|s| eframe::get_value(s, SETTINGS_KEY)).unwrap_or_default();
let waker_ctx = ctx.clone();
let backend = NativeBackend::new(move || waker_ctx.request_repaint());
let tray_ctx = ctx.clone();
let tray = match tray::Tray::new(move || tray_ctx.request_repaint()) {
Ok(t) => Some(t),
Err(e) => {
log::warn!("no tray icon: {e}");
None
}
};
let host = DesktopHost { tray: tray.is_some(), quit: false };
impl<'a> Shell<'a> {
fn new(cc: &eframe::CreationContext<'_>, launcher: &'a mut Launcher, hidden: bool) -> Self {
let window_hides = window_can_hide(cc.display_handle().ok().map(|d| d.as_raw()));
// Started hidden at login but there's no tray to come back from: show the window.
let visible = !hidden || tray.is_none();
let visible = !hidden || launcher.tray.is_none();
let mut shell = Self { launcher, window_hides, visible: true, quitting: false, applied_follow: None };
if !visible {
ctx.send_viewport_cmd(egui::ViewportCommand::Visible(false));
shell.put_away(&cc.egui_ctx);
} else if hidden {
ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true));
cc.egui_ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true));
}
Self { ui: LauncherUi::new(&ctx, Box::new(backend), settings), tray, host, show_requests, visible, quitting: false, applied_follow: None }
shell
}
fn show(&mut self, ctx: &egui::Context) {
@@ -184,76 +444,312 @@ impl Shell {
// user to its own (macOS; the setting doesn't exist elsewhere).
#[cfg(target_os = "macos")]
{
let on = self.ui.settings.show_on_current_desktop;
let on = self.launcher.ui.settings.show_on_current_desktop;
spaces::follow_current_desktop(on);
self.applied_follow = Some(on);
}
ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true));
ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false));
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
if self.window_hides {
ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true));
ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false));
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
} else {
// The window is already open and can't be raised from here: ask the desktop to point it out.
ctx.send_viewport_cmd(egui::ViewportCommand::RequestUserAttention(egui::UserAttentionType::Informational));
}
}
fn hide(&mut self, ctx: &egui::Context) {
self.visible = false;
ctx.send_viewport_cmd(egui::ViewportCommand::Visible(false));
/// Puts the window away in the tray: hidden where it can hide, closed where it can't.
fn put_away(&mut self, ctx: &egui::Context) {
if self.window_hides {
self.visible = false;
ctx.send_viewport_cmd(egui::ViewportCommand::Visible(false));
} else {
self.launcher.closed_to_tray = true;
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
}
fn quit(&mut self, ctx: &egui::Context) {
self.quitting = true;
self.launcher.closed_to_tray = false;
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
}
impl eframe::App for Shell {
impl eframe::App for Shell<'_> {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.ui.logic(ctx, &mut self.host);
let requests = self.launcher.tick(ctx);
#[cfg(target_os = "macos")]
if self.applied_follow != Some(self.ui.settings.show_on_current_desktop) {
let on = self.ui.settings.show_on_current_desktop;
if self.applied_follow != Some(self.launcher.ui.settings.show_on_current_desktop) {
let on = self.launcher.ui.settings.show_on_current_desktop;
spaces::follow_current_desktop(on);
self.applied_follow = Some(on);
}
if self.show_requests.try_iter().count() > 0 {
self.show(ctx);
}
let actions = self.tray.as_ref().map(tray::Tray::actions).unwrap_or_default();
for action in actions {
match action {
tray::TrayAction::Toggle if self.visible => self.hide(ctx),
tray::TrayAction::Toggle | tray::TrayAction::Show => self.show(ctx),
tray::TrayAction::Check => self.ui.check_now(),
// Straight to the browser: works while the window is hidden in the tray.
tray::TrayAction::Open(url) => {
if let Err(e) = artcraft_launcher_native::process::open(std::ffi::OsStr::new(url)) {
log::warn!("couldn't open {url}: {e}");
}
}
tray::TrayAction::Quit => self.quit(ctx),
for request in requests {
match request {
Request::Toggle if self.visible => self.put_away(ctx),
Request::Toggle | Request::Show => self.show(ctx),
Request::Quit => self.quit(ctx),
}
}
if let Some(t) = self.tray.as_mut() {
t.set_updates(self.ui.update_count());
}
if self.host.quit {
self.host.quit = false;
self.quit(ctx);
}
// The close button hides to the tray unless the user chose to quit.
if ctx.input(|i| i.viewport().close_requested()) && !self.quitting && self.tray.is_some() && self.ui.settings.close_to_tray {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
self.hide(ctx);
// The close button puts the window away in the tray unless the user chose to quit. That is
// the window manager's button or the header's own (no decorations), which asks to close
// with `ViewportCommand::Close` and arrives here the same way.
if ctx.input(|i| i.viewport().close_requested()) && !self.quitting && !self.launcher.closed_to_tray {
match on_close(self.launcher.tray.is_some(), self.launcher.ui.settings.close_to_tray, self.window_hides) {
OnClose::Hide => {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
self.put_away(ctx);
}
OnClose::ToTray => self.launcher.closed_to_tray = true,
OnClose::Quit => {}
}
}
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.ui.ui(ui, &mut self.host);
self.launcher.ui.ui(ui, &mut self.launcher.host);
}
fn save(&mut self, storage: &mut dyn eframe::Storage) {
eframe::set_value(storage, SETTINGS_KEY, &self.ui.settings);
eframe::set_value(storage, SETTINGS_KEY, &self.launcher.ui.settings);
}
fn clear_color(&self, visuals: &egui::Visuals) -> [f32; 4] {
(if visuals.dark_mode { Tokens::DARK.bg } else { Tokens::LIGHT.bg }).to_normalized_gamma_f32()
}
}
#[cfg(test)]
mod tests {
use std::ptr::NonNull;
use std::sync::atomic::{AtomicI64, AtomicUsize, Ordering};
use std::sync::mpsc::Sender;
use std::time::Instant;
use artcraft_launcher_core::protocol::{Capabilities, Command, Event, Source};
use eframe::wgpu::rwh::{AppKitDisplayHandle, WaylandDisplayHandle, WindowsDisplayHandle, XcbDisplayHandle, XlibDisplayHandle};
use super::*;
const NOW: i64 = 1_791_500_000;
/// What the tests can see of a [`Quiet`] backend, and its clock.
#[derive(Default)]
struct Seen {
polls: AtomicUsize,
detects: AtomicUsize,
clock: AtomicI64,
/// What the next poll hands out.
news: Mutex<Vec<Event>>,
}
/// A backend with nothing to say unless the test gives it news.
struct Quiet(Arc<Seen>);
impl Backend for Quiet {
fn send(&mut self, command: Command) {
if command == Command::Detect {
self.0.detects.fetch_add(1, Ordering::Relaxed);
}
}
fn poll(&mut self) -> Vec<Event> {
self.0.polls.fetch_add(1, Ordering::Relaxed);
std::mem::take(&mut *self.0.news.lock().unwrap_or_else(PoisonError::into_inner))
}
fn capabilities(&self) -> Capabilities {
Capabilities { manage_apps: true, reveal_label: "Show in Files", self_update: false }
}
fn platform(&self) -> Option<artcraft_launcher_core::Platform> {
None
}
fn now(&self) -> i64 {
self.0.clock.load(Ordering::Relaxed)
}
}
/// A launcher with no tray and no window, the sender of its show requests, and its backend's record.
fn launcher() -> (Launcher, Sender<()>, Arc<Seen>) {
let seen = Arc::new(Seen { clock: AtomicI64::new(NOW), ..Seen::default() });
let (show, show_requests) = channel();
(Launcher::new(Box::new(Quiet(seen.clone())), show_requests, None), show, seen)
}
#[test]
fn a_wayland_window_cannot_hide() {
assert!(!window_can_hide(Some(RawDisplayHandle::Wayland(WaylandDisplayHandle::new(NonNull::dangling())))));
}
#[test]
fn other_windows_hide() {
for display in [
RawDisplayHandle::Xlib(XlibDisplayHandle::new(None, 0)),
RawDisplayHandle::Xcb(XcbDisplayHandle::new(None, 0)),
RawDisplayHandle::Windows(WindowsDisplayHandle::new()),
RawDisplayHandle::AppKit(AppKitDisplayHandle::new()),
] {
assert!(window_can_hide(Some(display)), "{display:?}");
}
assert!(window_can_hide(None));
}
#[test]
fn a_wayland_session_is_known_before_the_window_opens() {
let env = |set: &'static [(&'static str, &'static str)]| move |name: &str| set.iter().find(|(n, _)| *n == name).map(|(_, v)| v.into());
let wayland_elsewhere = !cfg!(all(unix, not(target_os = "macos")));
assert_eq!(window_can_hide_here(env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":0")])), wayland_elsewhere);
assert_eq!(window_can_hide_here(env(&[("WAYLAND_SOCKET", "3")])), wayland_elsewhere);
assert!(window_can_hide_here(env(&[("DISPLAY", ":0")])));
assert!(window_can_hide_here(env(&[("WAYLAND_DISPLAY", ""), ("DISPLAY", ":0")])));
assert!(window_can_hide_here(env(&[])));
}
#[test]
fn closing_keeps_the_launcher_in_the_tray() {
assert_eq!(on_close(true, true, true), OnClose::Hide);
assert_eq!(on_close(true, true, false), OnClose::ToTray);
}
#[test]
fn closing_quits_without_a_tray_or_when_the_user_turned_it_off() {
for window_hides in [true, false] {
assert_eq!(on_close(false, true, window_hides), OnClose::Quit);
assert_eq!(on_close(true, false, window_hides), OnClose::Quit);
assert_eq!(on_close(false, false, window_hides), OnClose::Quit);
}
}
#[test]
fn the_tray_alone_waits_until_asked() {
assert_eq!(tray_step(&[]), TrayStep::Stay);
}
#[test]
fn the_tray_alone_opens_the_window_when_asked() {
assert_eq!(tray_step(&[Request::Toggle]), TrayStep::OpenWindow);
assert_eq!(tray_step(&[Request::Show]), TrayStep::OpenWindow);
}
#[test]
fn quit_wins_over_opening_the_window() {
assert_eq!(tray_step(&[Request::Quit]), TrayStep::Quit);
assert_eq!(tray_step(&[Request::Show, Request::Quit]), TrayStep::Quit);
assert_eq!(tray_step(&[Request::Quit, Request::Toggle]), TrayStep::Quit);
}
#[test]
fn with_no_window_a_show_request_opens_one() {
let (mut launcher, show, _) = launcher();
let (_wake, woken) = sync_channel(1);
assert!(show.send(()).is_ok());
assert!(launcher.wait_in_tray(&woken));
}
#[test]
fn with_no_window_quit_wins_over_a_show_request() {
let (mut launcher, show, _) = launcher();
let (_wake, woken) = sync_channel(1);
assert!(show.send(()).is_ok());
launcher.host.quit();
assert!(!launcher.wait_in_tray(&woken));
}
#[test]
fn with_no_window_it_sleeps_until_woken() {
let (mut launcher, show, seen) = launcher();
let (wake, woken) = sync_channel(1);
// Setting the UI up asked for one immediate run.
assert!(launcher.tick_in_tray().is_empty());
let pause = Duration::from_millis(200);
let waking = std::thread::spawn(move || {
std::thread::sleep(pause);
let _ = show.send(());
let _ = wake.try_send(());
});
let started = Instant::now();
assert!(launcher.wait_in_tray(&woken));
assert!(started.elapsed() >= pause);
// After the first run: once on the way in and once when woken. It slept in between.
assert_eq!(seen.polls.load(Ordering::Relaxed), 3);
assert!(waking.join().is_ok());
}
#[test]
fn with_no_window_the_ui_says_when_to_look_again() {
let (mut launcher, _show, _) = launcher();
// Setting the UI up asked for one immediate run.
assert!(launcher.tick_in_tray().is_empty());
assert_eq!(launcher.run_again.try_iter().min(), Some(Duration::ZERO));
assert!(launcher.tick_in_tray().is_empty());
// The UI's own interval, less the frame time egui takes off it.
let wait = launcher.run_again.try_iter().min().unwrap_or_default();
assert!(wait > Duration::from_secs(59) && wait <= Duration::from_secs(60), "{wait:?}");
}
#[test]
fn with_no_window_nobody_came_back_to_it() {
let (mut launcher, _show, seen) = launcher();
// The window lost focus before it closed.
let window = egui::Context::default();
let _ = window.run_logic(&no_window(), |ctx| assert!(launcher.tick(ctx).is_empty()));
// Long enough that coming back to a window would look again at what's installed.
seen.clock.store(NOW + 60, Ordering::Relaxed);
assert!(launcher.tick_in_tray().is_empty());
assert_eq!(seen.detects.load(Ordering::Relaxed), 0);
}
#[test]
fn with_no_window_changed_settings_are_saved_before_quitting() {
let dir = std::env::temp_dir().join(format!("artcraft-launcher-tray-save-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = dir.join("app.ron");
let seen = Arc::new(Seen { clock: AtomicI64::new(NOW), ..Seen::default() });
let (_show, show_requests) = channel();
let mut launcher = Launcher::new(Box::new(Quiet(seen.clone())), show_requests, Some(path.clone()));
let (_wake, woken) = sync_channel(1);
// A check finishes in the same tick as the user quits from the tray.
seen.news.lock().unwrap_or_else(PoisonError::into_inner).push(Event::CheckFinished {
at: NOW - 5,
source: Source::Feed,
rate_limited_until: None,
});
launcher.host.quit();
assert!(!launcher.wait_in_tray(&woken));
let saved: Option<Settings> = settings_file::load(&path, SETTINGS_KEY);
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(saved.and_then(|s| s.last_check), Some(NOW - 5));
}
#[test]
fn with_no_window_unchanged_settings_are_not_written() {
let dir = std::env::temp_dir().join(format!("artcraft-launcher-tray-nosave-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let (_, show_requests) = channel();
let mut launcher = Launcher::new(Box::new(Quiet(Arc::new(Seen::default()))), show_requests, Some(dir.join("app.ron")));
let (_wake, woken) = sync_channel(1);
launcher.host.quit();
assert!(!launcher.wait_in_tray(&woken));
assert!(!dir.exists());
}
#[test]
fn quitting_overrides_a_close_to_the_tray() {
let (mut launcher, _show, _) = launcher();
let ctx = launcher.windowless.clone();
let mut shell = Shell { launcher: &mut launcher, window_hides: false, visible: true, quitting: false, applied_follow: None };
shell.put_away(&ctx);
assert!(shell.launcher.closed_to_tray);
shell.quit(&ctx);
assert!(!shell.launcher.closed_to_tray);
}
#[test]
fn a_window_that_hides_is_not_closed_to_the_tray() {
let (mut launcher, _show, _) = launcher();
let ctx = launcher.windowless.clone();
let mut shell = Shell { launcher: &mut launcher, window_hides: true, visible: true, quitting: false, applied_follow: None };
shell.put_away(&ctx);
assert!(!shell.visible);
assert!(!shell.launcher.closed_to_tray);
}
}
+131
View File
@@ -0,0 +1,131 @@
//! The settings in eframe's own file, read and written while there is no window.
//!
//! eframe keeps its state (window size, egui memory and the launcher's settings) in `app.ron` in
//! its storage folder, but only while a window is open. Where the window closes to the tray
//! (Wayland), the launcher carries on without one, so it saves its settings to the same file
//! itself: same folder, same format, other keys left as they are.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
/// Where eframe keeps its state for `app_id`, as it picks it for a window with that app id.
pub fn path(app_id: &str) -> Option<PathBuf> {
eframe::storage_dir(app_id).map(|dir| dir.join("app.ron"))
}
/// The value stored under `key`, if the file has one that still reads.
pub fn load<T: serde::de::DeserializeOwned>(path: &Path, key: &str) -> Option<T> {
eframe::get_value(&RonFile::read(path), key)
}
/// Stores `value` under `key`, keeping what else the file holds.
pub fn save<T: serde::Serialize>(path: &Path, key: &str, value: &T) -> Result<(), String> {
let mut file = RonFile::read(path);
eframe::set_value(&mut file, key, value);
file.write(path)
}
/// eframe's file: a RON map of keys to RON strings.
#[derive(Default)]
struct RonFile(HashMap<String, String>);
impl RonFile {
/// What the file holds; empty when it is missing or doesn't read, as eframe takes it.
fn read(path: &Path) -> Self {
let Ok(text) = std::fs::read_to_string(path) else { return Self::default() };
match ron::from_str(&text) {
Ok(kv) => Self(kv),
Err(e) => {
log::warn!("{}: {e}", path.display());
Self::default()
}
}
}
/// Writes a new file and moves it into place, so a failed write leaves the old one whole.
fn write(&self, path: &Path) -> Result<(), String> {
let text = ron::ser::to_string_pretty(&self.0, ron::ser::PrettyConfig::default()).map_err(|e| e.to_string())?;
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
}
let tmp = path.with_extension("ron.tmp");
std::fs::write(&tmp, text).map_err(|e| format!("{}: {e}", tmp.display()))?;
std::fs::rename(&tmp, path).map_err(|e| format!("{}: {e}", path.display()))
}
}
impl eframe::Storage for RonFile {
fn get_string(&self, key: &str) -> Option<String> {
self.0.get(key).cloned()
}
fn set_string(&mut self, key: &str, value: String) {
self.0.insert(key.to_string(), value);
}
fn remove_string(&mut self, key: &str) {
self.0.remove(key);
}
fn flush(&mut self) {}
}
#[cfg(test)]
mod tests {
use artcraft_launcher_ui_egui::Settings;
use super::*;
/// A file in a folder of its own, removed when the test ends.
struct Scratch(PathBuf);
impl Scratch {
fn new(name: &str) -> Self {
let dir = std::env::temp_dir().join(format!("artcraft-launcher-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
Self(dir)
}
fn file(&self) -> PathBuf {
self.0.join("app.ron")
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn settings_saved_with_no_file_read_back() {
let dir = Scratch::new("settings-round-trip");
let settings = Settings { last_check: Some(1_791_500_000), notify: false, ..Settings::default() };
assert_eq!(save(&dir.file(), "settings", &settings), Ok(()));
assert_eq!(load::<Settings>(&dir.file(), "settings"), Some(settings));
}
#[test]
fn saving_keeps_what_eframe_stored() {
let dir = Scratch::new("settings-keeps-window");
assert!(std::fs::create_dir_all(&dir.0).is_ok());
// As eframe writes it: a map of keys to RON strings.
let window = r#"(inner_position_pixels:Some((x:10.0,y:20.0)),fullscreen:false)"#;
let eframe_file = format!("{{\n \"window\": {window:?},\n \"settings\": \"(notify:true)\",\n}}");
assert!(std::fs::write(dir.file(), eframe_file).is_ok());
assert_eq!(load::<Settings>(&dir.file(), "settings").map(|s| s.notify), Some(true));
let settings = Settings { notify: false, ..Settings::default() };
assert_eq!(save(&dir.file(), "settings", &settings), Ok(()));
assert_eq!(load::<Settings>(&dir.file(), "settings"), Some(settings));
assert_eq!(RonFile::read(&dir.file()).0.get("window").map(String::as_str), Some(window));
}
#[test]
fn a_missing_or_broken_file_has_no_settings() {
let dir = Scratch::new("settings-broken");
assert_eq!(load::<Settings>(&dir.file(), "settings"), None);
assert!(std::fs::create_dir_all(&dir.0).is_ok());
assert!(std::fs::write(dir.file(), "not ron {").is_ok());
assert_eq!(load::<Settings>(&dir.file(), "settings"), None);
}
}
+10
View File
@@ -137,6 +137,16 @@ impl LauncherUi {
}
}
/// Moves the UI to another window's context: fonts, image loaders and the theme belong to a
/// context, and toasts are timed on its clock.
pub fn attach(&mut self, ctx: &egui::Context) {
theme::install_fonts(ctx);
egui_extras::install_image_loaders(ctx);
self.fonts_ready = false;
self.applied_theme = None;
self.toasts.clear();
}
/// Installed apps with an update waiting (the tray shows the count).
pub fn update_count(&self) -> usize {
self.entries.iter().filter(|e| e.status(self.platform, self.caps.manage_apps) == AppStatus::UpdateAvailable).count()
+29
View File
@@ -145,6 +145,35 @@ fn snapshot(h: &mut Harness<'_, Option<LauncherUi>>, name: &str) {
img.save(std::path::Path::new(&dir).join(format!("{name}.png"))).unwrap();
}
#[test]
fn keeps_working_with_no_window_then_draws_in_a_new_one() {
let sent = Rc::new(RefCell::new(Vec::new()));
let backend = Fake { events: events(), sent: sent.clone(), manage: true, clock: Rc::new(Cell::new(NOW)) };
let windowless = egui::Context::default();
let mut ui = LauncherUi::new(&windowless, Box::new(backend), Settings { notify: false, ..Settings::default() });
// No window: the backend's news is still taken in.
ui.logic(&windowless, &mut NoHost);
assert!(sent.borrow().contains(&Command::CheckForUpdates { force: false }));
assert_eq!(ui.update_count(), 2);
assert_eq!(ui.settings.last_check, Some(NOW - 180));
let mut attached = false;
let mut h = Harness::builder().with_size(egui::vec2(1180.0, 900.0)).build_ui_state(
move |egui_ui, state: &mut LauncherUi| {
if !attached {
attached = true;
state.attach(egui_ui.ctx());
}
state.logic(&egui_ui.ctx().clone(), &mut NoHost);
state.ui(egui_ui, &mut NoHost);
},
ui,
);
h.run_steps(4);
h.get_by_label("Update to 0.5.0");
h.get_by_label("Update to 0.6.0");
}
#[test]
fn grid_shows_every_status_and_installs_on_click() {
let (mut h, sent) = harness(ViewMode::Grid, egui::Theme::Dark, true);