Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 17 additions & 23 deletions frontend/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

mod services;

use services::settings::{CLOSE_TO_TRAY_KEY, START_MINIMIZED_KEY, STORE_PATH};
use sysinfo::System;
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
use tauri::path::BaseDirectory;
Expand All @@ -13,9 +14,6 @@ use tauri_plugin_autostart::ManagerExt;
use tauri_plugin_shell::ShellExt;
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] = [
(
"BACKEND",
Expand Down Expand Up @@ -258,22 +256,6 @@ fn is_autostart_enabled(app: tauri::AppHandle) -> Result<bool, String> {
app.autolaunch().is_enabled().map_err(|e| e.to_string())
}

#[tauri::command]
fn get_close_to_tray(app: tauri::AppHandle) -> Result<bool, String> {
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
Expand All @@ -296,9 +278,19 @@ fn main() {
prod(app.handle(), &resource_path)?;

// When auto-started at boot (--minimized flag), keep the window hidden
// unless the user has opted out via the "Start Minimized" preference.
if std::env::args().any(|a| a == "--minimized") {
if let Some(window) = app.get_webview_window("main") {
let _ = window.hide();
let start_minimized = app
.store(STORE_PATH)
.ok()
.and_then(|s| s.get(START_MINIMIZED_KEY))
.and_then(|v| v.as_bool())
.unwrap_or(true); // default: start minimized to tray

if start_minimized {
if let Some(window) = app.get_webview_window("main") {
let _ = window.hide();
}
}
}

Expand Down Expand Up @@ -359,8 +351,10 @@ fn main() {
enable_autostart,
disable_autostart,
is_autostart_enabled,
get_close_to_tray,
set_close_to_tray,
services::settings::get_close_to_tray,
services::settings::set_close_to_tray,
services::settings::get_start_minimized,
services::settings::set_start_minimized,
])
.on_window_event(on_window_event)
.build(tauri::generate_context!())
Expand Down
1 change: 1 addition & 0 deletions frontend/src-tauri/src/services/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod settings;
pub mod tunnel;

use tauri::path::BaseDirectory;
Expand Down
37 changes: 37 additions & 0 deletions frontend/src-tauri/src/services/settings.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
use tauri_plugin_store::StoreExt;

pub const STORE_PATH: &str = "settings.json";
pub const CLOSE_TO_TRAY_KEY: &str = "close_to_tray";
pub const START_MINIMIZED_KEY: &str = "start_minimized";

#[tauri::command]
pub fn get_close_to_tray(app: tauri::AppHandle) -> Result<bool, String> {
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]
pub 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())
}

#[tauri::command]
pub fn get_start_minimized(app: tauri::AppHandle) -> Result<bool, String> {
let store = app.store(STORE_PATH).map_err(|e| e.to_string())?;
Ok(store
.get(START_MINIMIZED_KEY)
.and_then(|v| v.as_bool())
.unwrap_or(true)) // default: start minimized to tray, matching prior behavior
}

#[tauri::command]
pub fn set_start_minimized(app: tauri::AppHandle, enabled: bool) -> Result<(), String> {
let store = app.store(STORE_PATH).map_err(|e| e.to_string())?;
store.set(START_MINIMIZED_KEY, enabled);
store.save().map_err(|e| e.to_string())
}
85 changes: 85 additions & 0 deletions frontend/src/pages/SettingsPage/components/SettingSwitchRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import React from 'react';

interface SettingSwitchRowProps {
/**
* Base id used to derive the label/description ids referenced by
* aria-labelledby / aria-describedby
*/
id: string;
/**
* Row title
*/
label: string;
/**
* Row description
*/
description: string;
/**
* Whether the switch is on
*/
checked: boolean;
/**
* Whether the switch is disabled (loading / pending / unknown state)
*/
disabled: boolean;
/**
* Called when the switch is toggled
*/
onToggle: () => void;
}

/**
* Reusable labeled toggle switch row used by the System settings card
*/
const SettingSwitchRow: React.FC<SettingSwitchRowProps> = ({
id,
label,
description,
checked,
disabled,
onToggle,
}) => {
const labelId = `${id}-label`;
const descId = `${id}-desc`;

return (
<div className="flex items-center justify-between py-1">
<div>
<div id={labelId} className="font-medium">
{label}
</div>
<div id={descId} className="text-muted-foreground text-sm">
{description}
</div>
</div>

<button
role="switch"
aria-checked={checked}
aria-labelledby={labelId}
aria-describedby={descId}
disabled={disabled}
onClick={onToggle}
className={[
'relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full',
'transition-colors duration-200 ease-in-out',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50',
checked
? 'bg-primary focus-visible:ring-primary'
: 'bg-gray-200 focus-visible:ring-gray-500 dark:bg-gray-700',
].join(' ')}
>
<span
className={[
'inline-block h-4 w-4 rounded-full bg-white shadow-md',
'transition-transform duration-200 ease-in-out',
checked ? 'translate-x-6' : 'translate-x-1',
].join(' ')}
/>
</button>
</div>
);
};

export default SettingSwitchRow;
131 changes: 55 additions & 76 deletions frontend/src/pages/SettingsPage/components/SystemSettingsCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@ import React, { useEffect, useState } from 'react';
import { Monitor } from 'lucide-react';
import { invoke } from '@tauri-apps/api/core';
import SettingsCard from './SettingsCard';
import SettingSwitchRow from './SettingSwitchRow';

const SystemSettingsCard: React.FC = () => {
// null = unknown / error reading state
const [autostart, setAutostart] = useState<boolean | null>(null);
const [closeToTray, setCloseToTray] = useState<boolean | null>(null);
const [startMinimized, setStartMinimized] = useState<boolean | null>(null);
const [loading, setLoading] = useState(true);
const [pending, setPending] = useState(false);
const [pendingCloseToTray, setPendingCloseToTray] = useState(false);
const [pendingStartMinimized, setPendingStartMinimized] = useState(false);

useEffect(() => {
Promise.all([
Expand All @@ -19,6 +22,9 @@ const SystemSettingsCard: React.FC = () => {
invoke<boolean>('get_close_to_tray')
.then(setCloseToTray)
.catch(() => setCloseToTray(null)),
invoke<boolean>('get_start_minimized')
.then(setStartMinimized)
.catch(() => setStartMinimized(null)),
]).finally(() => setLoading(false));
}, []);

Expand Down Expand Up @@ -50,97 +56,70 @@ const SystemSettingsCard: React.FC = () => {
}
};

const handleStartMinimizedToggle = async () => {
if (startMinimized === null) return;
const next = !startMinimized;
setPendingStartMinimized(true);
try {
await invoke('set_start_minimized', { enabled: next });
setStartMinimized(next);
} catch (err) {
console.error('Failed to toggle start minimized:', err);
} finally {
setPendingStartMinimized(false);
}
};

const isDisabled = loading || pending || autostart === null;
const isChecked = autostart === true;

const closeToTrayDisabled =
loading || pendingCloseToTray || closeToTray === null;
const closeToTrayChecked = closeToTray === true;

const startMinimizedDisabled =
loading || pendingStartMinimized || startMinimized === null;
const startMinimizedChecked = startMinimized === true;

const autostartDescription =
startMinimized === false
? 'Automatically start PictoPy when you log in. The main window opens directly on boot.'
: 'Automatically start PictoPy when you log in. The window starts minimized to the system tray.';

return (
<SettingsCard
icon={Monitor}
title="System"
description="System integration and startup behavior"
>
<div className="flex items-center justify-between py-1">
<div>
<div id="autostart-label" className="font-medium">
Launch at startup
</div>
<div id="autostart-desc" className="text-muted-foreground text-sm">
Automatically start PictoPy when you log in. The window starts
minimized to the system tray.
</div>
</div>

<button
role="switch"
aria-checked={isChecked}
aria-labelledby="autostart-label"
aria-describedby="autostart-desc"
disabled={isDisabled}
onClick={handleToggle}
className={[
'relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full',
'transition-colors duration-200 ease-in-out',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50',
isChecked
? 'bg-primary focus-visible:ring-primary'
: 'bg-gray-200 focus-visible:ring-gray-500 dark:bg-gray-700',
].join(' ')}
>
<span
className={[
'inline-block h-4 w-4 rounded-full bg-white shadow-md',
'transition-transform duration-200 ease-in-out',
isChecked ? 'translate-x-6' : 'translate-x-1',
].join(' ')}
/>
</button>
</div>
<SettingSwitchRow
id="autostart"
label="Launch at startup"
description={autostartDescription}
checked={isChecked}
disabled={isDisabled}
onToggle={handleToggle}
/>

<div className="flex items-center justify-between py-1">
<div>
<div id="close-to-tray-label" className="font-medium">
Close to tray
</div>
<div
id="close-to-tray-desc"
className="text-muted-foreground text-sm"
>
When enabled, closing the window hides the app to the system tray
instead of exiting.
</div>
</div>
{isChecked && (
<SettingSwitchRow
id="start-minimized"
label="Start minimized"
description="When enabled, PictoPy starts silently in the system tray on boot. When disabled, the main window opens on boot instead."
checked={startMinimizedChecked}
disabled={startMinimizedDisabled}
onToggle={handleStartMinimizedToggle}
/>
)}

<button
role="switch"
aria-checked={closeToTrayChecked}
aria-labelledby="close-to-tray-label"
aria-describedby="close-to-tray-desc"
disabled={closeToTrayDisabled}
onClick={handleCloseToTrayToggle}
className={[
'relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full',
'transition-colors duration-200 ease-in-out',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50',
closeToTrayChecked
? 'bg-primary focus-visible:ring-primary'
: 'bg-gray-200 focus-visible:ring-gray-500 dark:bg-gray-700',
].join(' ')}
>
<span
className={[
'inline-block h-4 w-4 rounded-full bg-white shadow-md',
'transition-transform duration-200 ease-in-out',
closeToTrayChecked ? 'translate-x-6' : 'translate-x-1',
].join(' ')}
/>
</button>
</div>
<SettingSwitchRow
id="close-to-tray"
label="Close to tray"
description="When enabled, closing the window hides the app to the system tray instead of exiting."
checked={closeToTrayChecked}
disabled={closeToTrayDisabled}
onToggle={handleCloseToTrayToggle}
/>
</SettingsCard>
);
};
Expand Down
Loading