mirror of
https://github.com/feschber/lan-mouse.git
synced 2026-06-25 09:44:49 +03:00
The cut-off toast UX ("Accessibility granted. Relaunch Lan Mouse so
capture and emulat…") was unreadable in a compact window and split
the "grant" and "relaunch" flows into two disconnected surfaces. Fold
everything into the existing warning row with state-dependent content:
- AX missing:
title = "input capture is disabled"
subtitle = "grant Accessibility permission to enable"
button = "Grant" → opens System Settings → Accessibility
- AX granted, daemon still bailed:
title = "relaunch required"
subtitle = "Accessibility granted — restart to activate capture
and emulation"
button = "Relaunch" → spawns a fresh bundle via `open` after
a 1s delay, then quits.
- Both active: row hidden.
The emulation_status_row is kept hidden on macOS because capture and
emulation share the same TCC gate — a single row is sufficient and
two identical-looking warnings were noisy. `handle_emulation` still
exists for the non-macOS platforms where the rows are distinct.
Side effects:
- `relaunch_bundle` moved from lib.rs to macos_privacy so imp.rs can
call it from the row button handler.
- AX watcher callback shrinks to `window.present()` +
`refresh_capture_emulation_status()`; the toast-based dialog is
gone along with its helper.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
210 lines
7.0 KiB
Rust
210 lines
7.0 KiB
Rust
use std::cell::{Cell, RefCell};
|
|
|
|
use adw::subclass::prelude::*;
|
|
use adw::{ActionRow, PreferencesGroup, ToastOverlay, prelude::*};
|
|
use glib::subclass::InitializingObject;
|
|
use gtk::glib::clone;
|
|
use gtk::{Button, CompositeTemplate, Entry, Image, Label, ListBox, gdk, gio, glib};
|
|
|
|
use lan_mouse_ipc::{DEFAULT_PORT, FrontendRequestWriter};
|
|
|
|
use crate::authorization_window::AuthorizationWindow;
|
|
|
|
#[derive(CompositeTemplate, Default)]
|
|
#[template(resource = "/de/feschber/LanMouse/window.ui")]
|
|
pub struct Window {
|
|
#[template_child]
|
|
pub authorized_placeholder: TemplateChild<ActionRow>,
|
|
#[template_child]
|
|
pub fingerprint_row: TemplateChild<ActionRow>,
|
|
#[template_child]
|
|
pub port_edit_apply: TemplateChild<Button>,
|
|
#[template_child]
|
|
pub port_edit_cancel: TemplateChild<Button>,
|
|
#[template_child]
|
|
pub client_list: TemplateChild<ListBox>,
|
|
#[template_child]
|
|
pub client_placeholder: TemplateChild<ActionRow>,
|
|
#[template_child]
|
|
pub port_entry: TemplateChild<Entry>,
|
|
#[template_child]
|
|
pub hostname_copy_icon: TemplateChild<Image>,
|
|
#[template_child]
|
|
pub hostname_label: TemplateChild<Label>,
|
|
#[template_child]
|
|
pub toast_overlay: TemplateChild<ToastOverlay>,
|
|
#[template_child]
|
|
pub capture_emulation_group: TemplateChild<PreferencesGroup>,
|
|
#[template_child]
|
|
pub capture_status_row: TemplateChild<ActionRow>,
|
|
#[template_child]
|
|
pub emulation_status_row: TemplateChild<ActionRow>,
|
|
#[template_child]
|
|
pub input_emulation_button: TemplateChild<Button>,
|
|
#[template_child]
|
|
pub input_capture_button: TemplateChild<Button>,
|
|
#[template_child]
|
|
pub authorized_list: TemplateChild<ListBox>,
|
|
pub clients: RefCell<Option<gio::ListStore>>,
|
|
pub authorized: RefCell<Option<gio::ListStore>>,
|
|
pub frontend_request_writer: RefCell<Option<FrontendRequestWriter>>,
|
|
pub port: Cell<u16>,
|
|
pub capture_active: Cell<bool>,
|
|
pub emulation_active: Cell<bool>,
|
|
pub authorization_window: RefCell<Option<AuthorizationWindow>>,
|
|
}
|
|
|
|
#[glib::object_subclass]
|
|
impl ObjectSubclass for Window {
|
|
// `NAME` needs to match `class` attribute of template
|
|
const NAME: &'static str = "LanMouseWindow";
|
|
const ABSTRACT: bool = false;
|
|
|
|
type Type = super::Window;
|
|
type ParentType = adw::ApplicationWindow;
|
|
|
|
fn class_init(klass: &mut Self::Class) {
|
|
klass.bind_template();
|
|
klass.bind_template_callbacks();
|
|
}
|
|
|
|
fn instance_init(obj: &InitializingObject<Self>) {
|
|
obj.init_template();
|
|
}
|
|
}
|
|
|
|
#[gtk::template_callbacks]
|
|
impl Window {
|
|
#[template_callback]
|
|
fn handle_add_client_pressed(&self, _button: &Button) {
|
|
self.obj().request_client_create();
|
|
}
|
|
|
|
#[template_callback]
|
|
fn handle_copy_hostname(&self, _: &Button) {
|
|
if let Ok(hostname) = hostname::get() {
|
|
let display = gdk::Display::default().unwrap();
|
|
let clipboard = display.clipboard();
|
|
clipboard.set_text(hostname.to_str().expect("hostname: invalid utf8"));
|
|
let icon = self.hostname_copy_icon.clone();
|
|
icon.set_icon_name(Some("emblem-ok-symbolic"));
|
|
icon.set_css_classes(&["success"]);
|
|
glib::spawn_future_local(clone!(
|
|
#[weak]
|
|
icon,
|
|
async move {
|
|
glib::timeout_future_seconds(1).await;
|
|
icon.set_icon_name(Some("edit-copy-symbolic"));
|
|
icon.set_css_classes(&[]);
|
|
}
|
|
));
|
|
}
|
|
}
|
|
|
|
#[template_callback]
|
|
fn handle_copy_fingerprint(&self, button: &Button) {
|
|
let fingerprint: String = self.fingerprint_row.property("subtitle");
|
|
let display = gdk::Display::default().unwrap();
|
|
let clipboard = display.clipboard();
|
|
clipboard.set_text(&fingerprint);
|
|
button.set_icon_name("emblem-ok-symbolic");
|
|
button.set_css_classes(&["success"]);
|
|
glib::spawn_future_local(clone!(
|
|
#[weak]
|
|
button,
|
|
async move {
|
|
glib::timeout_future_seconds(1).await;
|
|
button.set_icon_name("edit-copy-symbolic");
|
|
button.set_css_classes(&[]);
|
|
}
|
|
));
|
|
}
|
|
|
|
#[template_callback]
|
|
fn handle_port_changed(&self, _entry: &Entry) {
|
|
self.port_edit_apply.set_visible(true);
|
|
self.port_edit_cancel.set_visible(true);
|
|
}
|
|
|
|
#[template_callback]
|
|
fn handle_port_edit_apply(&self) {
|
|
self.obj().request_port_change();
|
|
}
|
|
|
|
#[template_callback]
|
|
fn handle_port_edit_cancel(&self) {
|
|
log::debug!("cancel port edit");
|
|
self.port_entry
|
|
.set_text(self.port.get().to_string().as_str());
|
|
self.port_edit_apply.set_visible(false);
|
|
self.port_edit_cancel.set_visible(false);
|
|
}
|
|
|
|
#[template_callback]
|
|
fn handle_emulation(&self) {
|
|
// On macOS the emulation_status_row is hidden — capture_status_row
|
|
// acts as the shared warning (see update_capture_emulation_status).
|
|
// This handler still fires for the non-macOS platforms where the
|
|
// emulation row is distinct.
|
|
self.obj().request_emulation();
|
|
}
|
|
|
|
#[template_callback]
|
|
fn handle_capture(&self) {
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
use crate::macos_privacy;
|
|
if macos_privacy::accessibility_granted() {
|
|
// AX granted but the row is still visible => the daemon
|
|
// subprocess bailed before AX was in place and needs a
|
|
// fresh process. Quit + relaunch via Launch Services.
|
|
log::info!("capture row clicked in relaunch-required state");
|
|
macos_privacy::relaunch_bundle();
|
|
if let Some(app) = self.obj().application() {
|
|
app.quit();
|
|
}
|
|
return;
|
|
}
|
|
log::info!("capture row clicked in AX-missing state, opening pane");
|
|
macos_privacy::open_accessibility_settings();
|
|
}
|
|
self.obj().request_capture();
|
|
}
|
|
|
|
#[template_callback]
|
|
fn handle_add_cert_fingerprint(&self, _button: &Button) {
|
|
self.obj().open_fingerprint_dialog(None);
|
|
}
|
|
|
|
pub fn set_port(&self, port: u16) {
|
|
self.port.set(port);
|
|
if port == DEFAULT_PORT {
|
|
self.port_entry.set_text("");
|
|
} else {
|
|
self.port_entry.set_text(format!("{port}").as_str());
|
|
}
|
|
self.port_edit_apply.set_visible(false);
|
|
self.port_edit_cancel.set_visible(false);
|
|
}
|
|
}
|
|
|
|
impl ObjectImpl for Window {
|
|
fn constructed(&self) {
|
|
if let Ok(hostname) = hostname::get() {
|
|
self.hostname_label
|
|
.set_text(hostname.to_str().expect("hostname: invalid utf8"));
|
|
}
|
|
self.parent_constructed();
|
|
self.set_port(DEFAULT_PORT);
|
|
let obj = self.obj();
|
|
obj.setup_icon();
|
|
obj.setup_clients();
|
|
obj.setup_authorized();
|
|
}
|
|
}
|
|
|
|
impl WidgetImpl for Window {}
|
|
impl WindowImpl for Window {}
|
|
impl ApplicationWindowImpl for Window {}
|
|
impl AdwApplicationWindowImpl for Window {}
|