Skip to content

fix: confirm desktop close behavior before hiding to tray#118

Open
zouyonghe wants to merge 18 commits intoAstrBotDevs:mainfrom
zouyonghe:codex/close-to-tray-confirmation
Open

fix: confirm desktop close behavior before hiding to tray#118
zouyonghe wants to merge 18 commits intoAstrBotDevs:mainfrom
zouyonghe:codex/close-to-tray-confirmation

Conversation

@zouyonghe
Copy link
Copy Markdown
Member

@zouyonghe zouyonghe commented Apr 14, 2026

Summary

  • show a dedicated close confirmation dialog before silently hiding the desktop app to the tray
  • let users remember whether closing should minimize to tray or exit directly, and persist that preference in desktop state
  • wire the prompt window into Tauri capabilities and bridge commands so the tray/exit flow works from the shell layer

Test Plan

  • cargo test

Summary by Sourcery

Add a dedicated close confirmation flow to the desktop app that lets users choose and persist their preferred close behavior (minimize to tray or exit) and wire this flow through Tauri windows, bridge commands, and lifecycle handling.

New Features:

  • Introduce a close confirmation window that appears when the main desktop window is closed without a saved preference, offering tray or exit options.
  • Allow users to remember and persist their preferred close behavior (minimize to tray or exit) in the desktop state file.

Enhancements:

  • Unify immediate exit handling behind a shared lifecycle helper with explicit exit triggers and structured logging.
  • Improve robustness of desktop state handling around close behavior, including atomic writes and graceful recovery from malformed state files.
  • Add localized copy and a small UI for the close confirmation dialog with safety checks and fallback behavior if Tauri invocations fail or the window does not close automatically.
  • Extend main window event handling to respect saved close behavior preferences and drive either hiding to tray or exiting the app accordingly.
  • Update tray quit handling to reuse the new immediate exit helper for consistent shutdown behavior.

Tests:

  • Add Rust unit tests for close behavior parsing, state persistence, and confirm window helpers.
  • Add browser-side tests for the close confirmation HTML dialog to validate behavior and integration with the Tauri invoke API.

github-actions bot and others added 5 commits April 11, 2026 14:44
* fix: add backend startup heartbeat liveness probe

* fix: tighten startup heartbeat validation

* refactor: centralize startup heartbeat metadata

* fix: surface heartbeat invalidation sooner

* fix: harden startup heartbeat parsing

* fix: warn on stop-time heartbeat failures

* refactor: simplify startup heartbeat control flow

* refactor: flatten readiness heartbeat helpers

* refactor: clarify heartbeat helper responsibilities

* docs: clarify startup heartbeat path coupling

* fix: harden startup heartbeat coordination

* fix: make startup heartbeat checks monotonic

* fix: clean up heartbeat test and exit handling
Copy link
Copy Markdown

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 3 issues, and left some high level feedback:

  • In desktop_bridge_submit_close_prompt, consider treating failures to close the close-confirm window as non-fatal (log and still return ok: true) since the primary action (tray/exit) has already been executed and surfacing this as an error unnecessarily complicates the frontend flow.
  • The CloseAction string mapping is duplicated via the serde annotations, CloseAction::parse, parse_close_action, and as_state_value; you could simplify by relying on serde for serialization/deserialization and removing the manual string conversions to reduce drift when new actions are added.
  • The localization logic in ui/close-confirm.html overrides trayButton.textContent and exitButton.textContent conditionally even though messages already holds localized strings; simplifying this to always use copy.tray/copy.exit (and optionally avoiding leaking raw invokeError messages to users) would make the UI code more consistent and robust.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `desktop_bridge_submit_close_prompt`, consider treating failures to close the `close-confirm` window as non-fatal (log and still return `ok: true`) since the primary action (tray/exit) has already been executed and surfacing this as an error unnecessarily complicates the frontend flow.
- The `CloseAction` string mapping is duplicated via the `serde` annotations, `CloseAction::parse`, `parse_close_action`, and `as_state_value`; you could simplify by relying on `serde` for serialization/deserialization and removing the manual string conversions to reduce drift when new actions are added.
- The localization logic in `ui/close-confirm.html` overrides `trayButton.textContent` and `exitButton.textContent` conditionally even though `messages` already holds localized strings; simplifying this to always use `copy.tray`/`copy.exit` (and optionally avoiding leaking raw `invokeError` messages to users) would make the UI code more consistent and robust.

## Individual Comments

### Comment 1
<location path="src-tauri/src/bridge/commands.rs" line_range="330-341" />
<code_context>
+    }
+
+    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() {
+                    let reason = format!("Failed to close close confirm prompt window: {error}");
+                    append_desktop_log(&reason);
+                    return BackendBridgeResult {
+                        ok: false,
+                        reason: Some(reason),
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Consider treating prompt-window close failures as non-fatal for the selected tray action.

In the tray path the main window is already hidden before `prompt_window.close()` is called, so the chosen action (minimize to tray) has succeeded even if closing the `close-confirm` window fails. Returning `{ ok: false }` here will surface this as a failed action in the UI, which is misleading because only a cosmetic cleanup failed. Consider logging the error but still returning `{ ok: true }` (or otherwise distinguishing cosmetic failures) so the UI doesn’t show an error for a successful tray action.

```suggestion
            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 {
```
</issue_to_address>

### Comment 2
<location path="ui/close-confirm.html" line_range="204-205" />
<code_context>
+      document.getElementById("title").textContent = copy.title;
+      document.getElementById("description").textContent = copy.description;
+      document.getElementById("remember-label").textContent = copy.remember;
+      trayButton.textContent = locale === "zh-CN" ? copy.tray : "Minimize to tray";
+      exitButton.textContent = locale === "zh-CN" ? copy.exit : "Exit now";
+
+      function setPending(pending) {
</code_context>
<issue_to_address>
**nitpick:** Avoid duplicating the English labels already present in the locale map.

For `en-US`, `copy.tray` and `copy.exit` already provide these strings, so the ternaries only special-case `zh-CN` while duplicating the English literals. This risks the literals and the locale map drifting out of sync. Prefer always using `copy.tray` / `copy.exit` so the `messages` map remains the single source of truth.
</issue_to_address>

### Comment 3
<location path="src-tauri/src/close_behavior.rs" line_range="15" />
<code_context>
+    Value::Object(Map::new())
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub(crate) enum CloseAction {
</code_context>
<issue_to_address>
**issue (complexity):** Consider leveraging serde derives, a small typed DesktopState struct, and simpler path handling to reduce the amount of custom JSON and enum plumbing in this module while keeping the same behavior.

You can keep the same behavior while trimming quite a bit of surface area. Concretely:

### 1. Let serde handle enum parsing/serialization

`CloseAction::parse`, `as_state_value`, and `parse_close_action` duplicate what serde already does for you given the `#[serde(rename_all = "lowercase")]` derive.

You can drop those helpers and use serde directly:

```rust
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum CloseAction {
    Tray,
    Exit,
}

// Example usage in read/write:

fn parse_close_action_value(v: &Value) -> Option<CloseAction> {
    serde_json::from_value::<CloseAction>(v.clone()).ok()
}

fn close_action_to_value(action: CloseAction) -> Value {
    serde_json::to_value(action).expect("enum to serialize")
}
```

Then your read/write code can work directly with `CloseAction` via serde instead of manual `"tray"` / `"exit"` mapping.

### 2. Replace generic `Value` machinery with a small state struct

You currently have `empty_state_object`, `load_state_value`, and `ensure_object` to normalize JSON into an object and preserve unknown fields. You can keep all of that behavior but collapse it into a single typed struct that uses `#[serde(flatten)]`:

```rust
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};

const CLOSE_ACTION_FIELD: &str = "closeActionOnWindowClose";

#[derive(Serialize, Deserialize, Default)]
struct DesktopState {
    #[serde(rename = "closeActionOnWindowClose")]
    #[serde(skip_serializing_if = "Option::is_none")]
    close_action: Option<CloseAction>,

    // Preserve all other fields verbatim
    #[serde(flatten)]
    rest: Map<String, Value>,
}

fn load_desktop_state(raw: &str, log_subject: &str) -> DesktopState {
    match serde_json::from_str::<DesktopState>(raw) {
        Ok(state) => state,
        Err(error) => {
            crate::append_desktop_log(&format!(
                "failed to parse {log_subject}: {error}. resetting state semantics"
            ));
            DesktopState::default()
        }
    }
}
```

Then your read/write become much simpler:

```rust
fn read_cached_close_action_from_state_path(state_path: Option<PathBuf>) -> Option<CloseAction> {
    let raw = fs::read_to_string(state_path?).ok()?;
    let state = load_desktop_state(&raw, "desktop close behavior state");
    state.close_action
}

fn write_cached_close_action_to_state_path(
    action: Option<CloseAction>,
    state_path: Option<PathBuf>,
) -> Result<(), String> {
    let Some(state_path) = state_path else {
        crate::append_desktop_log(
            "close behavior state path is unavailable; skipping close action persistence",
        );
        return Ok(());
    };

    let mut state = match fs::read_to_string(&state_path) {
        Ok(raw) => load_desktop_state(
            &raw,
            &format!("close behavior state {}", state_path.display()),
        ),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => DesktopState::default(),
        Err(e) => {
            return Err(format!(
                "Failed to read close behavior state {}: {}",
                state_path.display(),
                e
            ));
        }
    };

    state.close_action = action;

    save_state(&state_path, &serde_json::to_value(&state).unwrap())
}
```

This removes `empty_state_object`, `ensure_object`, manual map insert/remove, and the `CLOSE_ACTION_FIELD` string manipulation, while still:

- Treating malformed/incorrect-root JSON as “reset to empty object”.
- Preserving unrelated fields via `rest`.
- Allowing the `None` case to remove the close-action field.

### 3. Collapse the `Option<Path>` plumbing

The `*_from_state_path` / `*_to_state_path` pairs add an extra level of indirection for the `Option<PathBuf>` that doesn’t buy much.

You can push the `Option` handling to the public API and make the internal helpers work with `&Path`, which simplifies signatures and branching:

```rust
pub(crate) fn read_cached_close_action(
    packaged_root_dir: Option<&Path>,
) -> Option<CloseAction> {
    let state_path =
        crate::desktop_state::resolve_desktop_state_path(packaged_root_dir)?;
    read_cached_close_action_at_path(&state_path)
}

fn read_cached_close_action_at_path(state_path: &Path) -> Option<CloseAction> {
    let raw = fs::read_to_string(state_path).ok()?;
    let state = load_desktop_state(&raw, "desktop close behavior state");
    state.close_action
}

pub(crate) fn write_cached_close_action(
    action: Option<CloseAction>,
    packaged_root_dir: Option<&Path>,
) -> Result<(), String> {
    let Some(state_path) =
        crate::desktop_state::resolve_desktop_state_path(packaged_root_dir)
    else {
        crate::append_desktop_log(
            "close behavior state path is unavailable; skipping close action persistence",
        );
        return Ok(());
    };

    write_cached_close_action_at_path(action, &state_path)
}

fn write_cached_close_action_at_path(
    action: Option<CloseAction>,
    state_path: &Path,
) -> Result<(), String> {
    // same as above, but with &Path
}
```

This keeps the behavior (no-op + log when path is unavailable) but reduces the number of functions and avoids threading `Option<PathBuf>` through the whole module.

---

If there is already a shared “atomic JSON save” helper elsewhere in the crate, wiring `save_state` through that helper instead of hand-rolling the temp-file pattern here would further cut down conceptual surface area while preserving the durability guarantees.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread src-tauri/src/bridge/commands.rs Outdated
Comment thread ui/close-confirm.html Outdated
Comment thread src-tauri/src/close_behavior.rs
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request implements a close confirmation dialog that allows users to choose between minimizing the application to the system tray or exiting completely. It introduces a new UI component, a persistence layer for saving user preferences, and logic within the window event loop to handle close requests based on these preferences. Key feedback includes addressing a potential file-locking issue on Windows during state persistence, migrating from internal to public Tauri APIs, and optimizing performance by avoiding synchronous I/O on the main thread. Other suggestions focus on improving code maintainability through the use of constants and simplifying localization logic.

Comment on lines +112 to +128
let mut file = fs::File::create(&tmp_path).map_err(|error| {
format!(
"Failed to create temporary close behavior state file {}: {}",
tmp_path.display(),
error
)
})?;
file.write_all(serialized.as_bytes())
.and_then(|_| file.sync_all())
.map_err(|error| {
format!(
"Failed to write temporary close behavior state file {}: {}",
tmp_path.display(),
error
)
})?;
fs::rename(&tmp_path, path).map_err(|error| {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

On Windows, fs::rename will likely fail if the source file is still open. The file handle created at line 112 remains in scope and holds an open handle to the temporary file until the end of the function. You should explicitly drop the file handle or wrap the write operations in a block before attempting to rename the file.

Suggested change
let mut file = fs::File::create(&tmp_path).map_err(|error| {
format!(
"Failed to create temporary close behavior state file {}: {}",
tmp_path.display(),
error
)
})?;
file.write_all(serialized.as_bytes())
.and_then(|_| file.sync_all())
.map_err(|error| {
format!(
"Failed to write temporary close behavior state file {}: {}",
tmp_path.display(),
error
)
})?;
fs::rename(&tmp_path, path).map_err(|error| {
let mut file = fs::File::create(&tmp_path).map_err(|error| {
format!(
"Failed to create temporary close behavior state file {}: {}",
tmp_path.display(),
error
)
})?;
file.write_all(serialized.as_bytes())
.and_then(|_| file.sync_all())
.map_err(|error| {
format!(
"Failed to write temporary close behavior state file {}: {}",
tmp_path.display(),
error
)
})?;
drop(file);
fs::rename(&tmp_path, path).map_err(|error| {

Comment thread ui/close-confirm.html Outdated
setPending(true);

try {
const result = await window.__TAURI_INTERNALS__.invoke(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

Using window.__TAURI_INTERNALS__ is discouraged as it targets internal APIs that are subject to change without notice. For Tauri v2, you should use the standard window.__TAURI__.core.invoke if the global Tauri object is enabled, or import invoke from the @tauri-apps/api package if using a bundler.

Suggested change
const result = await window.__TAURI_INTERNALS__.invoke(
const result = await window.__TAURI__.core.invoke(

Comment thread src-tauri/src/app_runtime.rs Outdated
Comment on lines +27 to +29
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());
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Performing synchronous file I/O and environment/home directory resolution within the window event loop can lead to UI unresponsiveness or stutters, as this closure runs on the main thread. Consider caching the packaged_root_dir and the saved_close_action in the BackendState during application startup to avoid blocking the event loop on every close request.

Comment thread src-tauri/src/bridge/commands.rs Outdated
DEFAULT_SHELL_LOCALE,
append_desktop_log,
);
if let Some(prompt_window) = app_handle.get_webview_window("close-confirm") {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Avoid using hardcoded strings for window labels. It is better to use the constant defined in the window::close_confirm module to ensure consistency across the codebase.

Suggested change
if let Some(prompt_window) = app_handle.get_webview_window("close-confirm") {
if let Some(prompt_window) = app_handle.get_webview_window(window::close_confirm::CLOSE_CONFIRM_WINDOW_LABEL) {

Comment thread src-tauri/src/window/close_confirm.rs Outdated
@@ -0,0 +1,78 @@
use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder};

const CLOSE_CONFIRM_WINDOW_LABEL: &str = "close-confirm";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

This constant should be made pub(crate) so it can be referenced by the bridge commands instead of using hardcoded strings.

Suggested change
const CLOSE_CONFIRM_WINDOW_LABEL: &str = "close-confirm";
pub(crate) const CLOSE_CONFIRM_WINDOW_LABEL: &str = "close-confirm";

Comment thread ui/close-confirm.html Outdated
Comment on lines +204 to +205
trayButton.textContent = locale === "zh-CN" ? copy.tray : "Minimize to tray";
exitButton.textContent = locale === "zh-CN" ? copy.exit : "Exit now";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The ternary logic here is redundant because the copy object is already localized based on the detected locale. You can directly use the properties from the copy object.

Suggested change
trayButton.textContent = locale === "zh-CN" ? copy.tray : "Minimize to tray";
exitButton.textContent = locale === "zh-CN" ? copy.exit : "Exit now";
trayButton.textContent = copy.tray;
exitButton.textContent = copy.exit;

@zouyonghe
Copy link
Copy Markdown
Member Author

@sourcery-ai review

Copy link
Copy Markdown

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 3 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="src-tauri/src/bridge/commands.rs" line_range="177-178" />
<code_context>
+    Log: Fn(&str),
+{
+    if let Err(error) = cleanup_result {
+        log(&format!(
+            "Failed to close close confirm prompt window: {error}"
+        ));
+    }
</code_context>
<issue_to_address>
**nitpick (typo):** The log message for close-confirm cleanup reads slightly awkwardly with a repeated "close".

The string "Failed to close close confirm prompt window" has a duplicated "close" that makes the log harder to scan. Consider rephrasing to something like "Failed to close confirm prompt window" or "Failed to close the close-confirm prompt window" for readability.

Suggested implementation:

```rust
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}"
        ));
    }

    Ok(BackendBridgeResponse::Success)
}

```

1. If `BackendBridgeResult` / `BackendBridgeResponse::Success` are not the correct result types for this function in your codebase, adjust the return to match the existing convention (e.g. `Ok(())` or another success variant).
2. Ensure `BackendBridgeResponse` is imported or in scope if that's the expected return type; otherwise, replace it with the appropriate type already used elsewhere in `commands.rs`.
</issue_to_address>

### Comment 2
<location path="src-tauri/src/bridge/commands.rs" line_range="164" />
<code_context>
     }
 }

+fn parse_close_prompt_action(raw_action: &str) -> Result<CloseAction, String> {
+    close_behavior::parse_close_action(raw_action)
+        .ok_or_else(|| "Invalid close action. Expected 'tray' or 'exit'.".to_string())
</code_context>
<issue_to_address>
**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:

```rust
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:

```rust
#[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:

```rust
// 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:

```rust
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.
</issue_to_address>

### Comment 3
<location path="src-tauri/src/close_behavior.rs" line_range="34" />
<code_context>
+    rest: Map<String, Value>,
+}
+
+pub(crate) fn parse_close_action(raw: &str) -> Option<CloseAction> {
+    serde_json::from_value(Value::String(raw.to_string())).ok()
+}
</code_context>
<issue_to_address>
**issue (complexity):** Consider simplifying CloseAction parsing and deserialization by matching directly on strings instead of routing through serde_json::Value and from_value, which will keep behavior while making the code easier to follow.

You can reduce indirection around `CloseAction` parsing/deserialization without changing behavior.

### 1. Avoid going through `serde_json::Value` for `CloseAction`

Both `parse_close_action` and `deserialize_close_action_option` currently route through `serde_json::Value` and `serde_json::from_value`, which makes a simple string→enum mapping harder to follow.

You can keep the “invalid → None (no error)” semantics and the `#[serde(rename_all = "lowercase")]` behavior while simplifying to direct string matching:

```rust
pub(crate) fn parse_close_action(raw: &str) -> Option<CloseAction> {
    match raw {
        "tray" => Some(CloseAction::Tray),
        "exit" => Some(CloseAction::Exit),
        _ => None,
    }
}
```

Then reuse this in the custom deserializer, instead of touching `serde_json::Value` at all:

```rust
fn deserialize_close_action_option<'de, D>(
    deserializer: D,
) -> Result<Option<CloseAction>, D::Error>
where
    D: Deserializer<'de>,
{
    let raw = Option::<String>::deserialize(deserializer)?;
    Ok(raw.as_deref().and_then(parse_close_action))
}
```

This keeps:

- `tray` / `exit` accepted,
- anything else treated as `None` (as your tests expect),
- unknown fields preserved via `rest: Map<String, Value>`,

but makes the control flow much easier to read and debug by removing JSON-specific machinery from a simple enum mapping.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread src-tauri/src/bridge/commands.rs Outdated
}
}

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.

Comment thread src-tauri/src/close_behavior.rs
@zouyonghe
Copy link
Copy Markdown
Member Author

@sourcery-ai review

Copy link
Copy Markdown

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 2 issues, and left some high level feedback:

  • The exit flow is implemented both in MainWindowAction::ExitApplication and in desktop_bridge_submit_close_prompt (marking quitting, logging, and calling app_handle.exit(0)), which could be refactored into a shared helper to keep shutdown behavior and logging consistent across all exit paths.
  • The close-confirm dialog script calls window.__TAURI_INTERNALS__.invoke directly; if you already have a wrapper or public Tauri API usage elsewhere, consider routing this through that instead to avoid depending on Tauri internals and keep the shell layer consistent.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The exit flow is implemented both in `MainWindowAction::ExitApplication` and in `desktop_bridge_submit_close_prompt` (marking `quitting`, logging, and calling `app_handle.exit(0)`), which could be refactored into a shared helper to keep shutdown behavior and logging consistent across all exit paths.
- The close-confirm dialog script calls `window.__TAURI_INTERNALS__.invoke` directly; if you already have a wrapper or public Tauri API usage elsewhere, consider routing this through that instead to avoid depending on Tauri internals and keep the shell layer consistent.

## Individual Comments

### Comment 1
<location path="src-tauri/src/close_behavior.rs" line_range="12-16" />
<code_context>
+    Exit,
+}
+
+fn deserialize_close_action_option<'de, D>(deserializer: D) -> Result<Option<CloseAction>, D::Error>
+where
+    D: Deserializer<'de>,
+{
+    let raw = Option::<String>::deserialize(deserializer)?;
+    Ok(raw.as_deref().and_then(parse_close_action))
+}
</code_context>
<issue_to_address>
**issue (bug_risk):** The custom deserializer for close_action will fail hard on non-string values, dropping the entire state.

Because this helper deserializes via `Option::<String>::deserialize`, any non-string `closeActionOnWindowClose` (e.g., `true`, `0`, or an object) will cause `serde_json::from_str::<DesktopState>` to fail and `load_desktop_state` to fall back to `DesktopState::default()`, discarding the saved state. To avoid losing the whole state on malformed values while keeping the field optional, you could deserialize as `Option<serde_json::Value>` and only call `parse_close_action` for `Value::String(s)`, treating other types like invalid strings (i.e., `None`) instead of an error.
</issue_to_address>

### Comment 2
<location path="src-tauri/src/close_behavior.rs" line_range="65" />
<code_context>
+    state.close_action
+}
+
+fn ensure_parent_dir(path: &Path) -> Result<(), String> {
+    if let Some(parent_dir) = path.parent() {
+        fs::create_dir_all(parent_dir).map_err(|error| {
</code_context>
<issue_to_address>
**issue (complexity):** Consider simplifying the I/O helpers by specializing `save_state` to `DesktopState` and inlining `ensure_parent_dir` to reduce unnecessary abstraction and surface area without changing behavior.

You can shave off a layer of abstraction in the I/O helpers without changing behavior by specializing `save_state` to `DesktopState` and inlining `ensure_parent_dir`. This simplifies the signatures and error surface while keeping the same semantics and tests.

For example:

```rust
fn save_desktop_state(path: &Path, state: &DesktopState) -> Result<(), String> {
    if let Some(parent_dir) = path.parent() {
        fs::create_dir_all(parent_dir).map_err(|error| {
            format!(
                "Failed to create close behavior directory {}: {}",
                parent_dir.display(),
                error
            )
        })?;
    }

    let serialized = serde_json::to_string_pretty(state)
        .map_err(|error| format!("Failed to serialize close behavior state: {error}"))?;

    let tmp_name = format!(
        "{}.tmp",
        path.file_name()
            .map(|value| value.to_string_lossy())
            .unwrap_or_default()
    );
    let tmp_path = path.with_file_name(tmp_name);

    let mut file = fs::File::create(&tmp_path).map_err(|error| {
        format!(
            "Failed to create temporary close behavior state file {}: {}",
            tmp_path.display(),
            error
        )
    })?;
    file.write_all(serialized.as_bytes())
        .and_then(|_| file.sync_all())
        .map_err(|error| {
            format!(
                "Failed to write temporary close behavior state file {}: {}",
                tmp_path.display(),
                error
            )
        })?;
    fs::rename(&tmp_path, path).map_err(|error| {
        format!(
            "Failed to atomically replace close behavior state file {}: {}",
            path.display(),
            error
        )
    })
}
```

Then update the call site:

```rust
fn write_cached_close_action_at_path(
    action: Option<CloseAction>,
    state_path: &Path,
) -> Result<(), String> {
    let mut state = match fs::read_to_string(state_path) {
        Ok(raw) => load_desktop_state(
            &raw,
            &format!("close behavior state {}", state_path.display()),
        ),
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => DesktopState::default(),
        Err(error) => {
            return Err(format!(
                "Failed to read close behavior state {}: {}",
                state_path.display(),
                error
            ));
        }
    };
    state.close_action = action;

    save_desktop_state(state_path, &state)
}
```

This keeps all existing behavior (including directory creation, temp file usage, and error messages) while removing a generic and a helper that currently only serve this one type.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread src-tauri/src/close_behavior.rs Outdated
Comment thread src-tauri/src/close_behavior.rs Outdated
@zouyonghe
Copy link
Copy Markdown
Member Author

@sourcery-ai review

Copy link
Copy Markdown

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 1 issue

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="src-tauri/src/bridge/commands.rs" line_range="177-179" />
<code_context>
+        .ok_or_else(|| "Invalid close action. Expected 'tray' or 'exit'.".to_string())
+}
+
+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 close confirm prompt window: {error}"
+        ));
+    }
</code_context>
<issue_to_address>
**nitpick (typo):** Adjust wording in the log message to avoid the duplicated "close" phrasing.

The log message currently says `"Failed to close close confirm prompt window"`, which is awkward. Please rephrase to remove the duplicated "close", for example: `"Failed to close confirm prompt window"` or `"Failed to close close-confirm prompt window"`.

```suggestion
        log(&format!(
            "Failed to close confirm prompt window: {error}"
        ));
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread src-tauri/src/bridge/commands.rs Outdated
@zouyonghe
Copy link
Copy Markdown
Member Author

@sourcery-ai review

Copy link
Copy Markdown

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 2 issues, and left some high level feedback:

  • The window label "close-confirm" is hard-coded in desktop_bridge_submit_close_prompt when looking up the prompt window; consider reusing the CLOSE_CONFIRM_WINDOW_LABEL constant from window::close_confirm to avoid label drift between modules.
  • The close action values ("tray"/"exit") are duplicated as string literals in both the Rust layer (parse_close_action) and the HTML dialog (submit("tray"/"exit")); wiring these through a shared enum/constant or a generated config would reduce the risk of the two drifting out of sync.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The window label "close-confirm" is hard-coded in `desktop_bridge_submit_close_prompt` when looking up the prompt window; consider reusing the `CLOSE_CONFIRM_WINDOW_LABEL` constant from `window::close_confirm` to avoid label drift between modules.
- The close action values ("tray"/"exit") are duplicated as string literals in both the Rust layer (`parse_close_action`) and the HTML dialog (`submit("tray"/"exit")`); wiring these through a shared enum/constant or a generated config would reduce the risk of the two drifting out of sync.

## Individual Comments

### Comment 1
<location path="ui/close-confirm.html" line_range="196-199" />
<code_context>
+      const trayButton = document.getElementById("tray-button");
+      const exitButton = document.getElementById("exit-button");
+      const error = document.getElementById("error");
+      const invokeTauri = window.__TAURI__?.core?.invoke || window.__TAURI__?.invoke || window.__TAURI_INTERNALS__?.invoke;
+      let recoveryTimer = null;
+
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Relying on `window.__TAURI_INTERNALS__` as a fallback is brittle; consider failing gracefully instead of depending on private APIs.

This fallback couples the dialog to a private Tauri API that may change without notice. Since there is already a user‑friendly path when `invokeTauri` is not a function, consider removing the `__TAURI_INTERNALS__` branch and relying only on `window.__TAURI__?.core?.invoke || window.__TAURI__?.invoke` to stay within supported APIs.

```suggestion
      const exitButton = document.getElementById("exit-button");
      const error = document.getElementById("error");
      const invokeTauri = window.__TAURI__?.core?.invoke || window.__TAURI__?.invoke;
      let recoveryTimer = null;
```
</issue_to_address>

### Comment 2
<location path="src-tauri/src/close_behavior.rs" line_range="12" />
<code_context>
+    Exit,
+}
+
+fn deserialize_close_action_option<'de, D>(deserializer: D) -> Result<Option<CloseAction>, D::Error>
+where
+    D: Deserializer<'de>,
</code_context>
<issue_to_address>
**issue (complexity):** Consider deserializing `close_action` via `Option<String>` instead of `Option<Value>` to express the optional string-to-`CloseAction` mapping more directly.

You can simplify the custom deserialization for `close_action` and avoid going through `serde_json::Value`, which reduces indirection without changing behavior.

Currently:

```rust
fn deserialize_close_action_option<'de, D>(deserializer: D) -> Result<Option<CloseAction>, D::Error>
where
    D: Deserializer<'de>,
{
    let raw = Option::<Value>::deserialize(deserializer)?;
    Ok(match raw {
        Some(Value::String(raw)) => parse_close_action(&raw),
        _ => None,
    })
}
```

This can be expressed more directly by deserializing to `Option<String>` instead of `Option<Value>` (you still treat non-strings and invalid values as `None`):

```rust
fn deserialize_close_action_option<'de, D>(deserializer: D) -> Result<Option<CloseAction>, D::Error>
where
    D: Deserializer<'de>,
{
    let raw = Option::<String>::deserialize(deserializer)?;
    Ok(raw.as_deref().and_then(parse_close_action))
}
```

This keeps all current semantics:

- Non-string `closeActionOnWindowClose``None`
- Invalid string value → `None`
- Other fields preserved via `rest` unchanged

…but removes the need to pattern-match on `Value` and makes the intent of “optional string, mapped through `parse_close_action`” clearer.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread ui/close-confirm.html
Comment thread src-tauri/src/close_behavior.rs
@zouyonghe
Copy link
Copy Markdown
Member Author

@sourcery-ai review

Copy link
Copy Markdown

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 2 issues, and left some high level feedback:

  • In close_behavior::load_desktop_state and write_cached_close_action, consider accepting a logging callback instead of calling crate::append_desktop_log directly so the module remains easier to reuse and test in isolation without depending on global logging.
  • In parse_close_prompt_action, build the error message using CLOSE_ACTION_TRAY and CLOSE_ACTION_EXIT rather than hard-coded 'tray'/'exit' strings so the validation message stays in sync if the allowed values ever change.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `close_behavior::load_desktop_state` and `write_cached_close_action`, consider accepting a logging callback instead of calling `crate::append_desktop_log` directly so the module remains easier to reuse and test in isolation without depending on global logging.
- In `parse_close_prompt_action`, build the error message using `CLOSE_ACTION_TRAY` and `CLOSE_ACTION_EXIT` rather than hard-coded `'tray'`/`'exit'` strings so the validation message stays in sync if the allowed values ever change.

## Individual Comments

### Comment 1
<location path="src-tauri/src/bridge/commands.rs" line_range="164-167" />
<code_context>
     }
 }

+fn parse_close_prompt_action(raw_action: &str) -> Result<CloseAction, String> {
+    close_behavior::parse_close_action(raw_action)
+        .ok_or_else(|| "Invalid close action. Expected 'tray' or 'exit'.".to_string())
+}
</code_context>
<issue_to_address>
**suggestion:** Avoid hardcoding expected action strings in the error message to stay consistent with `close_behavior` constants.

The error text hardcodes `'tray'` and `'exit'` while the accepted values are defined in `close_behavior::CLOSE_ACTION_TRAY` / `CLOSE_ACTION_EXIT`. If those constants change, the message will be wrong. Please build the error text from those constants (or the `CloseAction` enum) so it stays in sync.

```suggestion
fn parse_close_prompt_action(raw_action: &str) -> Result<CloseAction, String> {
    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,
        )
    })
}
```
</issue_to_address>

### Comment 2
<location path="src-tauri/src/window/close_confirm.rs" line_range="26-30" />
<code_context>
+where
+    F: Fn(&str),
+{
+    if let Some(window) = app_handle.get_webview_window(CLOSE_CONFIRM_WINDOW_LABEL) {
+        if let Err(error) = window.unminimize() {
+            log(&format!(
+                "failed to unminimize close confirm window: {error}"
+            ));
+        }
+        if let Err(error) = window.show() {
+            log(&format!("failed to show close confirm window: {error}"));
+        }
+        if let Err(error) = window.set_focus() {
+            log(&format!("failed to focus close confirm window: {error}"));
+        }
+        return Ok(());
+    }
+
</code_context>
<issue_to_address>
**suggestion (bug_risk):** The reopen path silently ignores failures to unminimize/show/focus the existing window, which might mask issues.

When the window already exists, errors from `unminimize`, `show`, and `set_focus` are only logged and the function still returns `Ok(())`. This can leave callers believing the dialog is visible and focused when it isn’t. Consider returning an `Err` on the first failure so callers can apply a fallback (e.g., hide the main window directly or show an alternative UX) instead of treating this as success.

```suggestion
    log: F,
) -> Result<(), String>
where
    F: Fn(&str),
{
    if let Some(window) = app_handle.get_webview_window(CLOSE_CONFIRM_WINDOW_LABEL) {
        if let Err(error) = window.unminimize() {
            let message = format!("failed to unminimize close confirm window: {error}");
            log(&message);
            return Err(message);
        }
        if let Err(error) = window.show() {
            let message = format!("failed to show close confirm window: {error}");
            log(&message);
            return Err(message);
        }
        if let Err(error) = window.set_focus() {
            let message = format!("failed to focus close confirm window: {error}");
            log(&message);
            return Err(message);
        }
        return Ok(());
    }

```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread src-tauri/src/bridge/commands.rs
Comment thread src-tauri/src/window/close_confirm.rs
@zouyonghe
Copy link
Copy Markdown
Member Author

@sourcery-ai review

Copy link
Copy Markdown

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 1 issue

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="src-tauri/src/close_behavior.rs" line_range="55" />
<code_context>
+    }
+}
+
+fn load_desktop_state<F>(raw: &str, log_subject: &str, log: &F) -> DesktopState
+where
+    F: Fn(&str),
</code_context>
<issue_to_address>
**issue (complexity):** Consider simplifying the logging and file-writing utilities by centralizing logger usage and extracting a reusable atomic JSON writer helper to reduce boilerplate and clarify responsibilities.

You can reduce complexity most effectively by simplifying the logging plumbing and centralizing the IO helpers, without changing behavior.

### 1. Narrow the logging surface (`Fn` generics → shared logger type)

Right now almost every function is generic over `F: Fn(&str)`, including internal helpers like `load_desktop_state` and `*_at_path`. That adds noise and duplicates the “logging capability” concept at every layer.

You can define a shared logger type and use `&Logger` internally, while keeping the public API generic for flexibility:

```rust
type Logger = dyn Fn(&str);

fn load_desktop_state(
    raw: &str,
    log_subject: &str,
    log: &Logger,
) -> DesktopState {
    match serde_json::from_str::<DesktopState>(raw) {
        Ok(state) => state,
        Err(error) => {
            log(&format!(
                "failed to parse {log_subject}: {error}. resetting state semantics"
            ));
            DesktopState::default()
        }
    }
}

fn read_cached_close_action_at_path(
    state_path: &Path,
    log: &Logger,
) -> Option<CloseAction> {
    let raw = fs::read_to_string(state_path).ok()?;
    let state = load_desktop_state(&raw, "desktop close behavior state", log);
    state.close_action
}

fn write_cached_close_action_at_path(
    action: Option<CloseAction>,
    state_path: &Path,
    log: &Logger,
) -> Result<(), String> {
    // unchanged body, just `log` is now `&Logger`
    // ...
    Ok(())
}

// Public API stays generic and just passes a `&Logger` through:
pub(crate) fn read_cached_close_action<F>(
    packaged_root_dir: Option<&Path>,
    log: F,
) -> Option<CloseAction>
where
    F: Fn(&str),
{
    let state_path = crate::desktop_state::resolve_desktop_state_path(packaged_root_dir)?;
    read_cached_close_action_at_path(&state_path, &log)
}

pub(crate) fn write_cached_close_action<F>(
    action: Option<CloseAction>,
    packaged_root_dir: Option<&Path>,
    log: F,
) -> Result<(), String>
where
    F: Fn(&str),
{
    let Some(state_path) = crate::desktop_state::resolve_desktop_state_path(packaged_root_dir)
    else {
        log("close behavior state path is unavailable; skipping close action persistence");
        return Ok(());
    };

    write_cached_close_action_at_path(action, &state_path, &log)
}
```

Tests can keep using `noop_log` as `&Logger` with no behavior change. This removes generic parameters from internal helpers and makes signatures more readable.

### 2. Extract a tiny reusable atomic JSON writer

`save_desktop_state` mixes state semantics with low-level atomic write details. You can keep all behavior but move the IO pattern into a small helper, which also makes future reuse easier:

```rust
fn atomic_write_json(path: &Path, value: &impl Serialize, what: &str) -> Result<(), String> {
    if let Some(parent_dir) = path.parent() {
        fs::create_dir_all(parent_dir).map_err(|error| {
            format!("Failed to create {what} directory {}: {}", parent_dir.display(), error)
        })?;
    }

    let serialized = serde_json::to_string_pretty(value)
        .map_err(|error| format!("Failed to serialize {what}: {error}"))?;

    let tmp_name = format!(
        "{}.tmp",
        path.file_name()
            .map(|value| value.to_string_lossy())
            .unwrap_or_default()
    );
    let tmp_path = path.with_file_name(tmp_name);

    let mut file = fs::File::create(&tmp_path).map_err(|error| {
        format!(
            "Failed to create temporary {what} file {}: {}",
            tmp_path.display(),
            error
        )
    })?;
    file.write_all(serialized.as_bytes())
        .and_then(|_| file.sync_all())
        .map_err(|error| {
            format!(
                "Failed to write temporary {what} file {}: {}",
                tmp_path.display(),
                error
            )
        })?;
    fs::rename(&tmp_path, path).map_err(|error| {
        format!(
            "Failed to atomically replace {what} file {}: {}",
            path.display(),
            error
        )
    })
}

fn save_desktop_state(path: &Path, state: &DesktopState) -> Result<(), String> {
    atomic_write_json(path, state, "close behavior state")
}
```

This keeps all existing error messages structurally the same (only `what` is parameterized), preserves atomic behavior, and makes the state-related code (`state.close_action = action`) clearly separate from IO mechanics, which should address part of the “responsibility mix” concern without changing functionality.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread src-tauri/src/close_behavior.rs
@zouyonghe
Copy link
Copy Markdown
Member Author

@sourcery-ai review

Copy link
Copy Markdown

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 1 issue

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="src-tauri/src/close_behavior.rs" line_range="8" />
<code_context>
+pub(crate) const CLOSE_ACTION_TRAY: &str = "tray";
+pub(crate) const CLOSE_ACTION_EXIT: &str = "exit";
+
+type Logger<'a> = dyn Fn(&str) + 'a;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
</code_context>
<issue_to_address>
**issue (complexity):** Consider simplifying the logging and write helpers by using a generic `Fn(&str)` logger instead of a `Logger` trait object and by specializing the atomic write function to desktop state only, keeping behavior but making the code more direct and focused.

You can reduce the complexity without changing behavior by (1) dropping the `Logger` trait object and (2) specializing `atomic_write_json` to desktop state.

### 1. Remove `Logger` trait object and use generics consistently

You’re already generic at the public API; you can carry that through to the internals and drop the `type Logger` indirection and `&Logger` references:

```rust
// remove
- type Logger<'a> = dyn Fn(&str) + 'a;

- fn load_desktop_state(raw: &str, log_subject: &str, log: &Logger<'_>) -> DesktopState {
+ fn load_desktop_state<F>(raw: &str, log_subject: &str, log: &F) -> DesktopState
+ where
+     F: Fn(&str),
+ {
    match serde_json::from_str::<DesktopState>(raw) {
        Ok(state) => state,
        Err(error) => {
            log(&format!(
                "failed to parse {log_subject}: {error}. resetting state semantics"
            ));
            DesktopState::default()
        }
    }
}

- fn read_cached_close_action_at_path(state_path: &Path, log: &Logger<'_>) -> Option<CloseAction> {
+ fn read_cached_close_action_at_path<F>(state_path: &Path, log: &F) -> Option<CloseAction>
+ where
+     F: Fn(&str),
+ {
    let raw = fs::read_to_string(state_path).ok()?;
    let state = load_desktop_state(&raw, "desktop close behavior state", log);
    state.close_action
}

- fn write_cached_close_action_at_path(
-     action: Option<CloseAction>,
-     state_path: &Path,
-     log: &Logger<'_>,
- ) -> Result<(), String> {
+ fn write_cached_close_action_at_path<F>(
+     action: Option<CloseAction>,
+     state_path: &Path,
+     log: &F,
+ ) -> Result<(), String>
+ where
+     F: Fn(&str),
+ {
    let mut state = match fs::read_to_string(state_path) {
        Ok(raw) => load_desktop_state(
            &raw,
            &format!("close behavior state {}", state_path.display()),
            log,
        ),
        // ...
    };
    // ...
}
```

Call sites (including tests) keep using `&noop_log` or `&log` unchanged, but the logging path is now a straightforward generic `Fn(&str)` with no trait object alias or lifetime gymnastics.

### 2. Specialize `atomic_write_json` for desktop state

Since this helper is only used for the desktop state, you can narrow its purpose and fold the `what` string into the messages. This makes the write path easier to follow:

```rust
- fn atomic_write_json(path: &Path, value: &impl Serialize, what: &str) -> Result<(), String> {
+ fn atomic_write_desktop_state(path: &Path, value: &DesktopState) -> Result<(), String> {
    if let Some(parent_dir) = path.parent() {
        fs::create_dir_all(parent_dir).map_err(|error| {
            format!(
-               "Failed to create {what} directory {}: {}",
+               "Failed to create desktop state directory {}: {}",
                parent_dir.display(),
                error
            )
        })?;
    }

    let serialized = serde_json::to_string_pretty(value)
-       .map_err(|error| format!("Failed to serialize {what}: {error}"))?;
+       .map_err(|error| format!("Failed to serialize desktop state: {error}"))?;
    // ...
    let mut file = fs::File::create(&tmp_path).map_err(|error| {
        format!(
-           "Failed to create temporary {what} file {}: {}",
+           "Failed to create temporary desktop state file {}: {}",
            tmp_path.display(),
            error
        )
    })?;
    // ...
    fs::rename(&tmp_path, path).map_err(|error| {
        format!(
-           "Failed to atomically replace {what} file {}: {}",
+           "Failed to atomically replace desktop state file {}: {}",
            path.display(),
            error
        )
    })
}

- fn save_desktop_state(path: &Path, state: &DesktopState) -> Result<(), String> {
-     atomic_write_json(path, state, "close behavior state")
- }
+ fn save_desktop_state(path: &Path, state: &DesktopState) -> Result<(), String> {
+     atomic_write_desktop_state(path, state)
+ }
```

This keeps behavior and messages effectively the same, but removes generic `Serialize` and the generic `what` concept, so the write path is clearly and solely about the desktop state.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread src-tauri/src/close_behavior.rs Outdated
@zouyonghe
Copy link
Copy Markdown
Member Author

@sourcery-ai review

Copy link
Copy Markdown

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 1 issue

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="src-tauri/src/close_behavior.rs" line_range="33" />
<code_context>
+    })
+}
+
+#[derive(Debug, Default, Serialize, Deserialize)]
+struct DesktopState {
+    #[serde(
</code_context>
<issue_to_address>
**issue (complexity):** Consider simplifying this module by removing the custom `DesktopState`/serde layer, collapsing the read/write helpers, and using a generic atomic JSON writer while preserving all existing behaviors.

You can keep the same behavior while cutting a lot of the bespoke machinery by:

1. Dropping `DesktopState` + custom serde
2. Collapsing the read/write layering
3. Generalizing the atomic write helper

### 1. Replace `DesktopState` + custom deserialization with `serde_json::Value`

All of this:

- `DesktopState`
- `deserialize_close_action_option`
- `load_desktop_state`
- `rest: Map<String, Value>`

exists mainly to preserve unknown fields and tolerate invalid `closeActionOnWindowClose` values. You can do that directly on `serde_json::Value`:

```rust
pub(crate) fn parse_close_action(raw: &str) -> Option<CloseAction> {
    match raw {
        CLOSE_ACTION_TRAY => Some(CloseAction::Tray),
        CLOSE_ACTION_EXIT => Some(CloseAction::Exit),
        _ => None,
    }
}

fn load_state_value<F>(raw: &str, log_subject: &str, log: &F) -> serde_json::Value
where
    F: Fn(&str),
{
    match serde_json::from_str::<serde_json::Value>(raw) {
        Ok(serde_json::Value::Object(map)) => serde_json::Value::Object(map),
        Ok(_) | Err(_) => {
            log(&format!(
                "failed to parse {log_subject}: resetting state semantics"
            ));
            serde_json::json!({})
        }
    }
}
```

Then `read_cached_close_action_at_path` becomes:

```rust
fn read_cached_close_action_at_path<F>(state_path: &Path, log: &F) -> Option<CloseAction>
where
    F: Fn(&str),
{
    let raw = fs::read_to_string(state_path).ok()?;
    let state = load_state_value(&raw, "desktop close behavior state", log);

    state
        .get("closeActionOnWindowClose")
        .and_then(|v| v.as_str())
        .and_then(parse_close_action)
}
```

This preserves:

- arbitrary other fields (you never drop them)
- invalid value → `None`
- non‑string value → `None`
- malformed JSON or non‑object root → log + treat as empty object

### 2. Simplify the write path and remove `*_at_path` and `save_desktop_state`

You can inline `save_desktop_state` and remove the layered `*_at_path` variant by working directly on `Value`:

```rust
fn write_cached_close_action_at_path<F>(
    action: Option<CloseAction>,
    state_path: &Path,
    log: &F,
) -> Result<(), String>
where
    F: Fn(&str),
{
    let mut state = match fs::read_to_string(state_path) {
        Ok(raw) => load_state_value(
            &raw,
            &format!("close behavior state {}", state_path.display()),
            log,
        ),
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => serde_json::json!({}),
        Err(error) => {
            return Err(format!(
                "Failed to read close behavior state {}: {}",
                state_path.display(),
                error
            ));
        }
    };

    let obj = state.as_object_mut().expect("load_state_value guarantees object");

    match action {
        Some(CloseAction::Tray) => {
            obj.insert(
                "closeActionOnWindowClose".to_string(),
                serde_json::json!("tray"),
            );
        }
        Some(CloseAction::Exit) => {
            obj.insert(
                "closeActionOnWindowClose".to_string(),
                serde_json::json!("exit"),
            );
        }
        None => {
            obj.remove("closeActionOnWindowClose");
        }
    }

    atomic_write_json(state_path, &state)
}
```

The public entry point can stay basically as‑is, but now calls the simplified helper:

```rust
pub(crate) fn write_cached_close_action<F>(
    action: Option<CloseAction>,
    packaged_root_dir: Option<&Path>,
    log: F,
) -> Result<(), String>
where
    F: Fn(&str),
{
    let Some(state_path) = crate::desktop_state::resolve_desktop_state_path(packaged_root_dir)
    else {
        log("close behavior state path is unavailable; skipping close action persistence");
        return Ok(());
    };

    write_cached_close_action_at_path(action, &state_path, &log)
}
```

This keeps:

- preserving unrelated fields
- resetting malformed state to object with only `closeActionOnWindowClose`
- `None` removing only the close‑action field

…without custom serde types or a separate `DesktopState` struct.

### 3. Generalize the atomic write helper

You can keep your existing atomic write semantics but make it reusable and less `DesktopState`‑specific:

```rust
fn atomic_write_json(path: &Path, value: &impl Serialize) -> Result<(), String> {
    if let Some(parent_dir) = path.parent() {
        fs::create_dir_all(parent_dir).map_err(|error| {
            format!(
                "Failed to create desktop state directory {}: {}",
                parent_dir.display(),
                error
            )
        })?;
    }

    let serialized =
        serde_json::to_string_pretty(value).map_err(|error| {
            format!("Failed to serialize desktop state: {error}")
        })?;

    let tmp_name = format!(
        "{}.tmp",
        path.file_name()
            .map(|value| value.to_string_lossy())
            .unwrap_or_default()
    );
    let tmp_path = path.with_file_name(tmp_name);

    let mut file = fs::File::create(&tmp_path).map_err(|error| {
        format!(
            "Failed to create temporary desktop state file {}: {}",
            tmp_path.display(),
            error
        )
    })?;
    file.write_all(serialized.as_bytes())
        .and_then(|_| file.sync_all())
        .map_err(|error| {
            format!(
                "Failed to write temporary desktop state file {}: {}",
                tmp_path.display(),
                error
            )
        })?;
    fs::rename(&tmp_path, path).map_err(|error| {
        format!(
            "Failed to atomically replace desktop state file {}: {}",
            path.display(),
            error
        )
    })
}
```

Then you can remove `atomic_write_desktop_state` and `save_desktop_state` entirely and just use `atomic_write_json`.

These tweaks keep all the current semantics (including logging, malformed‑file handling, and preservation of unknown fields) but significantly reduce the amount of custom serde and layering you have to reason about.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

})
}

#[derive(Debug, Default, Serialize, Deserialize)]
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 simplifying this module by removing the custom DesktopState/serde layer, collapsing the read/write helpers, and using a generic atomic JSON writer while preserving all existing behaviors.

You can keep the same behavior while cutting a lot of the bespoke machinery by:

  1. Dropping DesktopState + custom serde
  2. Collapsing the read/write layering
  3. Generalizing the atomic write helper

1. Replace DesktopState + custom deserialization with serde_json::Value

All of this:

  • DesktopState
  • deserialize_close_action_option
  • load_desktop_state
  • rest: Map<String, Value>

exists mainly to preserve unknown fields and tolerate invalid closeActionOnWindowClose values. You can do that directly on serde_json::Value:

pub(crate) fn parse_close_action(raw: &str) -> Option<CloseAction> {
    match raw {
        CLOSE_ACTION_TRAY => Some(CloseAction::Tray),
        CLOSE_ACTION_EXIT => Some(CloseAction::Exit),
        _ => None,
    }
}

fn load_state_value<F>(raw: &str, log_subject: &str, log: &F) -> serde_json::Value
where
    F: Fn(&str),
{
    match serde_json::from_str::<serde_json::Value>(raw) {
        Ok(serde_json::Value::Object(map)) => serde_json::Value::Object(map),
        Ok(_) | Err(_) => {
            log(&format!(
                "failed to parse {log_subject}: resetting state semantics"
            ));
            serde_json::json!({})
        }
    }
}

Then read_cached_close_action_at_path becomes:

fn read_cached_close_action_at_path<F>(state_path: &Path, log: &F) -> Option<CloseAction>
where
    F: Fn(&str),
{
    let raw = fs::read_to_string(state_path).ok()?;
    let state = load_state_value(&raw, "desktop close behavior state", log);

    state
        .get("closeActionOnWindowClose")
        .and_then(|v| v.as_str())
        .and_then(parse_close_action)
}

This preserves:

  • arbitrary other fields (you never drop them)
  • invalid value → None
  • non‑string value → None
  • malformed JSON or non‑object root → log + treat as empty object

2. Simplify the write path and remove *_at_path and save_desktop_state

You can inline save_desktop_state and remove the layered *_at_path variant by working directly on Value:

fn write_cached_close_action_at_path<F>(
    action: Option<CloseAction>,
    state_path: &Path,
    log: &F,
) -> Result<(), String>
where
    F: Fn(&str),
{
    let mut state = match fs::read_to_string(state_path) {
        Ok(raw) => load_state_value(
            &raw,
            &format!("close behavior state {}", state_path.display()),
            log,
        ),
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => serde_json::json!({}),
        Err(error) => {
            return Err(format!(
                "Failed to read close behavior state {}: {}",
                state_path.display(),
                error
            ));
        }
    };

    let obj = state.as_object_mut().expect("load_state_value guarantees object");

    match action {
        Some(CloseAction::Tray) => {
            obj.insert(
                "closeActionOnWindowClose".to_string(),
                serde_json::json!("tray"),
            );
        }
        Some(CloseAction::Exit) => {
            obj.insert(
                "closeActionOnWindowClose".to_string(),
                serde_json::json!("exit"),
            );
        }
        None => {
            obj.remove("closeActionOnWindowClose");
        }
    }

    atomic_write_json(state_path, &state)
}

The public entry point can stay basically as‑is, but now calls the simplified helper:

pub(crate) fn write_cached_close_action<F>(
    action: Option<CloseAction>,
    packaged_root_dir: Option<&Path>,
    log: F,
) -> Result<(), String>
where
    F: Fn(&str),
{
    let Some(state_path) = crate::desktop_state::resolve_desktop_state_path(packaged_root_dir)
    else {
        log("close behavior state path is unavailable; skipping close action persistence");
        return Ok(());
    };

    write_cached_close_action_at_path(action, &state_path, &log)
}

This keeps:

  • preserving unrelated fields
  • resetting malformed state to object with only closeActionOnWindowClose
  • None removing only the close‑action field

…without custom serde types or a separate DesktopState struct.

3. Generalize the atomic write helper

You can keep your existing atomic write semantics but make it reusable and less DesktopState‑specific:

fn atomic_write_json(path: &Path, value: &impl Serialize) -> Result<(), String> {
    if let Some(parent_dir) = path.parent() {
        fs::create_dir_all(parent_dir).map_err(|error| {
            format!(
                "Failed to create desktop state directory {}: {}",
                parent_dir.display(),
                error
            )
        })?;
    }

    let serialized =
        serde_json::to_string_pretty(value).map_err(|error| {
            format!("Failed to serialize desktop state: {error}")
        })?;

    let tmp_name = format!(
        "{}.tmp",
        path.file_name()
            .map(|value| value.to_string_lossy())
            .unwrap_or_default()
    );
    let tmp_path = path.with_file_name(tmp_name);

    let mut file = fs::File::create(&tmp_path).map_err(|error| {
        format!(
            "Failed to create temporary desktop state file {}: {}",
            tmp_path.display(),
            error
        )
    })?;
    file.write_all(serialized.as_bytes())
        .and_then(|_| file.sync_all())
        .map_err(|error| {
            format!(
                "Failed to write temporary desktop state file {}: {}",
                tmp_path.display(),
                error
            )
        })?;
    fs::rename(&tmp_path, path).map_err(|error| {
        format!(
            "Failed to atomically replace desktop state file {}: {}",
            path.display(),
            error
        )
    })
}

Then you can remove atomic_write_desktop_state and save_desktop_state entirely and just use atomic_write_json.

These tweaks keep all the current semantics (including logging, malformed‑file handling, and preservation of unknown fields) but significantly reduce the amount of custom serde and layering you have to reason about.

@zouyonghe
Copy link
Copy Markdown
Member Author

@sourcery-ai review

Copy link
Copy Markdown

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 1 issue, and left some high level feedback:

  • In desktop_bridge_submit_close_prompt, a failure to persist the remembered close action (write_cached_close_action error or missing state path) causes the whole close operation to fail; consider treating persistence as best-effort (log and proceed with the selected action) so users aren’t blocked from closing/minimizing the app when the state file is unavailable or unwritable.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `desktop_bridge_submit_close_prompt`, a failure to persist the remembered close action (`write_cached_close_action` error or missing state path) causes the whole close operation to fail; consider treating persistence as best-effort (log and proceed with the selected action) so users aren’t blocked from closing/minimizing the app when the state file is unavailable or unwritable.

## Individual Comments

### Comment 1
<location path="src-tauri/src/close_behavior.rs" line_range="90" />
<code_context>
+    state.close_action
+}
+
+fn atomic_write_desktop_state(path: &Path, state: &DesktopState) -> Result<(), String> {
+    if let Some(parent_dir) = path.parent() {
+        fs::create_dir_all(parent_dir).map_err(|error| {
</code_context>
<issue_to_address>
**issue (complexity):** Consider inlining the redundant `save_desktop_state` wrapper and replacing the repeated `F: Fn(&str)` generics with a shared logger type alias to simplify the module’s control flow and signatures.

You can trim some of the generic/plumbing complexity without changing behavior or the JSON format.

### 1. Drop the `save_desktop_state` wrapper

`save_desktop_state` is a thin wrapper over `atomic_write_desktop_state` and doesn’t add semantics. Calling the atomic function directly simplifies the control flow.

```rust
fn atomic_write_desktop_state(path: &Path, state: &DesktopState) -> Result<(), String> {
    // unchanged
}

// remove this function:
// fn save_desktop_state(path: &Path, state: &DesktopState) -> Result<(), String> {
//     atomic_write_desktop_state(path, state)
// }

fn write_cached_close_action_at_path<F>(
    action: Option<CloseAction>,
    state_path: &Path,
    log: &F,
) -> Result<(), String>
where
    F: Fn(&str),
{
    let mut state = match fs::read_to_string(state_path) {
        // unchanged
    };
    state.close_action = action;

    atomic_write_desktop_state(state_path, &state)?;

    Ok(())
}
```

This removes one extra layer in the write path without affecting tests or behavior.

### 2. Replace generic `F: Fn(&str)` with a simple logger type

The generic `F: Fn(&str)` on multiple functions increases surface area. You can keep the same call sites and testability, but avoid propagating generics by using a trait object alias:

```rust
type Logger<'a> = &'a dyn Fn(&str);

fn load_desktop_state(raw: &str, log_subject: &str, log: Logger<'_>) -> DesktopState {
    match serde_json::from_str::<DesktopState>(raw) {
        Ok(state) => state,
        Err(error) => {
            log(&format!(
                "failed to parse {log_subject}: {error}. resetting state semantics"
            ));
            DesktopState::default()
        }
    }
}

pub(crate) fn read_cached_close_action(
    packaged_root_dir: Option<&Path>,
    log: Logger<'_>,
) -> Option<CloseAction> {
    let state_path = crate::desktop_state::resolve_desktop_state_path(packaged_root_dir)?;
    read_cached_close_action_at_path(&state_path, log)
}

fn read_cached_close_action_at_path(state_path: &Path, log: Logger<'_>) -> Option<CloseAction> {
    let raw = fs::read_to_string(state_path).ok()?;
    let state = load_desktop_state(&raw, "desktop close behavior state", log);
    state.close_action
}

pub(crate) fn write_cached_close_action(
    action: Option<CloseAction>,
    packaged_root_dir: Option<&Path>,
    log: Logger<'_>,
) -> Result<(), String> {
    let Some(state_path) = crate::desktop_state::resolve_desktop_state_path(packaged_root_dir)
    else {
        let message =
            "close behavior state path is unavailable; skipping close action persistence";
        log(message);
        return Err(message.to_string());
    };

    write_cached_close_action_at_path(action, &state_path, log)
}

fn write_cached_close_action_at_path(
    action: Option<CloseAction>,
    state_path: &Path,
    log: Logger<'_>,
) -> Result<(), String> {
    // body unchanged except for log type
}
```

Tests can continue to pass `&noop_log` or closures by reference, and production code still passes `append_desktop_log`, but the module no longer needs a generic parameter on each function.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

state.close_action
}

fn atomic_write_desktop_state(path: &Path, state: &DesktopState) -> Result<(), 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 redundant save_desktop_state wrapper and replacing the repeated F: Fn(&str) generics with a shared logger type alias to simplify the module’s control flow and signatures.

You can trim some of the generic/plumbing complexity without changing behavior or the JSON format.

1. Drop the save_desktop_state wrapper

save_desktop_state is a thin wrapper over atomic_write_desktop_state and doesn’t add semantics. Calling the atomic function directly simplifies the control flow.

fn atomic_write_desktop_state(path: &Path, state: &DesktopState) -> Result<(), String> {
    // unchanged
}

// remove this function:
// fn save_desktop_state(path: &Path, state: &DesktopState) -> Result<(), String> {
//     atomic_write_desktop_state(path, state)
// }

fn write_cached_close_action_at_path<F>(
    action: Option<CloseAction>,
    state_path: &Path,
    log: &F,
) -> Result<(), String>
where
    F: Fn(&str),
{
    let mut state = match fs::read_to_string(state_path) {
        // unchanged
    };
    state.close_action = action;

    atomic_write_desktop_state(state_path, &state)?;

    Ok(())
}

This removes one extra layer in the write path without affecting tests or behavior.

2. Replace generic F: Fn(&str) with a simple logger type

The generic F: Fn(&str) on multiple functions increases surface area. You can keep the same call sites and testability, but avoid propagating generics by using a trait object alias:

type Logger<'a> = &'a dyn Fn(&str);

fn load_desktop_state(raw: &str, log_subject: &str, log: Logger<'_>) -> DesktopState {
    match serde_json::from_str::<DesktopState>(raw) {
        Ok(state) => state,
        Err(error) => {
            log(&format!(
                "failed to parse {log_subject}: {error}. resetting state semantics"
            ));
            DesktopState::default()
        }
    }
}

pub(crate) fn read_cached_close_action(
    packaged_root_dir: Option<&Path>,
    log: Logger<'_>,
) -> Option<CloseAction> {
    let state_path = crate::desktop_state::resolve_desktop_state_path(packaged_root_dir)?;
    read_cached_close_action_at_path(&state_path, log)
}

fn read_cached_close_action_at_path(state_path: &Path, log: Logger<'_>) -> Option<CloseAction> {
    let raw = fs::read_to_string(state_path).ok()?;
    let state = load_desktop_state(&raw, "desktop close behavior state", log);
    state.close_action
}

pub(crate) fn write_cached_close_action(
    action: Option<CloseAction>,
    packaged_root_dir: Option<&Path>,
    log: Logger<'_>,
) -> Result<(), String> {
    let Some(state_path) = crate::desktop_state::resolve_desktop_state_path(packaged_root_dir)
    else {
        let message =
            "close behavior state path is unavailable; skipping close action persistence";
        log(message);
        return Err(message.to_string());
    };

    write_cached_close_action_at_path(action, &state_path, log)
}

fn write_cached_close_action_at_path(
    action: Option<CloseAction>,
    state_path: &Path,
    log: Logger<'_>,
) -> Result<(), String> {
    // body unchanged except for log type
}

Tests can continue to pass &noop_log or closures by reference, and production code still passes append_desktop_log, but the module no longer needs a generic parameter on each function.

@zouyonghe
Copy link
Copy Markdown
Member Author

@sourcery-ai review

Copy link
Copy Markdown

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 2 issues, and left some high level feedback:

  • In desktop_bridge_submit_close_prompt, a failure to persist the remembered close action aborts the selected close/exit behavior; consider treating persistence failures as non-fatal (log and continue) so users aren’t blocked from closing the app when the state file is unavailable.
  • The CloseRequested window event handler synchronously reads the desktop state from disk via read_cached_close_action; you may want to cache the close behavior in memory (e.g., in BackendState) and update it only when the prompt is confirmed to avoid filesystem I/O in the hot path.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `desktop_bridge_submit_close_prompt`, a failure to persist the remembered close action aborts the selected close/exit behavior; consider treating persistence failures as non-fatal (log and continue) so users aren’t blocked from closing the app when the state file is unavailable.
- The `CloseRequested` window event handler synchronously reads the desktop state from disk via `read_cached_close_action`; you may want to cache the close behavior in memory (e.g., in `BackendState`) and update it only when the prompt is confirmed to avoid filesystem I/O in the hot path.

## Individual Comments

### Comment 1
<location path="src-tauri/src/close_behavior.rs" line_range="136" />
<code_context>
+    })
+}
+
+fn save_desktop_state(path: &Path, state: &DesktopState) -> Result<(), String> {
+    atomic_write_desktop_state(path, state)
+}
</code_context>
<issue_to_address>
**issue (complexity):** Consider simplifying the close behavior module by inlining trivial wrappers, separating strict parsing from lenient reset-on-error semantics, and centralizing repeated IO error formatting.

You can shave off a bit of indirection without changing behavior by tightening a few spots:

### 1. Inline `save_desktop_state` to remove a trivial layer

`save_desktop_state` is a thin wrapper over `atomic_write_desktop_state` and doesn’t add semantics.

```rust
// Before
fn save_desktop_state(path: &Path, state: &DesktopState) -> Result<(), String> {
    atomic_write_desktop_state(path, state)
}

fn write_cached_close_action_at_path<F>(
    action: Option<CloseAction>,
    state_path: &Path,
    log: &F,
) -> Result<(), String>
where
    F: Fn(&str),
{
    // ...
    save_desktop_state(state_path, &state)?;
    Ok(())
}
```

You can call `atomic_write_desktop_state` directly and drop the extra function:

```rust
fn write_cached_close_action_at_path<F>(
    action: Option<CloseAction>,
    state_path: &Path,
    log: &F,
) -> Result<(), String>
where
    F: Fn(&str),
{
    let mut state = match fs::read_to_string(state_path) {
        Ok(raw) => load_desktop_state(
            &raw,
            &format!("close behavior state {}", state_path.display()),
            log,
        ),
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => DesktopState::default(),
        Err(error) => {
            return Err(format!(
                "Failed to read close behavior state {}: {}",
                state_path.display(),
                error
            ));
        }
    };
    state.close_action = action;

    atomic_write_desktop_state(state_path, &state)?;
    Ok(())
}
```

This keeps all behavior and tests intact while removing one level of indirection.

---

### 2. Separate strict vs lenient state parsing to make reset semantics explicit

`load_desktop_state` has the “on any error, reset state” policy baked in. That’s fine, but it’s used in both read and write paths, which obscures where the reset decision is actually made.

You can factor out a strict parser and make `load_desktop_state` a small, lenient wrapper. Call sites that want the “reset semantics” keep calling `load_desktop_state`; if you later need strict behavior, it’s available without new logic:

```rust
fn parse_desktop_state(raw: &str) -> Result<DesktopState, serde_json::Error> {
    serde_json::from_str::<DesktopState>(raw)
}

fn load_desktop_state<F>(raw: &str, log_subject: &str, log: &F) -> DesktopState
where
    F: Fn(&str),
{
    match parse_desktop_state(raw) {
        Ok(state) => state,
        Err(error) => {
            log(&format!(
                "failed to parse {log_subject}: {error}. resetting state semantics"
            ));
            DesktopState::default()
        }
    }
}
```

Call sites remain the same, but the “reset on parse error” policy is now encoded in a tiny wrapper rather than in the only parsing function, which reduces cognitive load when following the read/write flows.

---

### 3. Centralize repeated error string formatting

The repeated `"Failed to ... desktop state ..."` strings add noise and couple formatting to multiple call sites. A tiny helper keeps this DRY and makes the main logic easier to scan:

```rust
fn desktop_state_io_error(context: &str, path: &Path, error: &std::io::Error) -> String {
    format!("{context} {}: {error}", path.display())
}
```

Usage:

```rust
if let Some(parent_dir) = path.parent() {
    fs::create_dir_all(parent_dir)
        .map_err(|error| desktop_state_io_error("Failed to create desktop state directory", parent_dir, &error))?;
}

let mut file = fs::File::create(&tmp_path)
    .map_err(|error| desktop_state_io_error("Failed to create temporary desktop state file", &tmp_path, &error))?;
```

This keeps behavior and messages identical while trimming repetition.
</issue_to_address>

### Comment 2
<location path="src-tauri/src/window/close_confirm.rs" line_range="23" />
<code_context>
+    (CLOSE_CONFIRM_WINDOW_WIDTH, CLOSE_CONFIRM_WINDOW_HEIGHT)
+}
+
+fn handle_existing_window_operation_result<F>(
+    operation_result: Result<(), String>,
+    operation: &str,
</code_context>
<issue_to_address>
**issue (complexity):** Consider inlining the close-confirm window error handling into `show_close_confirm_window` to avoid a single-use helper function and keep all related logic in one place.

You can simplify this by inlining the error-handling instead of using `handle_existing_window_operation_result`, which is only used in one place and adds indirection.

You can keep the same error message format and logging behavior locally in `show_close_confirm_window`, e.g.:

```rust
pub(crate) fn show_close_confirm_window<F>(
    app_handle: &AppHandle,
    default_shell_locale: &'static str,
    log: F,
) -> Result<(), String>
where
    F: Fn(&str),
{
    if let Some(window) = app_handle.get_webview_window(CLOSE_CONFIRM_WINDOW_LABEL) {
        let log_and_err = |operation: &str, error: String| -> String {
            let message =
                format!("failed to {operation} close confirm window: {error}");
            log(&message);
            message
        };

        if let Err(e) = window.unminimize() {
            return Err(log_and_err("unminimize", e.to_string()));
        }

        if let Err(e) = window.show() {
            return Err(log_and_err("show", e.to_string()));
        }

        if let Err(e) = window.set_focus() {
            return Err(log_and_err("focus", e.to_string()));
        }

        return Ok(());
    }

    let locale = crate::shell_locale::resolve_shell_locale(
        default_shell_locale,
        crate::runtime_paths::default_packaged_root_dir(),
    );
    let url = build_close_confirm_url(locale);
    let (width, height) = close_confirm_window_size();

    WebviewWindowBuilder::new(app_handle, CLOSE_CONFIRM_WINDOW_LABEL, url)
        .title("AstrBot")
        .inner_size(width, height)
        .resizable(false)
        .maximizable(false)
        .minimizable(false)
        .visible(true)
        .center()
        .build()
        .map(|_| ())
        .map_err(|error| format!("Failed to create close confirm window: {error}"))
}
```

With this, you can drop `handle_existing_window_operation_result` and its dedicated test. The logic remains in one place, the error strings stay identical, and the control flow is easier to scan.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

})
}

fn save_desktop_state(path: &Path, state: &DesktopState) -> Result<(), 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 simplifying the close behavior module by inlining trivial wrappers, separating strict parsing from lenient reset-on-error semantics, and centralizing repeated IO error formatting.

You can shave off a bit of indirection without changing behavior by tightening a few spots:

1. Inline save_desktop_state to remove a trivial layer

save_desktop_state is a thin wrapper over atomic_write_desktop_state and doesn’t add semantics.

// Before
fn save_desktop_state(path: &Path, state: &DesktopState) -> Result<(), String> {
    atomic_write_desktop_state(path, state)
}

fn write_cached_close_action_at_path<F>(
    action: Option<CloseAction>,
    state_path: &Path,
    log: &F,
) -> Result<(), String>
where
    F: Fn(&str),
{
    // ...
    save_desktop_state(state_path, &state)?;
    Ok(())
}

You can call atomic_write_desktop_state directly and drop the extra function:

fn write_cached_close_action_at_path<F>(
    action: Option<CloseAction>,
    state_path: &Path,
    log: &F,
) -> Result<(), String>
where
    F: Fn(&str),
{
    let mut state = match fs::read_to_string(state_path) {
        Ok(raw) => load_desktop_state(
            &raw,
            &format!("close behavior state {}", state_path.display()),
            log,
        ),
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => DesktopState::default(),
        Err(error) => {
            return Err(format!(
                "Failed to read close behavior state {}: {}",
                state_path.display(),
                error
            ));
        }
    };
    state.close_action = action;

    atomic_write_desktop_state(state_path, &state)?;
    Ok(())
}

This keeps all behavior and tests intact while removing one level of indirection.


2. Separate strict vs lenient state parsing to make reset semantics explicit

load_desktop_state has the “on any error, reset state” policy baked in. That’s fine, but it’s used in both read and write paths, which obscures where the reset decision is actually made.

You can factor out a strict parser and make load_desktop_state a small, lenient wrapper. Call sites that want the “reset semantics” keep calling load_desktop_state; if you later need strict behavior, it’s available without new logic:

fn parse_desktop_state(raw: &str) -> Result<DesktopState, serde_json::Error> {
    serde_json::from_str::<DesktopState>(raw)
}

fn load_desktop_state<F>(raw: &str, log_subject: &str, log: &F) -> DesktopState
where
    F: Fn(&str),
{
    match parse_desktop_state(raw) {
        Ok(state) => state,
        Err(error) => {
            log(&format!(
                "failed to parse {log_subject}: {error}. resetting state semantics"
            ));
            DesktopState::default()
        }
    }
}

Call sites remain the same, but the “reset on parse error” policy is now encoded in a tiny wrapper rather than in the only parsing function, which reduces cognitive load when following the read/write flows.


3. Centralize repeated error string formatting

The repeated "Failed to ... desktop state ..." strings add noise and couple formatting to multiple call sites. A tiny helper keeps this DRY and makes the main logic easier to scan:

fn desktop_state_io_error(context: &str, path: &Path, error: &std::io::Error) -> String {
    format!("{context} {}: {error}", path.display())
}

Usage:

if let Some(parent_dir) = path.parent() {
    fs::create_dir_all(parent_dir)
        .map_err(|error| desktop_state_io_error("Failed to create desktop state directory", parent_dir, &error))?;
}

let mut file = fs::File::create(&tmp_path)
    .map_err(|error| desktop_state_io_error("Failed to create temporary desktop state file", &tmp_path, &error))?;

This keeps behavior and messages identical while trimming repetition.

(CLOSE_CONFIRM_WINDOW_WIDTH, CLOSE_CONFIRM_WINDOW_HEIGHT)
}

fn handle_existing_window_operation_result<F>(
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-confirm window error handling into show_close_confirm_window to avoid a single-use helper function and keep all related logic in one place.

You can simplify this by inlining the error-handling instead of using handle_existing_window_operation_result, which is only used in one place and adds indirection.

You can keep the same error message format and logging behavior locally in show_close_confirm_window, e.g.:

pub(crate) fn show_close_confirm_window<F>(
    app_handle: &AppHandle,
    default_shell_locale: &'static str,
    log: F,
) -> Result<(), String>
where
    F: Fn(&str),
{
    if let Some(window) = app_handle.get_webview_window(CLOSE_CONFIRM_WINDOW_LABEL) {
        let log_and_err = |operation: &str, error: String| -> String {
            let message =
                format!("failed to {operation} close confirm window: {error}");
            log(&message);
            message
        };

        if let Err(e) = window.unminimize() {
            return Err(log_and_err("unminimize", e.to_string()));
        }

        if let Err(e) = window.show() {
            return Err(log_and_err("show", e.to_string()));
        }

        if let Err(e) = window.set_focus() {
            return Err(log_and_err("focus", e.to_string()));
        }

        return Ok(());
    }

    let locale = crate::shell_locale::resolve_shell_locale(
        default_shell_locale,
        crate::runtime_paths::default_packaged_root_dir(),
    );
    let url = build_close_confirm_url(locale);
    let (width, height) = close_confirm_window_size();

    WebviewWindowBuilder::new(app_handle, CLOSE_CONFIRM_WINDOW_LABEL, url)
        .title("AstrBot")
        .inner_size(width, height)
        .resizable(false)
        .maximizable(false)
        .minimizable(false)
        .visible(true)
        .center()
        .build()
        .map(|_| ())
        .map_err(|error| format!("Failed to create close confirm window: {error}"))
}

With this, you can drop handle_existing_window_operation_result and its dedicated test. The logic remains in one place, the error strings stay identical, and the control flow is easier to scan.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant