diff --git a/docs/backend/backend_python/openapi.json b/docs/backend/backend_python/openapi.json index 90f83983b..b69ee0a4d 100644 --- a/docs/backend/backend_python/openapi.json +++ b/docs/backend/backend_python/openapi.json @@ -1074,14 +1074,9 @@ "in": "query", "required": false, "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/InputType" - } - ], + "$ref": "#/components/schemas/InputType", "description": "Choose input type: 'path' or 'base64'", - "default": "path", - "title": "Input Type" + "default": "path" }, "description": "Choose input type: 'path' or 'base64'" } @@ -1341,7 +1336,7 @@ "required": false, "schema": { "type": "number", - "maximum": 100.0, + "maximum": 100, "minimum": 0.1, "description": "Location clustering radius in km", "default": 5.0, @@ -1427,7 +1422,7 @@ "required": false, "schema": { "type": "number", - "maximum": 100.0, + "maximum": 100, "minimum": 0.1, "description": "Location clustering radius in km", "default": 5.0, @@ -1503,7 +1498,7 @@ "required": false, "schema": { "type": "number", - "maximum": 100.0, + "maximum": 100, "minimum": 0.1, "description": "Location clustering radius in km", "default": 5.0, @@ -2614,6 +2609,7 @@ "metadata": { "anyOf": [ { + "additionalProperties": true, "type": "object" }, { diff --git a/frontend/jest.setup.ts b/frontend/jest.setup.ts index f76fb5b23..a63a9baf4 100644 --- a/frontend/jest.setup.ts +++ b/frontend/jest.setup.ts @@ -52,7 +52,17 @@ Object.defineProperty(window, 'matchMedia', { // Mock the module imports jest.mock('@tauri-apps/api/core', () => ({ - invoke: jest.fn().mockResolvedValue(null), + invoke: jest.fn().mockImplementation((cmd: string) => { + switch (cmd) { + case 'is_autostart_enabled': + return Promise.resolve(false); + case 'enable_autostart': + case 'disable_autostart': + return Promise.resolve(undefined); + default: + return Promise.resolve(null); + } + }), })); jest.mock('@tauri-apps/api/app', () => ({ diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock index 038f82916..2d6e74544 100644 --- a/frontend/src-tauri/Cargo.lock +++ b/frontend/src-tauri/Cargo.lock @@ -243,6 +243,17 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "auto-launch" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f012b8cc0c850f34117ec8252a44418f2e34a2cf501de89e29b241ae5f79471" +dependencies = [ + "dirs 4.0.0", + "thiserror 1.0.69", + "winreg 0.10.1", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -781,6 +792,15 @@ dependencies = [ "dirs-sys 0.3.7", ] +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys 0.3.7", +] + [[package]] name = "dirs" version = "6.0.0" @@ -933,7 +953,7 @@ dependencies = [ "rustc_version", "toml 0.9.10+spec-1.1.0", "vswhom", - "winreg", + "winreg 0.55.0", ] [[package]] @@ -2992,6 +3012,7 @@ dependencies = [ "sysinfo", "tauri", "tauri-build", + "tauri-plugin-autostart", "tauri-plugin-dialog", "tauri-plugin-fs", "tauri-plugin-opener", @@ -4344,7 +4365,7 @@ dependencies = [ "anyhow", "bytes", "cookie", - "dirs", + "dirs 6.0.0", "dunce", "embed_plist", "getrandom 0.3.4", @@ -4395,7 +4416,7 @@ checksum = "17fcb8819fd16463512a12f531d44826ce566f486d7ccd211c9c8cebdaec4e08" dependencies = [ "anyhow", "cargo_toml", - "dirs", + "dirs 6.0.0", "glob", "heck 0.5.0", "json-patch", @@ -4467,6 +4488,20 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-autostart" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "459383cebc193cdd03d1ba4acc40f2c408a7abce419d64bdcd2d745bc2886f70" +dependencies = [ + "auto-launch", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.17", +] + [[package]] name = "tauri-plugin-dialog" version = "2.4.2" @@ -4583,7 +4618,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27cbc31740f4d507712550694749572ec0e43bdd66992db7599b89fbfd6b167b" dependencies = [ "base64 0.22.1", - "dirs", + "dirs 6.0.0", "flate2", "futures-util", "http", @@ -5080,7 +5115,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" dependencies = [ "crossbeam-channel", - "dirs", + "dirs 6.0.0", "libappindicator", "muda", "objc2", @@ -6025,6 +6060,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "winreg" version = "0.55.0" @@ -6057,7 +6101,7 @@ dependencies = [ "block2", "cookie", "crossbeam-channel", - "dirs", + "dirs 6.0.0", "dpi", "dunce", "gdkx11", diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index c16753a36..9bc645443 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -11,7 +11,7 @@ edition = "2021" tauri-build = { version = "2.0.6", features = [] } [dependencies] -tauri = { version = "2.9.1", features = ["protocol-asset", "devtools"] } +tauri = { version = "2.9.1", features = ["protocol-asset", "devtools", "tray-icon"] } reqwest = { version = "0.12", features = ["blocking"] } walkdir = "2.3" sysinfo = "0.37.2" @@ -37,6 +37,7 @@ tauri-plugin-process = "2.3.1" tauri-plugin-store = "2.4.1" tauri-plugin-updater = "2.9.0" tauri-plugin-opener = "2.5.2" +tauri-plugin-autostart = "2" [features] # This feature is used for production builds or when a dev server is not specified, DO NOT REMOVE!! diff --git a/frontend/src-tauri/capabilities/migrated.json b/frontend/src-tauri/capabilities/migrated.json index 297b9f7be..b47fbeeb4 100644 --- a/frontend/src-tauri/capabilities/migrated.json +++ b/frontend/src-tauri/capabilities/migrated.json @@ -11,6 +11,7 @@ "core:resources:default", "core:menu:default", "core:tray:default", + "autostart:default", "shell:allow-open", "core:path:default", "core:event:default", @@ -19,7 +20,6 @@ "core:app:default", "core:resources:default", "core:menu:default", - "core:tray:default", "core:window:allow-set-title", "dialog:allow-open", "fs:default", diff --git a/frontend/src-tauri/src/main.rs b/frontend/src-tauri/src/main.rs index 5b7aa6acc..6948846a6 100644 --- a/frontend/src-tauri/src/main.rs +++ b/frontend/src-tauri/src/main.rs @@ -4,9 +4,15 @@ mod services; use sysinfo::System; +use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; use tauri::path::BaseDirectory; +use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; use tauri::{Manager, Window, WindowEvent}; -use tauri_plugin_shell::ShellExt; +use tauri_plugin_autostart::ManagerExt; +use tauri_plugin_store::StoreExt; + +const STORE_PATH: &str = "settings.json"; +const CLOSE_TO_TRAY_KEY: &str = "close_to_tray"; const ENDPOINTS: [(&str, &str, &str); 2] = [ ( @@ -44,12 +50,25 @@ fn is_process_alive() -> bool { } fn on_window_event(window: &Window, event: &WindowEvent) { - if !matches!(event, WindowEvent::CloseRequested { .. }) { - return; + if let WindowEvent::CloseRequested { api, .. } = event { + // Always take control of the close event. + api.prevent_close(); + + let app = window.app_handle().clone(); + let close_to_tray = app + .store(STORE_PATH) + .ok() + .and_then(|s| s.get(CLOSE_TO_TRAY_KEY)) + .and_then(|v| v.as_bool()) + .unwrap_or(true); // default: hide to tray + + if close_to_tray { + let _ = window.hide(); + } else { + let _ = kill_process_tree(); + app.exit(0); + } } - - let _ = kill_process_tree(); - window.app_handle().exit(0); } #[cfg(unix)] @@ -134,6 +153,7 @@ fn prod(app: &tauri::AppHandle, resource_path: &std::path::Path) -> Result<(), S println!("Sync spawned with PID {}", sync_child.pid()); use tauri_plugin_shell::process::CommandEvent; + use tauri_plugin_shell::ShellExt; tauri::async_runtime::spawn(async move { while let Some(event) = backend_rx.recv().await { match event { @@ -188,8 +208,44 @@ fn prod(_app: &tauri::AppHandle, _resource_path: &std::path::Path) -> Result<(), Ok(()) } +#[tauri::command] +fn enable_autostart(app: tauri::AppHandle) -> Result<(), String> { + app.autolaunch().enable().map_err(|e| e.to_string()) +} + +#[tauri::command] +fn disable_autostart(app: tauri::AppHandle) -> Result<(), String> { + app.autolaunch().disable().map_err(|e| e.to_string()) +} + +#[tauri::command] +fn is_autostart_enabled(app: tauri::AppHandle) -> Result { + app.autolaunch().is_enabled().map_err(|e| e.to_string()) +} + +#[tauri::command] +fn get_close_to_tray(app: tauri::AppHandle) -> Result { + let store = app.store(STORE_PATH).map_err(|e| e.to_string())?; + Ok(store + .get(CLOSE_TO_TRAY_KEY) + .and_then(|v| v.as_bool()) + .unwrap_or(true)) +} + +#[tauri::command] +fn set_close_to_tray(app: tauri::AppHandle, enabled: bool) -> Result<(), String> { + let store = app.store(STORE_PATH).map_err(|e| e.to_string())?; + store.set(CLOSE_TO_TRAY_KEY, enabled); + store.save().map_err(|e| e.to_string()) +} + fn main() { tauri::Builder::default() + // Auto-start: pass --minimized so the window starts hidden when launched at boot + .plugin(tauri_plugin_autostart::init( + tauri_plugin_autostart::MacosLauncher::LaunchAgent, + Some(vec!["--minimized"]), + )) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_store::Builder::new().build()) @@ -202,10 +258,68 @@ fn main() { println!("Resource path: {:?}", resource_path); prod(app.handle(), &resource_path)?; + + // When auto-started at boot (--minimized flag), keep the window hidden + if std::env::args().any(|a| a == "--minimized") { + if let Some(window) = app.get_webview_window("main") { + let _ = window.hide(); + } + } + + // System tray: context menu with Show / Quit + let show_item = MenuItem::with_id(app, "show", "Show", true, None::<&str>)?; + let separator = PredefinedMenuItem::separator(app)?; + let quit_item = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; + let menu = Menu::with_items(app, &[&show_item, &separator, &quit_item])?; + + TrayIconBuilder::new() + .icon( + app.default_window_icon() + .ok_or("no default window icon")? + .clone(), + ) + .tooltip("PictoPy") + .menu(&menu) + .show_menu_on_left_click(false) + .on_menu_event(|app, event| match event.id.as_ref() { + "show" => { + if let Some(w) = app.get_webview_window("main") { + let _ = w.show(); + let _ = w.set_focus(); + } + } + "quit" => { + let _ = kill_process_tree(); + app.exit(0); + } + _ => {} + }) + // Left-click on the tray icon also shows the window + .on_tray_icon_event(|tray, event| { + if let TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } = event + { + let app = tray.app_handle(); + if let Some(w) = app.get_webview_window("main") { + let _ = w.show(); + let _ = w.set_focus(); + } + } + }) + .build(app)?; + Ok(()) }) .invoke_handler(tauri::generate_handler![ services::get_resources_folder_path, + enable_autostart, + disable_autostart, + is_autostart_enabled, + get_close_to_tray, + set_close_to_tray, ]) .on_window_event(on_window_event) .run(tauri::generate_context!()) diff --git a/frontend/src/pages/SettingsPage/Settings.tsx b/frontend/src/pages/SettingsPage/Settings.tsx index 472f788fb..df5d4a0f1 100644 --- a/frontend/src/pages/SettingsPage/Settings.tsx +++ b/frontend/src/pages/SettingsPage/Settings.tsx @@ -6,6 +6,7 @@ import FolderManagementCard from './components/FolderManagementCard'; import UserPreferencesCard from './components/UserPreferencesCard'; import ApplicationControlsCard from './components/ApplicationControlsCard'; import AccountSettingsCard from './components/AccountSettingsCard'; +import SystemSettingsCard from './components/SystemSettingsCard'; /** * Settings page component @@ -70,6 +71,7 @@ const Settings: React.FC = () => { + )} diff --git a/frontend/src/pages/SettingsPage/components/SystemSettingsCard.tsx b/frontend/src/pages/SettingsPage/components/SystemSettingsCard.tsx new file mode 100644 index 000000000..4a05be707 --- /dev/null +++ b/frontend/src/pages/SettingsPage/components/SystemSettingsCard.tsx @@ -0,0 +1,148 @@ +import React, { useEffect, useState } from 'react'; +import { Monitor } from 'lucide-react'; +import { invoke } from '@tauri-apps/api/core'; +import SettingsCard from './SettingsCard'; + +const SystemSettingsCard: React.FC = () => { + // null = unknown / error reading state + const [autostart, setAutostart] = useState(null); + const [closeToTray, setCloseToTray] = useState(null); + const [loading, setLoading] = useState(true); + const [pending, setPending] = useState(false); + const [pendingCloseToTray, setPendingCloseToTray] = useState(false); + + useEffect(() => { + Promise.all([ + invoke('is_autostart_enabled') + .then(setAutostart) + .catch(() => setAutostart(null)), + invoke('get_close_to_tray') + .then(setCloseToTray) + .catch(() => setCloseToTray(null)), + ]).finally(() => setLoading(false)); + }, []); + + const handleToggle = async () => { + if (autostart === null) return; + const next = !autostart; + setPending(true); + try { + await invoke(next ? 'enable_autostart' : 'disable_autostart'); + setAutostart(next); + } catch (err) { + console.error('Failed to toggle autostart:', err); + } finally { + setPending(false); + } + }; + + const handleCloseToTrayToggle = async () => { + if (closeToTray === null) return; + const next = !closeToTray; + setPendingCloseToTray(true); + try { + await invoke('set_close_to_tray', { enabled: next }); + setCloseToTray(next); + } catch (err) { + console.error('Failed to toggle close-to-tray:', err); + } finally { + setPendingCloseToTray(false); + } + }; + + const isDisabled = loading || pending || autostart === null; + const isChecked = autostart === true; + + const closeToTrayDisabled = + loading || pendingCloseToTray || closeToTray === null; + const closeToTrayChecked = closeToTray === true; + + return ( + +
+
+
+ Launch at startup +
+
+ Automatically start PictoPy when you log in. The window starts + minimized to the system tray. +
+
+ + +
+ +
+
+
+ Close to tray +
+
+ When enabled, closing the window hides the app to the system tray + instead of exiting. +
+
+ + +
+
+ ); +}; + +export default SystemSettingsCard; diff --git a/frontend/src/pages/__tests__/PageSanity.test.tsx b/frontend/src/pages/__tests__/PageSanity.test.tsx index 9c68744df..65ddf51f8 100644 --- a/frontend/src/pages/__tests__/PageSanity.test.tsx +++ b/frontend/src/pages/__tests__/PageSanity.test.tsx @@ -20,6 +20,7 @@ describe('Page Sanity Tests', () => { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); + await screen.findByRole('switch', { name: /launch at startup/i }); expect(screen.getByText('Folder Management')).toBeInTheDocument(); expect(screen.getByText('User Preferences')).toBeInTheDocument(); diff --git a/frontend/src/pages/__tests__/SettingsPage.test.tsx b/frontend/src/pages/__tests__/SettingsPage.test.tsx index f173afd19..b162374ab 100644 --- a/frontend/src/pages/__tests__/SettingsPage.test.tsx +++ b/frontend/src/pages/__tests__/SettingsPage.test.tsx @@ -1,4 +1,5 @@ import { render, screen, act } from '@/test-utils'; +import { invoke } from '@tauri-apps/api/core'; import userEvent from '@testing-library/user-event'; import Settings from '../SettingsPage/Settings'; @@ -10,6 +11,7 @@ describe('Settings Page', () => { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); + await screen.findByRole('switch', { name: /launch at startup/i }); return { user }; }; @@ -33,13 +35,29 @@ describe('Settings Page', () => { test('GPU Acceleration toggle changes state on click', async () => { const { user } = await setupTest(); - const gpuSwitch = screen.getByRole('switch'); + const gpuSwitch = screen.getByRole('switch', { + name: /gpu acceleration/i, + }); expect(gpuSwitch).toHaveAttribute('aria-checked', 'false'); await user.click(gpuSwitch); expect(gpuSwitch).toHaveAttribute('aria-checked', 'true'); }); + + test('launch at startup switch enables autostart', async () => { + const { user } = await setupTest(); + const mockedInvoke = invoke as jest.MockedFunction; + mockedInvoke.mockClear(); + + const autostartSwitch = screen.getByRole('switch', { + name: /launch at startup/i, + }); + + await user.click(autostartSwitch); + + expect(mockedInvoke).toHaveBeenCalledWith('enable_autostart'); + }); }); describe('Action Buttons', () => { @@ -107,7 +125,9 @@ describe('Settings Page', () => { test('toggle cycles through ON/OFF states', async () => { const { user } = await setupTest(); - const gpuSwitch = screen.getByRole('switch'); + const gpuSwitch = screen.getByRole('switch', { + name: /gpu acceleration/i, + }); expect(gpuSwitch).toHaveAttribute('aria-checked', 'false'); await user.click(gpuSwitch);