Skip to content

Commit 4074259

Browse files
authored
feat(macOS): reopen hidden window on relaunch and add dock icon (#135)
1 parent c495ba7 commit 4074259

7 files changed

Lines changed: 233 additions & 4 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/plumeimpactor/Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@ single-instance = "0.3.3"
3232
auto-launcher = "0.6.1" # Auto-launching on startup
3333

3434
[target.'cfg(target_os = "macos")'.dependencies]
35-
plume_gestalt = { path = "../../crates/plume_gestalt" }
35+
plume_gestalt = { path = "../../crates/plume_gestalt" }
36+
objc2 = "0.6"
37+
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSRunningApplication"] }
3638

3739
[build-dependencies]
3840
winresource = "0.1"
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
#[cfg(target_os = "macos")]
2+
use std::sync::atomic::{AtomicBool, Ordering};
3+
4+
#[cfg(target_os = "macos")]
5+
static APP_WAS_ACTIVE: AtomicBool = AtomicBool::new(false);
6+
7+
#[cfg(target_os = "macos")]
8+
pub(crate) fn set_main_window_visible(visible: bool) {
9+
use objc2::MainThreadMarker;
10+
use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy};
11+
12+
let Some(main_thread) = MainThreadMarker::new() else {
13+
log::warn!("Unable to update macOS activation policy off the main thread");
14+
return;
15+
};
16+
17+
let app = NSApplication::sharedApplication(main_thread);
18+
let policy = if visible {
19+
NSApplicationActivationPolicy::Regular
20+
} else {
21+
NSApplicationActivationPolicy::Accessory
22+
};
23+
24+
if !app.setActivationPolicy(policy) {
25+
log::warn!("Failed to switch macOS activation policy");
26+
}
27+
28+
if visible {
29+
app.activate();
30+
} else {
31+
app.deactivate();
32+
}
33+
}
34+
35+
#[cfg(target_os = "macos")]
36+
pub(crate) fn reset_activation_state() {
37+
use objc2::MainThreadMarker;
38+
use objc2_app_kit::NSApplication;
39+
40+
let Some(main_thread) = MainThreadMarker::new() else {
41+
return;
42+
};
43+
44+
let app = NSApplication::sharedApplication(main_thread);
45+
APP_WAS_ACTIVE.store(app.isActive(), Ordering::Relaxed);
46+
}
47+
48+
#[cfg(target_os = "macos")]
49+
pub(crate) fn activation_reopen_requested() -> bool {
50+
use objc2::MainThreadMarker;
51+
use objc2_app_kit::NSApplication;
52+
53+
let Some(main_thread) = MainThreadMarker::new() else {
54+
return false;
55+
};
56+
57+
let app = NSApplication::sharedApplication(main_thread);
58+
let is_active = app.isActive();
59+
let was_active = APP_WAS_ACTIVE.swap(is_active, Ordering::Relaxed);
60+
61+
is_active && !was_active
62+
}
63+
64+
#[cfg(not(target_os = "macos"))]
65+
pub(crate) fn set_main_window_visible(_visible: bool) {}
66+
67+
#[cfg(not(target_os = "macos"))]
68+
pub(crate) fn reset_activation_state() {}
69+
70+
#[cfg(not(target_os = "macos"))]
71+
pub(crate) fn activation_reopen_requested() -> bool {
72+
false
73+
}

apps/plumeimpactor/src/main.rs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22

33
use crate::refresh::spawn_refresh_daemon;
44

5-
#[cfg(any(target_os = "linux", target_os = "windows"))]
5+
#[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))]
66
use single_instance::SingleInstance;
77

88
mod appearance;
99
mod defaults;
10+
mod macos_app;
1011
mod refresh;
12+
mod relaunch;
1113
mod screen;
1214
mod startup;
1315
mod subscriptions;
@@ -23,10 +25,13 @@ fn main() -> iced::Result {
2325
.install_default()
2426
.ok();
2527

26-
#[cfg(any(target_os = "linux", target_os = "windows"))]
27-
let _single_instance = match SingleInstance::new(APP_NAME) {
28+
#[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))]
29+
let _single_instance = match SingleInstance::new(&crate::relaunch::single_instance_key()) {
2830
Ok(instance) => {
2931
if !instance.is_single() {
32+
if let Err(err) = crate::relaunch::notify_running_instance() {
33+
log::warn!("Failed to signal existing instance: {err}");
34+
}
3035
log::info!("Another instance is already running; exiting.");
3136
return Ok(());
3237
}

apps/plumeimpactor/src/relaunch.rs

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
#[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))]
2+
use std::io::{Read, Write};
3+
#[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))]
4+
use std::net::{Ipv4Addr, TcpListener, TcpStream};
5+
6+
#[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))]
7+
const RELAUNCH_SIGNAL: &[u8] = b"show";
8+
#[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))]
9+
const RELAUNCH_PORT_FILE: &str = "relaunch.port";
10+
#[cfg(target_os = "macos")]
11+
const SINGLE_INSTANCE_FILE: &str = "single-instance.lock";
12+
13+
#[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))]
14+
fn relaunch_port_path() -> std::path::PathBuf {
15+
crate::defaults::get_data_path().join(RELAUNCH_PORT_FILE)
16+
}
17+
18+
#[cfg(any(target_os = "linux", target_os = "windows"))]
19+
pub(crate) fn single_instance_key() -> String {
20+
crate::APP_NAME.to_string()
21+
}
22+
23+
#[cfg(target_os = "macos")]
24+
pub(crate) fn single_instance_key() -> String {
25+
crate::defaults::get_data_path()
26+
.join(SINGLE_INSTANCE_FILE)
27+
.to_string_lossy()
28+
.into_owned()
29+
}
30+
31+
#[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))]
32+
pub(crate) fn notify_running_instance() -> Result<(), String> {
33+
let port_contents =
34+
std::fs::read_to_string(relaunch_port_path()).map_err(|e| format!("read port: {e}"))?;
35+
let port: u16 = port_contents
36+
.trim()
37+
.parse()
38+
.map_err(|e| format!("parse port: {e}"))?;
39+
40+
let mut stream =
41+
TcpStream::connect((Ipv4Addr::LOCALHOST, port)).map_err(|e| format!("connect: {e}"))?;
42+
stream
43+
.write_all(RELAUNCH_SIGNAL)
44+
.map_err(|e| format!("send signal: {e}"))
45+
}
46+
47+
#[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))]
48+
pub(crate) fn start_listener<F>(on_relaunch: F) -> Result<(), String>
49+
where
50+
F: Fn() + Send + Sync + 'static,
51+
{
52+
let listener =
53+
TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).map_err(|e| format!("bind listener: {e}"))?;
54+
let port = listener
55+
.local_addr()
56+
.map_err(|e| format!("resolve listener addr: {e}"))?
57+
.port();
58+
59+
std::fs::write(relaunch_port_path(), port.to_string())
60+
.map_err(|e| format!("write port: {e}"))?;
61+
62+
let on_relaunch = std::sync::Arc::new(on_relaunch);
63+
std::thread::spawn(move || {
64+
for incoming in listener.incoming() {
65+
match incoming {
66+
Ok(mut stream) => {
67+
let mut buffer = [0_u8; 16];
68+
match stream.read(&mut buffer) {
69+
Ok(bytes_read) if bytes_read > 0 => {
70+
if buffer[..bytes_read].starts_with(RELAUNCH_SIGNAL) {
71+
on_relaunch();
72+
}
73+
}
74+
Ok(_) => {}
75+
Err(err) => log::warn!("Failed to read relaunch signal: {err}"),
76+
}
77+
}
78+
Err(err) => {
79+
log::warn!("Relaunch listener stopped: {err}");
80+
break;
81+
}
82+
}
83+
}
84+
});
85+
86+
Ok(())
87+
}

apps/plumeimpactor/src/screen/mod.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ pub enum Message {
4343
TrayIconClicked,
4444
#[cfg(target_os = "linux")]
4545
GtkTick,
46+
#[cfg(target_os = "macos")]
47+
MacOsActivationTick,
4648

4749
// Refresh operations
4850
RefreshAppNow {
@@ -56,6 +58,7 @@ pub enum Message {
5658
UpdateTrayMenu,
5759

5860
// Window management
61+
RelaunchRequested,
5962
ShowWindow,
6063
HideWindow,
6164
Quit,
@@ -115,6 +118,8 @@ impl Impactor {
115118
let (id, open_task) = window::open(defaults::default_window_settings());
116119
(Some(id), open_task.discard())
117120
};
121+
crate::macos_app::set_main_window_visible(main_window.is_some());
122+
crate::macos_app::reset_activation_state();
118123

119124
(
120125
Self {
@@ -292,7 +297,23 @@ impl Impactor {
292297
while gtk::glib::MainContext::default().iteration(false) {}
293298
Task::none()
294299
}
300+
#[cfg(target_os = "macos")]
301+
Message::MacOsActivationTick => {
302+
if self.main_window.is_none() && crate::macos_app::activation_reopen_requested() {
303+
Task::done(Message::RelaunchRequested)
304+
} else {
305+
Task::none()
306+
}
307+
}
308+
Message::RelaunchRequested => {
309+
if self.main_window.is_none() {
310+
Task::done(Message::ShowWindow)
311+
} else {
312+
Task::none()
313+
}
314+
}
295315
Message::ShowWindow => {
316+
crate::macos_app::set_main_window_visible(true);
296317
if let Some(id) = self.main_window {
297318
window::gain_focus(id)
298319
} else {
@@ -304,6 +325,7 @@ impl Impactor {
304325
Message::HideWindow => {
305326
if let Some(id) = self.main_window {
306327
self.main_window = None;
328+
crate::macos_app::set_main_window_visible(false);
307329
window::close(id)
308330
} else {
309331
Task::none()
@@ -700,6 +722,7 @@ impl Impactor {
700722
};
701723

702724
let tray_menu_refresh_subscription = subscriptions::tray_menu_refresh_subscription();
725+
let relaunch_subscription = subscriptions::relaunch_subscription();
703726

704727
let close_subscription = iced::event::listen_with(|event, _status, _id| {
705728
if let iced::Event::Window(window::Event::CloseRequested) = event {
@@ -714,6 +737,7 @@ impl Impactor {
714737
hover_subscription,
715738
progress_subscription,
716739
tray_menu_refresh_subscription,
740+
relaunch_subscription,
717741
close_subscription,
718742
])
719743
}

apps/plumeimpactor/src/subscriptions.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,11 @@ pub(crate) fn tray_subscription() -> Subscription<Message> {
108108
let _ = tx.unbounded_send(Message::GtkTick);
109109
}
110110

111+
#[cfg(target_os = "macos")]
112+
{
113+
let _ = tx.unbounded_send(Message::MacOsActivationTick);
114+
}
115+
111116
std::thread::sleep(std::time::Duration::from_millis(32));
112117
}
113118
});
@@ -143,6 +148,37 @@ pub(crate) fn tray_menu_refresh_subscription() -> Subscription<Message> {
143148
})
144149
}
145150

151+
#[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))]
152+
pub(crate) fn relaunch_subscription() -> Subscription<Message> {
153+
Subscription::run(|| {
154+
iced::stream::channel(
155+
10,
156+
|mut output: iced::futures::channel::mpsc::Sender<Message>| async move {
157+
use iced::futures::{SinkExt, StreamExt};
158+
let (tx, mut rx) = iced::futures::channel::mpsc::unbounded::<Message>();
159+
160+
if let Err(err) = crate::relaunch::start_listener({
161+
let tx = tx.clone();
162+
move || {
163+
let _ = tx.unbounded_send(Message::RelaunchRequested);
164+
}
165+
}) {
166+
log::warn!("Failed to start relaunch listener: {err}");
167+
}
168+
169+
while let Some(message) = rx.next().await {
170+
let _ = output.send(message).await;
171+
}
172+
},
173+
)
174+
})
175+
}
176+
177+
#[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
178+
pub(crate) fn relaunch_subscription() -> Subscription<Message> {
179+
Subscription::none()
180+
}
181+
146182
pub(crate) fn file_hover_subscription() -> Subscription<Message> {
147183
let window_events = window::events().filter_map(|(_id, event)| match event {
148184
window::Event::FileHovered(_) => Some(Message::MainScreen(general::Message::FilesHovered)),

0 commit comments

Comments
 (0)