diff --git a/CHANGELOG.md b/CHANGELOG.md index e2111a88..de108e18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - **A leaked `serve.token` is rotated on Windows, not just Unix.** After SBS-953, a world-readable token was replaced on Unix, but Windows still tightened the DACL and reused the same secret. The ACL is now inspected before tightening; if anyone other than the current user, SYSTEM, or Administrators can read the file, the token is replaced. Closes SBS-1043. ### Fixed +- **The detached Settings window now reopens where you left it.** Its saved size and position are restored and clamped on screen instead of being overwritten by a second frontend resize on every open. Closes #275. - **`serve --refresh-interval` now controls response caching.** Successful usage and cost responses are cached separately by provider selection for the requested TTL. A zero interval disables caching, and provider failures are retried on the next request. Fixes #273. - **Codex latest-session cost follows transcript time.** Copying or touching an older rollout no longer makes it replace a newer session in local cost summaries. Fixes #271. - **A corrupt `window_geometry.json` no longer wipes other windows' saved positions.** SBS-1024 locked the persist so two surfaces could not drop each other's keys, but a file that would not read or parse still loaded as empty defaults, and the next save replaced the whole file with only the window that just moved. Persist now refuses that write — the same fail-closed rule API keys already use — instead of rewriting siblings to an empty store. Closes SBS-1041. diff --git a/apps/desktop-tauri/src-tauri/src/main.rs b/apps/desktop-tauri/src-tauri/src/main.rs index 2b98b036..2d21aef7 100644 --- a/apps/desktop-tauri/src-tauri/src/main.rs +++ b/apps/desktop-tauri/src-tauri/src/main.rs @@ -298,6 +298,9 @@ fn main() { if shell::flyout_window::handle_window_event(window, event) { return; } + if shell::settings_window::handle_window_event(window, event) { + return; + } // Only the main window participates in blur-dismiss and close-to-hide. // The detached settings window uses normal OS close behavior. if window.label() != "main" { diff --git a/apps/desktop-tauri/src-tauri/src/shell/settings_window.rs b/apps/desktop-tauri/src-tauri/src/shell/settings_window.rs index 89ce2857..22df7170 100644 --- a/apps/desktop-tauri/src-tauri/src/shell/settings_window.rs +++ b/apps/desktop-tauri/src-tauri/src/shell/settings_window.rs @@ -3,6 +3,8 @@ use tauri::{Emitter, Manager, PhysicalPosition, WebviewUrl}; +use crate::surface::SurfaceMode; + const SETTINGS_LABEL: &str = "settings"; const SETTINGS_WIDTH: f64 = 720.0; const SETTINGS_HEIGHT: f64 = 580.0; @@ -22,9 +24,17 @@ pub fn open_or_focus(app: &tauri::AppHandle, tab: &str) -> Result<(), String> { let url = WebviewUrl::App(format!("index.html?window=settings&tab={tab}").into()); + let stored = crate::geometry_store::load(SurfaceMode::Settings); + let width = stored + .and_then(|geometry| geometry.width) + .map_or(SETTINGS_WIDTH, f64::from); + let height = stored + .and_then(|geometry| geometry.height) + .map_or(SETTINGS_HEIGHT, f64::from); + let win = tauri::WebviewWindowBuilder::new(app, SETTINGS_LABEL, url) .title("Ceiling Settings") - .inner_size(SETTINGS_WIDTH, SETTINGS_HEIGHT) + .inner_size(width, height) .decorations(false) .shadow(false) .theme(Some(tauri::Theme::Dark)) @@ -35,22 +45,47 @@ pub fn open_or_focus(app: &tauri::AppHandle, tab: &str) -> Result<(), String> { // Force DWM caption to dark; keep WS_THICKFRAME since window is resizable super::dwm::force_dark_caption_resizable(&win); - // Manually center: Tauri's .center() is unreliable on Windows when - // called from async commands. Compute position from the primary monitor. - if let Ok(Some(monitor)) = win.primary_monitor() { - let pos = monitor.position(); - let size = monitor.size(); - let scale = win.scale_factor().unwrap_or(1.0); - let win_w = (SETTINGS_WIDTH * scale) as i32; - let win_h = (SETTINGS_HEIGHT * scale) as i32; - let x = pos.x + (size.width as i32 - win_w) / 2; - let y = pos.y + (size.height as i32 - win_h) / 2; + if let Some((x, y)) = super::position::default_surface_position(app, SurfaceMode::Settings) { let _ = win.set_position(PhysicalPosition::new(x, y)); } Ok(()) } +/// Persist move and resize events for the detached Settings window. +pub fn handle_window_event(window: &tauri::Window, event: &tauri::WindowEvent) -> bool { + if window.label() != SETTINGS_LABEL { + return false; + } + + if matches!( + event, + tauri::WindowEvent::Moved(_) | tauri::WindowEvent::Resized(_) + ) && !window.is_maximized().unwrap_or(false) + && !window.is_minimized().unwrap_or(false) + && let Ok(position) = window.outer_position() + { + let scale = window.scale_factor().unwrap_or(1.0).max(1.0); + let logical_size = window.outer_size().ok().map(|size| { + ( + (size.width as f64 / scale).round().max(1.0) as u32, + (size.height as f64 / scale).round().max(1.0) as u32, + ) + }); + crate::geometry_store::save( + SurfaceMode::Settings, + crate::geometry_store::StoredGeometry { + x: position.x, + y: position.y, + width: logical_size.map(|size| size.0), + height: logical_size.map(|size| size.1), + }, + ); + } + + true +} + /// Dismiss Settings without exiting CodexBar. /// /// The detached Settings window is hidden instead of closed so Tauri's diff --git a/apps/desktop-tauri/src-tauri/src/shell/tests.rs b/apps/desktop-tauri/src-tauri/src/shell/tests.rs index 43609da6..2daeecb2 100644 --- a/apps/desktop-tauri/src-tauri/src/shell/tests.rs +++ b/apps/desktop-tauri/src-tauri/src/shell/tests.rs @@ -895,6 +895,51 @@ fn remembered_popout_position_clamps_using_stored_size() { assert_eq!(position, Some((392, 292))); } +#[test] +fn remembered_settings_position_clamps_using_stored_size() { + let monitor = MonitorPlacement { + bounds: Rect { + x: 0, + y: 0, + width: 1000, + height: 800, + }, + work_area: Rect { + x: 0, + y: 0, + width: 1000, + height: 760, + }, + scale_factor: 1.0, + }; + let stored = crate::geometry_store::StoredGeometry { + x: 900, + y: 700, + width: Some(720), + height: Some(580), + }; + + let position = + remembered_surface_position_with_monitors(SurfaceMode::Settings, stored, &[monitor], None); + + assert_eq!(position, Some((272, 172))); +} + +#[test] +fn remembered_panel_size_uses_stored_settings_size() { + let stored = crate::geometry_store::StoredGeometry { + x: 0, + y: 0, + width: Some(840), + height: Some(680), + }; + + let size = remembered_panel_size(SurfaceMode::Settings, stored); + + assert_eq!(size.width, 840); + assert_eq!(size.height, 680); +} + #[test] fn remembered_panel_size_uses_stored_popout_size() { let stored = crate::geometry_store::StoredGeometry { diff --git a/apps/desktop-tauri/src/surfaces/Settings.test.ts b/apps/desktop-tauri/src/surfaces/Settings.test.ts index dec41d93..eef01d99 100644 --- a/apps/desktop-tauri/src/surfaces/Settings.test.ts +++ b/apps/desktop-tauri/src/surfaces/Settings.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { TAB_META } from "./Settings"; +import settingsSource from "./Settings.tsx?raw"; /** Live Settings shell tabs, in render order. SBS-872. */ const LIVE_SETTINGS_TABS = [ @@ -27,4 +28,9 @@ describe("Settings navigation", () => { // to the Rust allowlist and the SettingsTabId union. expect(TAB_META.map((tab) => tab.id)).toEqual([...LIVE_SETTINGS_TABS]); }); + + it("does not overwrite restored window geometry on mount", () => { + expect(settingsSource).not.toContain("setSize("); + expect(settingsSource).not.toContain("setPosition("); + }); }); diff --git a/apps/desktop-tauri/src/surfaces/Settings.tsx b/apps/desktop-tauri/src/surfaces/Settings.tsx index 5974ec77..10dc6ac7 100644 --- a/apps/desktop-tauri/src/surfaces/Settings.tsx +++ b/apps/desktop-tauri/src/surfaces/Settings.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState, type ReactElement, type ReactNode } from "react"; -import { getCurrentWindow, LogicalPosition, LogicalSize } from "@tauri-apps/api/window"; +import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import type { BootstrapState, @@ -11,7 +11,7 @@ import { useSurfaceTarget } from "../hooks/useSurfaceMode"; import { useLocale } from "../hooks/useLocale"; import { useTabListKeyboard } from "../hooks/useTabListKeyboard"; import type { LocaleKey } from "../i18n/keys"; -import { closeSettingsWindow, getWorkAreaRect, setSurfaceMode } from "../lib/tauri"; +import { closeSettingsWindow, setSurfaceMode } from "../lib/tauri"; import GeneralTab from "./settings/tabs/GeneralTab"; import DisplayTab from "./settings/tabs/DisplayTab"; import AdvancedTab from "./settings/tabs/AdvancedTab"; @@ -117,41 +117,6 @@ function isSettingsTab(value: string): value is SettingsTab { return TAB_META.some((t) => t.id === value); } -const SETTINGS_WINDOW_HEIGHT = 580; -const SETTINGS_WINDOW_WIDTH = 600; - -async function applySettingsWindowSize() { - const workArea = await getWorkAreaRect().catch(() => null); - const screenWidth = window.screen.availWidth || window.innerWidth || SETTINGS_WINDOW_WIDTH; - const screenHeight = window.screen.availHeight || window.innerHeight || SETTINGS_WINDOW_HEIGHT; - const maxWidth = Math.min(workArea?.width ?? screenWidth, screenWidth); - const maxHeight = Math.min(workArea?.height ?? screenHeight, screenHeight); - const width = Math.max( - 360, - Math.min(SETTINGS_WINDOW_WIDTH, maxWidth - 16), - ); - const height = Math.max( - 360, - Math.min(SETTINGS_WINDOW_HEIGHT, maxHeight - 16), - ); - const win = getCurrentWindow(); - await win.setSize(new LogicalSize(width, height)).catch(() => {}); - const screenOrigin = window.screen as Screen & { - availLeft?: number; - availTop?: number; - }; - const left = screenOrigin.availLeft ?? workArea?.x ?? 0; - const top = screenOrigin.availTop ?? workArea?.y ?? 0; - await win - .setPosition( - new LogicalPosition( - left + Math.max(8, Math.round((screenWidth - width) / 2)), - top + Math.max(8, Math.round((screenHeight - height) / 2)), - ), - ) - .catch(() => {}); -} - export default function Settings({ state, initialTab: propTab }: { state: BootstrapState; initialTab?: string }) { const { settings, saving, error, update } = useSettings(state.settings); const { t } = useLocale(); @@ -164,10 +129,6 @@ export default function Settings({ state, initialTab: propTab }: { state: Bootst : "general"; const [activeTab, setActiveTab] = useState(initialTab); - useEffect(() => { - void applySettingsWindowSize(); - }, []); - // Respond to prop-driven tab changes (detached window re-focus events). useEffect(() => { if (propTab && isSettingsTab(propTab)) {