BlueSky & more on desktop
lazurite.stormlightlabs.org/
tauri
rust
typescript
bluesky
appview
atproto
solid
1use crate::error::{AppError, Result as AppResult};
2use crate::settings;
3use crate::state::AppState;
4use std::sync::Mutex;
5use tauri::{
6 image::Image,
7 menu::{Menu, MenuItem},
8 tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
9 AppHandle, Manager, WebviewUrl, WebviewWindow, WebviewWindowBuilder,
10};
11use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
12use tauri_plugin_log::log;
13
14const COMPOSER_WINDOW_LABEL: &str = "composer";
15const APP_INDEX_PATH: &str = "index.html";
16const COMPOSER_HASH_ROUTE: &str = "#/composer";
17const COMPOSER_INIT_SCRIPT: &str = r#"
18if (window.location.hash !== '#/composer') {
19 window.location.replace(`${window.location.pathname}${window.location.search}#/composer`);
20}
21"#;
22const MAIN_WINDOW_LABEL: &str = "main";
23const MENU_NEW_POST: &str = "new_post";
24const MENU_TOGGLE_WINDOW: &str = "toggle_window";
25const MENU_QUIT: &str = "quit";
26const DEFAULT_GLOBAL_SHORTCUT: &str = "Ctrl+Shift+N";
27
28#[derive(Default)]
29struct ComposerShortcutState {
30 current_shortcut: Mutex<Option<String>>,
31}
32
33pub fn setup_tray(app: &AppHandle) -> std::result::Result<(), Box<dyn std::error::Error>> {
34 let new_post_i = MenuItem::with_id(app, MENU_NEW_POST, "New Post…", true, None::<&str>)?;
35 let toggle_window_i = MenuItem::with_id(app, MENU_TOGGLE_WINDOW, "Show / Hide", true, None::<&str>)?;
36 let quit_i = MenuItem::with_id(app, MENU_QUIT, "Quit", true, None::<&str>)?;
37
38 let menu = Menu::with_items(app, &[&new_post_i, &toggle_window_i, &quit_i])?;
39 let tray_icon = Image::from_bytes(include_bytes!("../../public/tray-icon.png"))?;
40
41 let tray = TrayIconBuilder::new()
42 .icon(tray_icon)
43 .menu(&menu)
44 .show_menu_on_left_click(false)
45 .on_menu_event(|app, event| match event.id().as_ref() {
46 MENU_NEW_POST => {
47 let _ = open_composer_window(app);
48 }
49 MENU_TOGGLE_WINDOW => toggle_window_visibility(app),
50 MENU_QUIT => app.exit(0),
51 _ => {}
52 })
53 .on_tray_icon_event(|tray, event| {
54 if let TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, .. } = event {
55 let _ = open_composer_window(tray.app_handle());
56 }
57 })
58 .build(app)?;
59
60 app.manage(tray);
61
62 Ok(())
63}
64
65pub fn setup_global_shortcut(app: &AppHandle) -> std::result::Result<(), Box<dyn std::error::Error>> {
66 app.manage(ComposerShortcutState::default());
67 sync_global_shortcut(app)?;
68 Ok(())
69}
70
71pub fn sync_global_shortcut(app: &AppHandle) -> AppResult<()> {
72 let configured_shortcut = app
73 .try_state::<AppState>()
74 .and_then(|state| {
75 settings::get_settings(&state)
76 .ok()
77 .map(|settings| settings.global_shortcut)
78 })
79 .unwrap_or_else(|| DEFAULT_GLOBAL_SHORTCUT.to_string());
80
81 update_global_shortcut(app, &configured_shortcut)
82}
83
84pub fn update_global_shortcut(app: &AppHandle, shortcut: &str) -> AppResult<()> {
85 let shortcut = shortcut.trim();
86 if shortcut.is_empty() {
87 return Err(AppError::validation("global shortcut must not be empty"));
88 }
89
90 let parsed_shortcut: Shortcut = shortcut
91 .parse()
92 .map_err(|error| AppError::validation(format!("invalid global shortcut '{shortcut}': {error}")))?;
93
94 let shortcut_state = app.state::<ComposerShortcutState>();
95 let mut current_shortcut = shortcut_state
96 .current_shortcut
97 .lock()
98 .map_err(|_| AppError::StatePoisoned("composer_shortcut"))?;
99
100 if current_shortcut.as_deref() == Some(shortcut) {
101 return Ok(());
102 }
103
104 if let Some(existing_shortcut) = current_shortcut.as_ref() {
105 let existing_shortcut = existing_shortcut
106 .parse::<Shortcut>()
107 .map_err(|error| AppError::validation(format!("invalid registered global shortcut: {error}")))?;
108 app.global_shortcut().unregister(existing_shortcut).map_err(|error| {
109 AppError::validation(format!(
110 "failed to unregister existing global shortcut '{}': {error}",
111 current_shortcut.as_deref().unwrap_or_default()
112 ))
113 })?;
114 }
115
116 app.global_shortcut()
117 .on_shortcut(parsed_shortcut, |app, _, event| {
118 if event.state == ShortcutState::Pressed {
119 let _ = open_composer_window(app);
120 }
121 })
122 .map_err(|error| AppError::validation(format!("failed to register global shortcut '{shortcut}': {error}")))?;
123
124 log::info!("registered global composer shortcut: {shortcut}");
125 *current_shortcut = Some(shortcut.to_string());
126 Ok(())
127}
128
129fn toggle_window_visibility(app: &AppHandle) {
130 if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
131 if is_window_visible(&window) {
132 let _ = window.hide();
133 } else {
134 show_window(&window);
135 }
136 }
137}
138
139fn is_window_visible(window: &WebviewWindow) -> bool {
140 window.is_visible().unwrap_or(false) && !window.is_minimized().unwrap_or(false)
141}
142
143fn open_composer_window(app: &AppHandle) -> std::result::Result<(), Box<dyn std::error::Error>> {
144 if let Some(window) = app.get_webview_window(COMPOSER_WINDOW_LABEL) {
145 route_window_to_composer(&window);
146 show_window(&window);
147 return Ok(());
148 }
149
150 let window = WebviewWindowBuilder::new(app, COMPOSER_WINDOW_LABEL, WebviewUrl::App(APP_INDEX_PATH.into()))
151 .initialization_script(COMPOSER_INIT_SCRIPT)
152 .title("New Post")
153 .inner_size(720.0, 640.0)
154 .min_inner_size(560.0, 420.0)
155 .resizable(true)
156 .center()
157 .build()?;
158
159 show_window(&window);
160
161 Ok(())
162}
163
164fn route_window_to_composer(window: &WebviewWindow) {
165 let _ = window.eval(format!(
166 "if (window.location.hash !== '{COMPOSER_HASH_ROUTE}') {{ window.location.hash = '/composer'; }}"
167 ));
168}
169
170fn show_window(window: &WebviewWindow) {
171 let _ = window.unminimize();
172 let _ = window.show();
173 let _ = window.set_focus();
174}