Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
58 changes: 58 additions & 0 deletions scripts/ui/close-confirm.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import assert from 'node:assert/strict';
import path from 'node:path';
import { readFile } from 'node:fs/promises';
import { test } from 'node:test';
import { fileURLToPath } from 'node:url';

const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(scriptDir, '..', '..');
const htmlPath = path.join(projectRoot, 'ui', 'close-confirm.html');

const readHtml = async () => readFile(htmlPath, 'utf8');

test('close confirm dialog uses locale copy as the single source of truth for button labels', async () => {
const html = await readHtml();

assert.match(html, /trayButton\.textContent = copy\.tray;/);
assert.match(html, /exitButton\.textContent = copy\.exit;/);
});

test('close confirm dialog avoids exposing raw invoke errors to users', async () => {
const html = await readHtml();

assert.doesNotMatch(html, /invokeError\.message/);
assert.match(html, /error\.textContent = copy\.submitError;/);
});

test('close confirm dialog routes Tauri command calls through a local invoke wrapper', async () => {
const html = await readHtml();

assert.match(html, /const invokeTauri =/);
assert.doesNotMatch(html, /window\.__TAURI_INTERNALS__\?\.invoke/);
assert.match(html, /await invokeTauri\(/);
});

test('close confirm dialog reads close action values from query params instead of hard-coded literals', async () => {
const html = await readHtml();

assert.match(html, /const trayAction = params\.get\("trayAction"\);/);
assert.match(html, /const exitAction = params\.get\("exitAction"\);/);
assert.doesNotMatch(html, /submit\("tray"\)/);
assert.doesNotMatch(html, /submit\("exit"\)/);
});

test('close confirm dialog only schedules frontend close fallback for tray actions', async () => {
const html = await readHtml();

assert.match(html, /if \(action === trayAction\) \{/);
assert.match(html, /recoveryTimer = window\.setTimeout\(/);
assert.match(html, /window\.close\(\);/);
});

test('close confirm dialog suppresses invoke teardown errors for exit actions', async () => {
const html = await readHtml();

assert.match(html, /catch \(_invokeError\) \{/);
assert.match(html, /if \(action === exitAction\) \{/);
assert.match(html, /return;/);
});
2 changes: 1 addition & 1 deletion src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "main-capability",
"description": "Default IPC capability for the main window and loopback dashboard origin.",
"windows": ["main"],
"windows": ["main", "close-confirm"],
"local": true,
"remote": {
"urls": ["http://127.0.0.1:*", "http://localhost:*"]
Expand Down
53 changes: 44 additions & 9 deletions src-tauri/src/app_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ use tauri::{
};

use crate::{
app_runtime_events, append_desktop_log, append_startup_log, bridge, lifecycle, startup_task,
tray, window, BackendState, DEFAULT_SHELL_LOCALE, DESKTOP_LOG_FILE, STARTUP_MODE_ENV,
app_runtime_events, append_desktop_log, append_startup_log, bridge, close_behavior, lifecycle,
startup_task, tray, window, BackendState, DEFAULT_SHELL_LOCALE, DESKTOP_LOG_FILE,
STARTUP_MODE_ENV,
};

fn configure_plugins(builder: Builder<tauri::Wry>) -> Builder<tauri::Wry> {
Expand All @@ -22,24 +23,51 @@ fn configure_window_events(builder: Builder<tauri::Wry>) -> Builder<tauri::Wry>
builder.on_window_event(|window, event| {
let is_quitting = window.app_handle().state::<BackendState>().is_quitting();
let action = match &event {
WindowEvent::CloseRequested { .. } => app_runtime_events::main_window_action(
window.label(),
is_quitting,
false,
true,
false,
),
WindowEvent::CloseRequested { .. } => {
let packaged_root_dir = crate::runtime_paths::default_packaged_root_dir();
let saved_close_action = close_behavior::read_cached_close_action(
packaged_root_dir.as_deref(),
append_desktop_log,
);

app_runtime_events::main_window_action(
window.label(),
is_quitting,
false,
true,
false,
saved_close_action,
)
}
WindowEvent::Focused(false) => app_runtime_events::main_window_action(
window.label(),
is_quitting,
matches!(window.is_minimized(), Ok(true)),
false,
true,
None,
),
_ => app_runtime_events::MainWindowAction::None,
};

match action {
app_runtime_events::MainWindowAction::ShowClosePrompt => {
if let WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
}
append_desktop_log(
"main window close requested without saved preference; close prompt pending",
);
if let Err(error) = window::close_confirm::show_close_confirm_window(
window.app_handle(),
DEFAULT_SHELL_LOCALE,
append_desktop_log,
) {
append_desktop_log(&format!(
"failed to open close confirm prompt window: {error}"
));
}
}
app_runtime_events::MainWindowAction::PreventCloseAndHide => {
if let WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
Expand All @@ -50,6 +78,12 @@ fn configure_window_events(builder: Builder<tauri::Wry>) -> Builder<tauri::Wry>
append_desktop_log,
);
}
app_runtime_events::MainWindowAction::ExitApplication => {
lifecycle::events::request_immediate_exit(
window.app_handle(),
lifecycle::events::ImmediateExitTrigger::SavedExitPreference,
);
}
app_runtime_events::MainWindowAction::HideIfMinimized => {
window::actions::hide_main_window(
window.app_handle(),
Expand Down Expand Up @@ -170,6 +204,7 @@ pub(crate) fn run() {
crate::bridge::commands::desktop_bridge_restart_backend,
crate::bridge::commands::desktop_bridge_stop_backend,
crate::bridge::commands::desktop_bridge_open_external_url,
crate::bridge::commands::desktop_bridge_submit_close_prompt,
crate::bridge::commands::desktop_bridge_check_app_update,
crate::bridge::commands::desktop_bridge_install_app_update
])
Expand Down
36 changes: 31 additions & 5 deletions src-tauri/src/app_runtime_events.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
use tauri::{webview::PageLoadEvent, RunEvent};

use crate::close_behavior::CloseAction;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum MainWindowAction {
None,
ShowClosePrompt,
PreventCloseAndHide,
ExitApplication,
HideIfMinimized,
}

Expand All @@ -29,6 +33,7 @@ pub(crate) fn main_window_action(
minimized_on_focus_lost: bool,
is_close_requested: bool,
is_focus_lost: bool,
saved_close_action: Option<CloseAction>,
) -> MainWindowAction {
if window_label != "main" {
return MainWindowAction::None;
Expand All @@ -38,7 +43,11 @@ pub(crate) fn main_window_action(
return if is_quitting {
MainWindowAction::None
} else {
MainWindowAction::PreventCloseAndHide
match saved_close_action {
Some(CloseAction::Tray) => MainWindowAction::PreventCloseAndHide,
Some(CloseAction::Exit) => MainWindowAction::ExitApplication,
None => MainWindowAction::ShowClosePrompt,
}
};
}

Expand Down Expand Up @@ -93,6 +102,7 @@ mod tests {
main_window_action, page_load_action, run_event_action, MainWindowAction, PageLoadAction,
RunEventAction,
};
use crate::close_behavior::CloseAction;
use tauri::{webview::PageLoadEvent, RunEvent};

#[cfg(target_os = "macos")]
Expand All @@ -101,23 +111,39 @@ mod tests {
#[test]
fn main_window_action_ignores_non_main_windows() {
assert_eq!(
main_window_action("settings", false, false, true, false),
main_window_action("settings", false, false, true, false, None),
MainWindowAction::None
);
}

#[test]
fn main_window_action_hides_on_close_when_not_quitting() {
fn main_window_action_prompts_when_no_saved_close_preference_exists() {
assert_eq!(
main_window_action("main", false, false, true, false, None),
MainWindowAction::ShowClosePrompt
);
}

#[test]
fn main_window_action_hides_on_close_when_saved_preference_is_tray() {
assert_eq!(
main_window_action("main", false, false, true, false),
main_window_action("main", false, false, true, false, Some(CloseAction::Tray)),
MainWindowAction::PreventCloseAndHide
);
}

#[test]
fn main_window_action_exits_on_close_when_saved_preference_is_exit() {
assert_eq!(
main_window_action("main", false, false, true, false, Some(CloseAction::Exit)),
MainWindowAction::ExitApplication
);
}

#[test]
fn main_window_action_hides_on_minimized_focus_loss() {
assert_eq!(
main_window_action("main", false, true, false, true),
main_window_action("main", false, true, false, true, None),
MainWindowAction::HideIfMinimized
);
}
Expand Down
102 changes: 101 additions & 1 deletion src-tauri/src/bridge/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@ use crate::bridge::updater_types::{
map_update_channel_ok, map_update_check_error, map_update_install_error, map_update_install_ok,
DesktopAppUpdateChannelResult, DesktopAppUpdateCheckResult, DesktopAppUpdateResult,
};
use crate::close_behavior::{self, CloseAction};
use crate::{
append_desktop_log, restart_backend_flow, runtime_paths, shell_locale, tray, update_channel,
BackendBridgeResult, BackendBridgeState, BackendState, DEFAULT_SHELL_LOCALE,
window, BackendBridgeResult, BackendBridgeState, BackendState, DEFAULT_SHELL_LOCALE,
};

fn resolve_update_channel(app_handle: &AppHandle) -> update_channel::UpdateChannel {
Expand Down Expand Up @@ -160,6 +161,33 @@ fn parse_openable_url(raw_url: &str) -> Result<Url, String> {
}
}

fn parse_close_prompt_action(raw_action: &str) -> Result<CloseAction, String> {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider inlining the close-prompt cleanup and parsing logic into the command to avoid extra helper layers that add indirection without clear benefit.

You can reduce indirection here without losing any functionality.

1. Remove finish_tray_close_prompt_cleanup indirection

The generic helper is only used once and always with append_desktop_log. You can inline the logic and drop the generic function and its tests:

match action {
    CloseAction::Tray => {
        window::actions::hide_main_window(
            &app_handle,
            DEFAULT_SHELL_LOCALE,
            append_desktop_log,
        );

        if let Some(prompt_window) = app_handle.get_webview_window("close-confirm") {
            if let Err(error) = prompt_window.close() {
                append_desktop_log(&format!(
                    "Failed to close close confirm prompt window: {error}"
                ));
            }
        }

        BackendBridgeResult {
            ok: true,
            reason: None,
        }
    }
    CloseAction::Exit => {
        let state = app_handle.state::<BackendState>();
        state.mark_quitting();
        app_handle.exit(0);
        BackendBridgeResult {
            ok: true,
            reason: None,
        }
    }
}

Then you can delete finish_tray_close_prompt_cleanup and its tests.

2. Avoid wrapping parse_close_action with another parser

Instead of parse_close_prompt_action, you can map the Option from close_behavior::parse_close_action directly at the call site, keeping only one parsing API:

#[tauri::command]
pub(crate) fn desktop_bridge_submit_close_prompt(
    app_handle: AppHandle,
    action: String,
    remember: bool,
) -> BackendBridgeResult {
    let action = match close_behavior::parse_close_action(&action) {
        Some(action) => action,
        None => {
            return BackendBridgeResult {
                ok: false,
                reason: Some("Invalid close action. Expected 'tray' or 'exit'.".to_string()),
            };
        }
    };

    // ... rest unchanged ...
}

If you still want a testable parsing function, you can move an ergonomic Result-returning parser into close_behavior itself:

// in close_behavior
pub fn parse_close_action_result(raw: &str) -> Result<CloseAction, String> {
    parse_close_action(raw)
        .ok_or_else(|| "Invalid close action. Expected 'tray' or 'exit'.".to_string())
}

And in the command:

let action = match close_behavior::parse_close_action_result(&action) {
    Ok(action) => action,
    Err(error) => {
        return BackendBridgeResult {
            ok: false,
            reason: Some(error),
        };
    }
};

This removes the extra abstraction layer in the bridge module and keeps the flow in desktop_bridge_submit_close_prompt more direct.

close_behavior::parse_close_action(raw_action).ok_or_else(|| {
format!(
"Invalid close action. Expected '{}' or '{}'.",
close_behavior::CLOSE_ACTION_TRAY,
close_behavior::CLOSE_ACTION_EXIT,
)
})
}
Comment thread
sourcery-ai[bot] marked this conversation as resolved.

fn finish_tray_close_prompt_cleanup<Log>(
cleanup_result: Result<(), String>,
log: Log,
) -> BackendBridgeResult
where
Log: Fn(&str),
{
if let Err(error) = cleanup_result {
log(&format!("Failed to close confirm prompt window: {error}"));
}

BackendBridgeResult {
ok: true,
reason: None,
}
}

#[cfg(target_os = "macos")]
fn open_url_with_system_browser(url: &str) -> Result<(), String> {
Command::new("open")
Expand Down Expand Up @@ -287,6 +315,78 @@ pub(crate) fn desktop_bridge_open_external_url(url: String) -> BackendBridgeResu
}
}

#[tauri::command]
pub(crate) fn desktop_bridge_submit_close_prompt(
app_handle: AppHandle,
action: String,
remember: bool,
) -> BackendBridgeResult {
let action = match parse_close_prompt_action(&action) {
Ok(action) => action,
Err(error) => {
return BackendBridgeResult {
ok: false,
reason: Some(error),
};
}
};

if remember {
let packaged_root_dir = runtime_paths::default_packaged_root_dir();
if let Err(error) = close_behavior::write_cached_close_action(
Some(action),
packaged_root_dir.as_deref(),
append_desktop_log,
) {
append_desktop_log(&format!(
"failed to persist remembered close action; aborting selected action: {error}"
));
return BackendBridgeResult {
ok: false,
reason: Some(error),
};
}
}

match action {
CloseAction::Tray => {
window::actions::hide_main_window(
&app_handle,
DEFAULT_SHELL_LOCALE,
append_desktop_log,
);
let cleanup_result = if let Some(prompt_window) =
app_handle.get_webview_window(window::close_confirm::CLOSE_CONFIRM_WINDOW_LABEL)
{
prompt_window.close().map_err(|error| error.to_string())
} else {
Ok(())
};

finish_tray_close_prompt_cleanup(cleanup_result, append_desktop_log)
}
CloseAction::Exit => {
if let Some(prompt_window) =
app_handle.get_webview_window(window::close_confirm::CLOSE_CONFIRM_WINDOW_LABEL)
{
if let Err(error) = prompt_window.close() {
append_desktop_log(&format!(
"Failed to close confirm prompt window before exit: {error}"
));
}
}
crate::lifecycle::events::request_immediate_exit(
&app_handle,
crate::lifecycle::events::ImmediateExitTrigger::ClosePromptExitAction,
);
BackendBridgeResult {
ok: true,
reason: None,
}
}
}
}

#[tauri::command]
pub(crate) fn desktop_bridge_set_shell_locale(
app_handle: AppHandle,
Expand Down
Loading