diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c26c8e1..2d9cbad 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,7 +9,7 @@ on: workflow_dispatch: permissions: - contents: write + contents: read jobs: build-windows: @@ -17,7 +17,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup Node uses: actions/setup-node@v6 @@ -41,6 +41,15 @@ jobs: throw "Tag ${{ github.ref_name }} does not match package version $expected" } + - name: Validate distribution license + if: startsWith(github.ref, 'refs/tags/') + shell: pwsh + run: | + $license = Get-Content LICENSE -Raw + if ($license -match "not been finalized|Do not redistribute") { + throw "Public releases are blocked until LICENSE contains final distribution terms" + } + - name: TypeScript check run: npm run check @@ -86,14 +95,27 @@ jobs: src-tauri/target/release/bundle/**/*.msi src-tauri/target/release/bundle/**/*.sha256 + publish-release: + if: startsWith(github.ref, 'refs/tags/') + needs: build-windows + runs-on: windows-latest + permissions: + contents: write + + steps: + - name: Download Windows bundle + uses: actions/download-artifact@v8 + with: + name: WindowAutoLayout-Windows + path: release-artifacts + - name: Publish tagged release - if: startsWith(github.ref, 'refs/tags/') shell: pwsh env: GH_TOKEN: ${{ github.token }} run: | $tag = "${{ github.ref_name }}" - $files = Get-ChildItem "src-tauri/target/release/bundle" -Recurse -File | + $files = Get-ChildItem "release-artifacts" -Recurse -File | Where-Object { $_.Extension -in ".exe", ".msi", ".sha256" } | ForEach-Object { $_.FullName } diff --git a/.gitignore b/.gitignore index f87ad2f..b4ad20e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ lerna-debug.log* node_modules dist dist-ssr +.tools/ src-tauri/target src-tauri/gen *.pdb diff --git a/CHANGELOG.md b/CHANGELOG.md index ee9f1bc..41eb6b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,86 @@ # Changelog +## 0.1.28 + +- Added a live tray command named `Restore windows now`, a checked automatic-restore status item, and an optional restore-on-left-click tray action. +- Added persistent restore and automatic-state controls to every app view, with tray and window state kept in sync while a restore is running. +- Added separate settings for Show Desktop recovery, post-game recovery, launching closed apps, and tray left-click behavior. +- Saved display resolution, work area, and scale metadata with each captured window so layouts adapt cleanly to resolution or taskbar changes while staying exact on unchanged displays. +- Replaced volatile `DISPLAY1` and `DISPLAY2` targets with hardware-backed monitor identities, including a guarded migration for layouts saved before Windows reordered display names. +- Added bounded placement verification with repeated geometry checks, one-pixel tolerance, and clear expected-versus-actual failures when another app refuses its saved bounds. +- Waited past undersized launch and update splash windows before placing the real app surface, preventing late startup windows from reclaiming old bounds. +- Kept already-running minimized or tray-hidden apps recoverable when launching closed apps is disabled, and made startup restore honor that preference. +- Made restore and capture results independent of optional log-file writes, tightened imported display metadata, and made layout-lock state changes transactional. +- Kept the WebView unloaded in tray-only mode and destroyed it when the interface closes, leaving one low-priority native process with no idle polling. +- Updated the interface, newest-first activity log, documentation screenshot, frontend dependencies, and release checks; frontend and Rust advisory scans report no known vulnerabilities. + +## 0.1.26 + +- Limited OBS tray activation to truly hidden windows so a minimized OBS window is never toggled back into the tray. +- Added an ordered OBS repaint with a one-shot client-surface check and one non-minimizing retry when the title bar appears but the Qt interface is still blank. +- Prevented restore-generated window events from feeding back into the event-driven layout lock and stopped generic window restores from minimizing windows as a side effect. + +## 0.1.25 + +- Made background restores show windows asynchronously without activating them or waiting on another app's UI thread. +- Added a shared renderer refresh for restored windows, including windows that already match their saved rectangle after leaving the tray. +- Replaced synchronous forced paints with a one-shot resize and queued full-surface repaint, preventing blank client areas without adding idle work. +- Routed minimized OBS windows through OBS's native Qt tray restore handler before using the generic Win32 fallback. +- Added a bounded, state-verified OBS minimize/restore recovery so Qt rebuilds and presents its docks instead of leaving a white client area. +- Blocked OBS presentation recovery while a game or fullscreen app is foreground and restored the previous window only when focus stayed on OBS during the pulse. + +## 0.1.24 + +- Made OpenLaunchDeck restores use its supported `--show` single-instance command, so a startup tray process exposes the existing window without creating a duplicate. +- Removed OpenLaunchDeck background-only arguments during a layout restore while preserving unrelated custom launch arguments. +- Added a no-focus Qt relayout and full child repaint after OBS is moved, returning it to the exact saved rectangle before the restore completes. + +## 0.1.23 + +- Serialized restore requests so startup, tray, dashboard, and event-lock restores cannot move the same windows at the same time. +- Reported restores paused by a foreground game as paused instead of success, and kept the event guard ready to retry after the game exits. +- Preserved maximized state when capturing layouts and constrained oversized saved bounds after monitor resolution changes. +- Made nearest-monitor fallback compare the saved profile canvas against every connected display. +- Added safe config import parsing, schema normalization, duplicate-ID repair, regex validation, and bounds for imported delays and rectangles. +- Made a second launch bring the existing tray app forward and made the close-button setting exit cleanly when tray hiding is disabled. +- Removed settings that did not affect runtime behavior and made fullscreen game protection unconditional. +- Isolated page scrolling inside the app shell, reset scroll position between views, and added an interface recovery screen. + +## 0.1.22 + +- Removed the global hotkey and disabled Tauri raw mouse and keyboard device events, so WindowAutoLayout no longer registers for background input. +- Replaced the five-second layout-lock poller with a blocking Windows event guard for Show Desktop and game-to-desktop transitions. +- Prevented automatic and startup restores from forcing window focus or synthesizing keyboard input. +- Added mid-restore game checks so background work stops if a game or fullscreen app becomes active. +- Coalesced restore events through one low-priority worker instead of creating a thread for every shell event. +- Tightened the Tauri content security policy and removed the unused opener plugin. +- Kept Microsoft Store app profiles working across versioned WindowsApps folder and executable-name changes. +- Redesigned the dashboard, layout editor, app editor, settings, profiles, and logs around a denser desktop control surface. + +## 0.1.21 + +- Added an event-driven desktop guard that restores already-running profile windows after Show Desktop or after leaving a latency-sensitive game. +- Kept the guard idle while games such as Valorant are foreground and restored without launching missing apps. +- Left the installed app running after local installs instead of closing the smoke-launch process. +- Prevented duplicate tray processes with a named single-instance guard. +- Accepted BOM-marked config files so a valid config is not replaced with defaults. +- Kept startup registration pointed at `WindowAutoLayout.exe --startup-restore` so the tray app comes back on Windows login. + +## 0.1.20 + +- Started the app hidden during startup restore so no WindowAutoLayout window is created on-screen before tray mode. +- Lowered WindowAutoLayout process priority and the layout-lock worker thread priority so the game scheduler wins. +- Raised layout-lock polling to a 5-30 second range and paused it for 60 seconds while Valorant or another latency-sensitive game is foreground. +- Treated tray-only OpenLaunchDeck as a clean no-op instead of relaunching it or marking the restore partial. +- Kept the exact-placement and low-CPU restore fixes from 0.1.19. + +## 0.1.19 + +- Restored normal windows after unmaximizing before the final move, so Windows does not undo the saved bounds. +- Rechecked restored windows and retried the saved rectangle once if Windows reported drift after the move. +- Kept stale or off-screen saved layouts on the target monitor while preserving exact saved bounds when they still touch the monitor. +- Made Capture Current Layout use the window's real monitor, falling back to its center point only when Windows does not report a monitor. + ## 0.1.18 - Cut layout lock CPU use by only running full restore work when managed windows actually change. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7439cf2..17b868b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,7 +34,8 @@ powershell -ExecutionPolicy Bypass -File scripts\install-current.ps1 -SkipBuild - Keep Win32 calls inside the Rust backend. - Avoid permanent busy loops. -- Clamp polling intervals for lock/restore behavior. +- Prefer blocking Windows events over recurring polling for background behavior. +- Do not add global mouse hooks, keyboard hooks, raw-input listeners, or synthetic input. - Treat hidden tray windows, minimized windows, and delayed windows as normal cases. - Return clear errors to the UI instead of panicking. - Keep matching conservative enough to avoid tool windows and helper windows. diff --git a/README.md b/README.md index b889df9..7dfa16a 100644 --- a/README.md +++ b/README.md @@ -22,18 +22,23 @@ Streaming and gaming setups usually need the same windows in the same places: OBS on one side, Discord on another, a browser where chat or dashboards live, and everything ready after a reboot. Windows can remember some things, but tray apps, delayed startup windows, monitor offsets, and Show Desktop can still scramble a setup. -WindowAutoLayout saves workspace profiles and restores them from the app, tray menu, startup, or a hotkey. It is built for the everyday Windows workflow where pressing one button should bring the setup back, whether the apps are already open, minimized, hidden in the tray, or fully closed. +WindowAutoLayout saves workspace profiles and restores them from the app, tray menu, or Windows startup. It is built for the everyday Windows workflow where pressing one button should bring the setup back, whether the apps are already open, minimized, hidden in the tray, or fully closed. ## What It Does -- Saves app layouts per monitor using monitor-relative coordinates. +- Saves app layouts against a hardware-backed monitor identity using monitor-relative physical-pixel coordinates plus captured display and work-area metadata. - Captures the visible windows on a selected monitor into the active profile. - Detects top-level windows with title, class name, process name, PID, bounds, visibility, minimized state, and executable path when Windows exposes it. -- Restores a profile by launching missing apps, waiting for real matching windows, then moving and resizing them with Win32 APIs. +- Restores a profile by launching missing apps, waiting past splash windows for the real matching surface, then moving and resizing it with Win32 APIs. - Pulls minimized and hidden tray windows forward before treating an app as missing. -- Handles OBS in the tray by asking OBS through its tray icon path, then waiting for the real OBS window to repaint before applying the saved layout. -- Keeps a selected profile locked while the lock is on, so Show Desktop, accidental minimize, and accidental moves get snapped back. -- Supports editable app presets, multiple profiles, startup restore, tray restore, logs, JSON import/export, and a global hotkey. +- Handles OBS in the tray through its Qt tray path, applies the saved layout, then runs a bounded presentation recovery so the real interface is painted before restore finishes. +- Uses OpenLaunchDeck's `--show` single-instance handoff to restore its existing startup tray process instead of launching a duplicate. +- Refreshes restored window surfaces without activating them, so apps do not remain blank until clicked. +- Keeps a selected profile protected with an event-driven lock that reacts to Show Desktop and game-to-desktop transitions without a polling loop. +- Reopens the existing tray instance when WindowAutoLayout is launched a second time instead of creating a duplicate process. +- Does not install global mouse hooks, keyboard hooks, raw-input listeners, or system-wide hotkeys. +- Keeps a live `Restore windows now` command and checked automatic-restore state in the tray menu. +- Supports editable app presets, multiple profiles, startup restore, configurable tray left-click behavior, logs, and JSON import/export. - Stores config and logs locally. No telemetry, accounts, analytics, or background network calls. ## Download @@ -58,10 +63,23 @@ After install, WindowAutoLayout lives under the current Windows user profile and 4. Open and arrange the apps you want in the profile. 5. On Dashboard, pick the capture monitor and press Capture current layout. 6. Fine-tune app matching on Apps or individual windows on Layout if needed. -7. Press Restore from Dashboard, the tray menu, startup restore, or `Ctrl+Alt+L`. +7. Press Restore windows now from any app view or the tray menu. +8. Turn on Automatic restore if Show Desktop or returning from a game should recover that profile. For a complete setup walkthrough, see [docs/usage.md](docs/usage.md). +## Tray Controls + +Right-click the tray icon for the current runtime controls: + +- `Restore windows now` runs the selected startup/default profile immediately. +- `Automatic restore: On/Off` shows the real state and toggles recovery for the selected automatic profile. +- `Open WindowAutoLayout` opens the existing single app instance. +- `Open activity log` opens the local restore log. +- `Exit` stops the tray process. + +Settings can make a normal left-click either open WindowAutoLayout or restore the layout immediately. While a restore is active, the tray label, tooltip, and app header show that state and block duplicate restore requests. + ## OBS And Tray Apps OBS can stay running in the system tray with Replay Buffer on. For the OBS app entry, keep these enabled: @@ -69,13 +87,23 @@ OBS can stay running in the system tray with Replay Buffer on. For the OBS app e - `Pull hidden/tray windows` - `Wake running tray apps` -When OBS is fully hidden in the tray, Windows may report no normal main window. WindowAutoLayout handles that by sending OBS the same tray activation path a manual tray click uses, waiting for OBS to show and repaint, then moving it into the saved layout. That avoids starting a duplicate OBS process and avoids moving the window while OBS is still a blank shell. +When OBS is fully hidden in the tray, Windows may report no normal main window. WindowAutoLayout handles that by sending OBS the same Qt tray activation path a manual tray click uses, waiting for the main window, applying the saved rectangle, and running a bounded state-verified presentation recovery. That avoids starting a duplicate OBS process and prevents the main frame from being left as a blank shell. + +Background restores never fake a click or leave each restored app focused. The generic path uses Windows' non-activating show behavior, applies the saved rectangle, and queues a one-shot surface repaint. OBS uses the bounded recovery above and returns to the previous window when focus did not change. No repaint timer runs after the restore completes. More details and recovery checks are in [docs/troubleshooting.md](docs/troubleshooting.md). -## Layout Lock +## Automatic Restore + +The automatic control arms a Windows shell-event guard for the selected profile. Show Desktop recovery and post-game recovery can be toggled separately. The guard does not run a timer or repeatedly inspect every window. Background restores never launch missing apps, never force focus, and stop immediately if a game or fullscreen app becomes active again. + +Manual and startup restores can launch closed apps when `Launch apps that are closed` is enabled. Turning that setting off still allows an already-running minimized or tray-hidden app to be recovered. + +## Game And Input Safety + +WindowAutoLayout does not register global hotkeys or low-level mouse/keyboard hooks. Tauri's optional raw device-event stream is explicitly filtered out, the process and restore worker run below normal priority, and the background guard blocks on Windows accessibility events instead of polling. Window movement uses normal Win32 window-management calls and does not synthesize keyboard or mouse input. -The lock button watches the selected profile and only runs a full restore when one of its managed windows changes position, visibility, minimized state, or disappears. It is meant for live setups where the layout should recover after Show Desktop, accidental minimize, dragging, or another app stealing placement without burning CPU while everything is already stable. The lock interval is clamped to a low-impact range, and fullscreen non-profile apps get a longer pause so games are not polled hard. +The WebView interface is created only while the app window is open. Closing to tray destroys the WebView process tree and leaves the small native tray process running, so normal tray use does not carry a hidden browser runtime through a game. ## Startup Restore @@ -85,7 +113,7 @@ Startup restore uses: "" --startup-restore ``` -The app can start minimized to tray, wait for configured startup delay seconds, restore the default startup profile, launch missing apps, then keep the layout locked if that option is enabled. +The app can start minimized to tray, wait for the configured startup delay, restore the default profile, optionally launch closed apps, and keep automatic restore armed if it is enabled. ## Matching Rules @@ -103,6 +131,7 @@ Useful notes: - OBS may take longer while plugins and docks load. - Discord uses multiple Electron processes and may appear late. +- Startup and updater splash windows are ignored until a launch-ready surface appears. - Steam can show update or login windows before the main window. - Browser titles change with tabs. - Elevated apps may block moves from a normal, non-elevated WindowAutoLayout process. @@ -131,7 +160,7 @@ npm run check cd src-tauri cargo fmt --check cargo check -cargo test +cargo test --all-targets cargo clippy --all-targets -- -D warnings cd .. npm audit --audit-level=moderate @@ -168,9 +197,9 @@ scripts/ Local install/update helper Every version tag matching `v*` builds the Windows bundles and publishes installer assets to GitHub Releases. ```powershell -git tag v0.1.18 +git tag v0.1.28 git push origin main -git push origin v0.1.18 +git push origin v0.1.28 ``` The workflow validates that the tag matches `package.json`, runs TypeScript, frontend audit, and Rust checks, builds the Tauri bundle, generates checksums, uploads CI artifacts, and publishes the release. diff --git a/docs/assets/windowautolayout-apps.png b/docs/assets/windowautolayout-apps.png index 680512a..0c240bb 100644 Binary files a/docs/assets/windowautolayout-apps.png and b/docs/assets/windowautolayout-apps.png differ diff --git a/docs/manual-test.md b/docs/manual-test.md index cde0423..d4fe324 100644 --- a/docs/manual-test.md +++ b/docs/manual-test.md @@ -8,12 +8,20 @@ Use these checks for release smoke testing on Windows 11. For restore behavior, - Confirm the installed exe exists under `%LOCALAPPDATA%\WindowAutoLayout`. - Confirm the installed file version matches the release version. - Start the installed app once and confirm it stays alive. +- Start the installed app again and confirm the existing window opens without creating a second process. - Close the visible window and confirm the tray icon remains when close-to-tray is enabled. +- After closing to tray, confirm WindowAutoLayout has no WebView2 child process and remains near zero CPU while idle. +- Reopen the app and close it again to confirm the WebView lifecycle works repeatedly. +- Disable close-to-tray, close the window, and confirm the process exits instead of becoming unreachable. +- Right-click the tray icon and confirm `Restore windows now` is first and `Automatic restore: On/Off` matches the app header. +- Change the tray left-click setting and confirm both Open WindowAutoLayout and Restore windows now work. ## Monitor And Window Detection - Start WindowAutoLayout and confirm every connected monitor appears with bounds, resolution, primary flag, and scale. - Put a monitor to the left of primary and confirm negative X coordinates show correctly. +- Confirm a 4K display and a differently scaled primary display report their physical resolutions and Windows scale values correctly. +- Confirm monitor targets are stored as hardware identities and remain on the same physical panel after Windows display-number reordering. - Disconnect the target monitor and confirm restore returns a missing-monitor result when fallback is disabled. - Reconnect the monitor and refresh. - Confirm window detection shows title, class name, process name, PID, bounds, and visible/minimized state. @@ -28,8 +36,16 @@ Use these checks for release smoke testing on Windows 11. For restore behavior, - Select each real window in Layout and save it when testing per-window capture. - Move both windows away from their saved positions. - Restore the profile from Dashboard. +- Compare the final DWM frame bounds to the saved physical-pixel rectangle and confirm every normal window is within one pixel. - Minimize one window and restore again. -- Maximize one window, save the layout, and restore again. +- Turn off `Launch apps that are closed`, hide a running app in the tray, and confirm Restore still recovers it without starting a duplicate process. +- Restore several minimized or tray-hidden apps without clicking them afterward. +- Confirm every restored client area paints fully and remains usable without taskbar activation. +- Confirm a background or layout-lock restore returns to the original foreground window and never runs while a game or fullscreen app is foreground. +- Maximize one window, capture it, and confirm its saved window state remains maximized after restore. +- Change monitor resolution and confirm the captured layout scales to the new physical display bounds. +- Move or resize the taskbar without changing resolution and confirm work-area layouts follow the new usable area. +- With three displays connected, confirm nearest fallback chooses the display closest to the saved profile size. - Enable `Move all matching windows` for a browser and verify multiple matching windows move when intended. - Check Logs for applied layout counts. @@ -46,6 +62,14 @@ This is the important streaming test. - Confirm OBS does not show a blank or white window. - Confirm no `OBS is already running` prompt appears. - Confirm the OBS process count stays at one. + +## OpenLaunchDeck Startup Tray Restore + +- Enable OpenLaunchDeck launch at startup and confirm its Run entry uses `--background`. +- Leave one OpenLaunchDeck process running with its main window hidden in the tray. +- Restore a profile containing OpenLaunchDeck. +- Confirm the existing OpenLaunchDeck window appears and is moved to the saved rectangle. +- Confirm there is still exactly one long-running `OpenLaunchDeck.exe` process after the `--show` helper exits. - Confirm Logs include `Asked tray icon to restore hidden running window` or another restore success line. ## Launch Flow @@ -54,10 +78,13 @@ This is the important streaming test. - Close one app, leave the other open, and restore. - Close both apps and restore. - Confirm delayed windows continue to be detected until the timeout. +- Confirm a small launch or updater splash is ignored until the real main window appears, then verify the settled DWM frame still matches after 30 seconds. - Confirm app launch failures appear in Logs. - Confirm running tray apps are woken before a duplicate launch is attempted. +- Turn off `Launch apps that are closed`, close an app fully, and confirm it is reported missing rather than launched. +- Start with the same setting off and confirm startup restore honors it. -## Startup, Tray, And Hotkey +## Startup, Tray, And Input Safety - Enable startup restore and confirm the HKCU Run entry is created. - Confirm the Run entry includes `--startup-restore`. @@ -65,18 +92,38 @@ This is the important streaming test. - Confirm the startup delay is honored. - Confirm the default startup profile restores. - Close the main window and confirm it hides to tray. -- Restore from the tray menu. -- Press `Ctrl+Alt+L` and confirm the selected/default profile restores. -- Disable the hotkey and confirm it no longer triggers. +- Restore from the tray menu and confirm its label temporarily changes to `Restoring windows...`. +- Confirm duplicate tray and app restore controls are disabled until that restore finishes. +- Toggle automatic restore from the tray and confirm the app header updates without reopening the app. +- Confirm `Ctrl+Alt+L` is not reserved by WindowAutoLayout. +- Confirm the installed process stays at Below normal priority. +- Confirm there is only one WindowAutoLayout tray process. -## Layout Lock +## Automatic Restore -- Enable layout lock for the streaming profile. -- Move a target window during the lock and confirm it snaps back. +- Enable automatic restore for the streaming profile. - Press Show Desktop and confirm the profile is restored while lock is active. -- Minimize OBS during the lock and confirm it is pulled back when OBS settings allow it. -- Confirm the lock stops after its duration or when disabled manually. -- Watch CPU usage briefly and confirm the lock is not causing heavy idle load. +- Leave a fullscreen app and confirm the profile is restored after returning to the desktop. +- Return to the fullscreen app immediately and confirm the pending restore is cancelled. +- Confirm a closed profile app is not launched by an automatic event restore. +- Confirm disabling the lock stops event restores. +- Sample idle CPU and confirm there is no recurring five-second activity. +- Trigger restore twice quickly and confirm only one restore runs. +- Start a restore while a fullscreen game is foreground and confirm the result says Paused rather than Success. + +## Interface + +- Check Dashboard, Profiles, Layout, Apps, Logs, and Settings at 1180x760 and the supported minimum 980x640. +- Confirm every control remains visible, text does not overlap, and each page scrolls inside the workspace rather than moving the app shell. +- Overlap saved windows on the Layout canvas and confirm the Selected app menu can still select every app. +- Confirm keyboard focus rings appear on buttons, fields, tabs, selects, and toggles. + +## Config Import + +- Import a valid exported config and confirm it remains editable before saving. +- Import malformed JSON and confirm the current config stays unchanged. +- Import duplicate or empty profile/app IDs and confirm they are repaired without losing entries. +- Import extreme delay and rectangle values and confirm they are constrained to safe bounds. ## Failure Cases @@ -91,7 +138,10 @@ This is the important streaming test. - Run `npm run check`. - Run `cargo fmt --check` in `src-tauri`. -- Run `cargo test` in `src-tauri`. +- Run `cargo test --all-targets` in `src-tauri`. +- Run `cargo clippy --all-targets -- -D warnings` in `src-tauri`. +- Run `npm audit --audit-level=moderate`. +- Run `cargo audit` when a project-local copy is available. - Run `npm run desktop:build`. - Install the built app. - Run the installed-app OBS tray test. diff --git a/docs/release.md b/docs/release.md index b868919..a1b5b8e 100644 --- a/docs/release.md +++ b/docs/release.md @@ -18,7 +18,7 @@ Update these together for every release: The tag must match the package version: ```text -package.json version 0.1.18 -> tag v0.1.18 +package.json version 0.1.28 -> tag v0.1.28 ``` ## Local Finish Pass @@ -30,13 +30,15 @@ npm run check cd src-tauri cargo fmt --check cargo check -cargo test +cargo test --all-targets cargo clippy --all-targets -- -D warnings cd .. npm audit --audit-level=moderate npm run desktop:build ``` +Run a locked Rust advisory scan with a project-local `cargo-audit` binary when available. Warnings for dependencies that are not in the Windows target graph should be documented separately from actual vulnerabilities. + Install or update this PC from the freshly built bundle: ```powershell @@ -52,6 +54,8 @@ The install helper verifies: - installed app version - launch smoke, unless `-NoLaunchSmoke` is used +After the smoke launch, confirm tray-only mode has no WindowAutoLayout-owned WebView2 child, sample idle CPU, reopen and close the interface once, and verify the native process remains available from the tray. + ## Manual Smoke Before publishing a release, run the manual cases in [manual-test.md](manual-test.md). For OBS-related changes, do not skip the installed-app OBS tray test. @@ -68,17 +72,27 @@ Minimum OBS release check: ## Publish +Before creating a public tag: + +- replace the pending `LICENSE` text with the final distribution terms +- configure Authenticode signing for both Windows installers +- verify the signing certificate and timestamp service in CI +- add a signed update channel before advertising automatic updates + +The workflow intentionally rejects release tags while `LICENSE` still contains the pending no-redistribution text. Do not publish unsigned installers as a production release. + Commit the release changes, then push main and the version tag: ```powershell git push origin main -git tag v0.1.18 -git push origin v0.1.18 +git tag v0.1.28 +git push origin v0.1.28 ``` The GitHub workflow: - validates the tag matches `package.json` +- blocks distribution while the repository license is still pending - runs TypeScript checks - runs the frontend dependency audit - runs Rust format, check, test, and clippy passes @@ -101,7 +115,7 @@ WindowAutoLayout__x64_en-US.msi.sha256 Verify the published release: ```powershell -gh release view v0.1.18 --repo Riqqqque/WindowAutoLayout --json url,tagName,name,isDraft,isPrerelease,assets +gh release view v0.1.28 --repo Riqqqque/WindowAutoLayout --json url,tagName,name,isDraft,isPrerelease,assets ``` ## Troubleshooting Release Builds diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 64ee06c..4957ed3 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -24,9 +24,16 @@ What WindowAutoLayout should do: 1. Detect that `obs64.exe` is already running. 2. Avoid launching a duplicate OBS process. -3. Ask OBS through its tray icon path if OBS has no normal main window. +3. Ask OBS through its tray icon path when the main window is hidden or minimized. 4. Wait for the real OBS window to show and repaint. 5. Apply the saved layout. +6. Queue a no-focus surface relayout and child repaint. + +The final no-focus refresh is shared by all restored apps. It prevents stale dock cuts and blank client areas without adding any background repaint loop. + +Minimized OBS windows use the Qt tray restore handler first. Directly changing the Win32 show state can expose an OBS frame before Qt has rebuilt its docks, which is the white-client symptom this path avoids. + +OBS then receives one bounded presentation recovery. WindowAutoLayout verifies that Windows actually reached the minimized state, restores the window, verifies that it is no longer minimized, and gives Qt time to present the rebuilt interface. The recovery is blocked while a game or fullscreen app is foreground, and the previous window is restored only when focus stayed on OBS during the pulse. Good log lines look like: @@ -38,6 +45,18 @@ Restore finished with status Success If OBS still does not appear, click the OBS tray icon manually once, save the main OBS window again from Layout, then restore again. That confirms the saved entry points at the real OBS main window instead of a dock panel. +## Restored Window Is Blank Until Clicked + +WindowAutoLayout uses an asynchronous exposure cycle for windows coming out of the tray or a minimized state, then performs one bounded resize and queues a full client repaint. OBS also gets the state-verified presentation recovery described above. No mouse click or key press is synthesized, and there is no repaint activity after the restore finishes. + +If one app still remains blank, confirm its saved entry targets the real main window rather than a splash, helper, dock, or GPU overlay window. Capture that app again while its main interface is visible, then inspect the matched title and class in Layout. + +## Restore Button Or Tray State Looks Stuck + +The app header and tray menu should both show `Restoring` only while one restore is active. Duplicate restore controls are intentionally disabled during that time. + +If the state does not clear, open the activity log and look for a target app that stopped responding during launch or window recovery. Exit WindowAutoLayout from the tray and reopen it; the single-instance guard prevents a second background copy from taking over the tray state. + ## OBS Says It Is Already Running This means something tried to start OBS while an OBS process was already alive. @@ -76,6 +95,8 @@ Check the app entry: If the executable path is blank, WindowAutoLayout tries normal Windows discovery paths, app paths, Start Menu shortcuts, common install folders, and `PATH`. +`Launch apps that are closed` may be turned off. That prevents new process launches but does not prevent WindowAutoLayout from recovering an app that is already running in the tray. + ## App Is Running But No Window Is Found This usually means the app is in the tray, still loading, elevated, or showing a splash/update/login window. @@ -91,32 +112,39 @@ Try: ## Window Moves To The Wrong Monitor -WindowAutoLayout saves positions relative to the target monitor. If monitor IDs change after unplugging, driver updates, or display rearranges, the saved target can point somewhere unexpected. +WindowAutoLayout saves positions relative to a hardware-backed monitor identity. Version 0.1.28 migrates older `DISPLAY1` and `DISPLAY2` targets and uses saved canvas dimensions when Windows has reassigned those volatile names. Fix: -1. Go to Settings and refresh monitors. -2. Pick the default monitor again. +1. Go to Settings and confirm the monitor model and resolution shown as the default. +2. Refresh app data after reconnecting or rearranging displays. 3. Check the profile target monitor. 4. Check app-level target monitors. -5. Save the layout again if the monitor arrangement changed. +5. Save the layout again only if the physical workspace itself changed. + +Captured layouts include the display's physical resolution and usable work area. A resolution or taskbar change is mapped into the new area. On an unchanged display, saved coordinates remain exact instead of being rescaled. + +## Window Is A Few Pixels Off + +Normal-window restores are checked repeatedly against the DWM frame bounds with a one-pixel tolerance. If an app keeps overriding its own size or location, the restore result reports both the expected and actual rectangle instead of claiming success. + +Check whether the app is elevated, enforcing a minimum size, restoring its own session, or using a saved maximized state. Capture the settled main window again after the app has finished loading. ## Show Desktop Still Hides Windows -The Windows Show Desktop command can minimize windows. Layout lock is the recovery mechanism. +The Windows Show Desktop command can minimize windows. Automatic restore is the recovery mechanism. -Turn on layout lock for the profile. While the lock is active, WindowAutoLayout watches the managed windows and restores the profile when one moves, minimizes, hides, or disappears. +Turn on automatic restore for the profile, enable `Restore after Show Desktop`, and leave WindowAutoLayout running in the tray. The event guard restores already-running profile windows without launching apps or taking focus. -## Hotkey Does Nothing +## Game Or Input Feels Affected -Check: +WindowAutoLayout does not register a global hotkey, raw mouse/keyboard device events, or low-level input hooks. Automatic restore is event-driven and has no recurring scan interval. + +Check Task Manager for exactly one `WindowAutoLayout.exe` process. Its priority should be Below normal, and CPU should stay at or near zero while idle. Disable automatic restore temporarily to separate a shell-triggered restore from unrelated game behavior. -- hotkey is enabled in Settings -- the accelerator is valid -- another app is not already using the same hotkey -- WindowAutoLayout is still running in the tray +When the interface is closed, there should be no `msedgewebview2.exe` child owned by WindowAutoLayout. Reopening the interface creates WebView2 on demand; closing to tray removes it again. -Try changing the hotkey, saving, then changing it back. +Automatic restore is skipped whenever a known game or any fullscreen foreground window is active. If a restore was already waiting to run, returning to the game cancels it. ## Startup Restore Does Not Run @@ -152,7 +180,7 @@ npm run check cd src-tauri cargo fmt --check cargo check -cargo test +cargo test --all-targets cargo clippy --all-targets -- -D warnings cd .. npm audit --audit-level=moderate diff --git a/docs/usage.md b/docs/usage.md index c6b717f..23fd6bc 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -11,7 +11,7 @@ WindowAutoLayout is built around one idea: save a workspace once, then bring it | Layout | The saved X, Y, width, and height for an app window | | Target monitor | The monitor where the saved layout should land | | Restore | The action that launches missing apps, finds windows, and moves them | -| Layout lock | A temporary keep-in-place mode that restores a profile when managed windows change | +| Automatic restore | An event-driven guard that recovers a profile after Show Desktop or leaving a fullscreen app | ## First Setup @@ -22,9 +22,9 @@ WindowAutoLayout is built around one idea: save a workspace once, then bring it 5. Arrange the apps exactly how you want them. 6. Go to Dashboard, pick the monitor to capture, and press Capture current layout. 7. Fine-tune any app entry in Apps or any single window in Layout if needed. -8. Press Restore. +8. Press Restore windows now. -Once that works, enable startup restore or use the tray menu and hotkey. +Once that works, enable startup restore or use the tray menu. ## Profiles @@ -36,7 +36,7 @@ A profile groups apps together. A typical streaming profile might include: - Stream music app - Chat or moderation tool -Profiles can have their own target monitor. If an app entry also has a target monitor, the app entry wins for that app. +Profiles can have their own target monitor. If an app entry also has a target monitor, the app entry wins for that app. New captures store a hardware-backed monitor identity, so Windows renumbering `DISPLAY1` and `DISPLAY2` does not silently retarget a saved workspace. ## App Entries @@ -63,7 +63,7 @@ For most apps, process name plus saved layout is enough. Use Layout when you want full control over which real window belongs to an app entry. -Use Dashboard capture when the visible windows on one monitor should become the current profile. Capture sets the profile target monitor, replaces that profile's app list with the visible windows on the selected monitor, and saves their monitor-relative positions. +Use Dashboard capture when the visible windows on one monitor should become the current profile. Capture sets the profile target monitor, replaces that profile's app list with the visible windows on the selected monitor, and saves their monitor-relative physical-pixel positions. It also records the display resolution, usable work area, and scale so the layout can adapt if that display changes later. Good save flow: @@ -81,12 +81,28 @@ Restore works in this order: 1. Finds matching visible, minimized, or hidden windows according to the app settings. 2. Pulls hidden or minimized windows forward when allowed. 3. Starts missing apps when launching is enabled. -4. Waits for delayed main windows to appear. +4. Waits past small launch or updater splash windows for the real main surface. 5. Applies the saved layout. -6. Logs success, skip, launch, match, and error details. +6. Rechecks normal windows until the saved bounds hold across two geometry reads. +7. Logs success, skip, launch, match, and error details. If an app is running but no usable window appears, restore reports that instead of silently pretending it worked. +`Launch apps that are closed` only controls new process launches. An existing minimized or tray-hidden app can still be recovered when that setting is off. + +## Tray Controls + +Right-click the tray icon to restore immediately, see whether automatic restore is on, open the app, open the activity log, or exit. The restore and automatic items are disabled while a restore is already running. + +Settings controls what a normal left-click does: + +- `Open WindowAutoLayout` shows the existing app window. +- `Restore windows now` triggers the default profile without opening the app. + +The tray text, tooltip, and app header all use the same runtime status, so they cannot silently disagree about whether automatic restore is active. + +Closing the visible interface to tray unloads its WebView processes. The native tray process stays resident and can still restore, toggle automatic recovery, or reopen the interface. + ## OBS Setup Recommended OBS app entry: @@ -97,11 +113,14 @@ Recommended OBS app entry: | Pull hidden/tray windows | On | | Wake running tray apps | On | | Restore if minimized | On | -| Launch if missing | On | | Detection timeout | At least 25 seconds | | Retry interval | Around 700 ms | -OBS tray behavior is special. When OBS is hidden in the system tray, Windows may expose only helper windows or no normal main window. WindowAutoLayout asks OBS through its Qt tray icon message window before moving it, which matches the manual tray click path and gives OBS time to repaint. +OBS tray behavior is special. When OBS is hidden in the system tray, Windows may expose only helper windows or no normal main window. WindowAutoLayout asks OBS through its Qt tray icon message window, waits for the main window, applies the saved rectangle, and verifies a real minimize/restore transition so Qt presents the rebuilt interface. + +OpenLaunchDeck can start with Windows in `--background` mode. During a restore, WindowAutoLayout launches the saved OpenLaunchDeck executable with `--show`. OpenLaunchDeck hands that request to its existing single instance, restores the tray window, and exits the short-lived helper process. + +Every restored window receives a non-activating, one-shot surface refresh after it is shown or moved. This asks toolkits such as Qt and Electron to redraw newly exposed client areas without synthetic input or background repaint polling. OBS uses the additional bounded recovery above and returns to the previous window when focus stayed on OBS during that recovery. ## Discord Setup @@ -118,11 +137,11 @@ Discord can take a moment to expose its main Electron window after launch. Keep ## Startup Restore -Startup restore is controlled from Settings and Profiles. +Startup restore is controlled from Settings, with the default profile selected under Profiles. Settings control whether WindowAutoLayout starts with Windows, whether it starts minimized to tray, how long it waits, and whether it restores on launch. -Profiles control which profile is the default startup profile and whether that profile participates in startup restore. +Profiles control which profile is the default startup profile. The startup command is stored in the current user's Run key: @@ -132,28 +151,19 @@ The startup command is stored in the current user's Run key: No admin rights are required for normal startup registration. -## Layout Lock - -Layout lock is for keeping a live workspace in place. - -When enabled, WindowAutoLayout watches the selected profile and restores only when a managed window changes. That means: - -- Show Desktop gets corrected while the lock is active. -- Accidentally dragged windows snap back. -- Minimized matching windows get pulled back if the app settings allow it. -- The app keeps watching the selected profile until the lock is disabled or the lock duration ends. +## Automatic Restore -The lock interval is clamped internally to 2-5 seconds. If a fullscreen app that is not part of the profile is foreground, WindowAutoLayout pauses lock work longer so games are not polled hard. +Automatic restore is for recovering a workspace from shell actions without continuous background work. -## Hotkey +When enabled: -The default hotkey is: - -```text -Ctrl+Alt+L -``` +- Show Desktop triggers a restore of already-running profile windows when its setting is enabled. +- Leaving a game or fullscreen app triggers a delayed restore when its setting is enabled. +- Returning to a game before restore begins cancels the background restore. +- Automatic restore never launches a missing app or forces keyboard focus. +- No window scan runs on a timer while the desktop is idle. -When enabled, the hotkey restores the selected/default profile. If restore-without-opening is enabled, the main WindowAutoLayout window does not need to pop open. +Use the Dashboard Restore button or tray Restore command when missing apps also need to launch. ## Config And Logs @@ -163,7 +173,7 @@ User config and logs are stored under: %APPDATA%\com.rique.windowautolayout ``` -The config is JSON and can be imported or exported from Settings. If the config becomes unreadable, WindowAutoLayout backs it up and starts from a fresh default config. +The config is JSON and can be imported or exported from Settings. Imports are parsed and normalized by the Rust backend before the interface uses them. If the config becomes unreadable, WindowAutoLayout backs it up and starts from a fresh default config. ## Everyday Streaming Flow @@ -174,4 +184,4 @@ The config is JSON and can be imported or exported from Settings. If the config 5. Missing apps open. 6. Tray apps are pulled forward. 7. The selected streaming profile is restored. -8. Layout lock keeps the workspace stable if enabled. +8. Automatic restore recovers the workspace after relevant shell events if enabled. diff --git a/examples/windowautolayout.example.json b/examples/windowautolayout.example.json index faec8f1..6e26ade 100644 --- a/examples/windowautolayout.example.json +++ b/examples/windowautolayout.example.json @@ -1,11 +1,9 @@ { - "schemaVersion": 3, - "appVersion": "0.1.18", + "schemaVersion": 7, + "appVersion": "0.1.28", "global": { "defaultMonitorId": null, - "monitorMissingBehavior": "nearestMatch", - "warnWhenMonitorMissing": true, - "advancedMode": false + "monitorMissingBehavior": "nearestMatch" }, "startup": { "enabled": false, @@ -13,24 +11,17 @@ "defaultProfileId": "profile-streaming", "delaySeconds": 8, "restoreOnLaunch": true, - "launchMissingApps": true, - "enforceAfterStartup": true + "launchMissingApps": true }, "tray": { "minimizeToTrayOnClose": true, - "showRestoreStatus": true - }, - "hotkey": { - "enabled": true, - "accelerator": "Ctrl+Alt+L", - "restoreWithoutOpening": true + "leftClickAction": "openWindow" }, "enforcement": { "enabled": false, "profileId": "profile-streaming", - "durationSeconds": 30, - "intervalMs": 2000, - "pauseForFullscreenGames": true + "restoreOnDesktopReveal": true, + "restoreAfterGameExit": true }, "profiles": [ { @@ -38,8 +29,6 @@ "name": "Streaming", "description": "OBS and Discord on the selected monitor.", "targetMonitorId": null, - "startupRestore": true, - "enforceAfterRestore": true, "apps": [ { "id": "preset-obs", @@ -51,12 +40,12 @@ "titleRule": { "mode": "startsWith", "value": "OBS", "caseSensitive": false }, "className": null, "targetMonitorId": null, + "capturedDisplay": null, "layout": { "x": 0, "y": 0, "width": 1280, "height": 720 }, "windowState": "normal", "launchDelaySeconds": 0, "detectionTimeoutSeconds": 25, "retryIntervalMs": 700, - "launchIfMissing": true, "moveIfRunning": true, "forceResize": true, "applyToAllMatchingWindows": false, @@ -76,12 +65,12 @@ "titleRule": null, "className": null, "targetMonitorId": null, + "capturedDisplay": null, "layout": { "x": 1280, "y": 0, "width": 640, "height": 720 }, "windowState": "normal", "launchDelaySeconds": 0, "detectionTimeoutSeconds": 25, "retryIntervalMs": 700, - "launchIfMissing": true, "moveIfRunning": true, "forceResize": true, "applyToAllMatchingWindows": false, @@ -101,12 +90,12 @@ "titleRule": { "mode": "contains", "value": "GitHub Desktop", "caseSensitive": false }, "className": null, "targetMonitorId": null, + "capturedDisplay": null, "layout": { "x": 80, "y": 80, "width": 1280, "height": 900 }, "windowState": "normal", "launchDelaySeconds": 0, "detectionTimeoutSeconds": 45, "retryIntervalMs": 700, - "launchIfMissing": true, "moveIfRunning": true, "forceResize": true, "applyToAllMatchingWindows": false, diff --git a/package-lock.json b/package-lock.json index fabdb77..6358ee4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,30 +1,28 @@ { "name": "windowautolayout", - "version": "0.1.18", + "version": "0.1.28", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windowautolayout", - "version": "0.1.18", + "version": "0.1.28", "dependencies": { - "@tauri-apps/api": "^2", - "@tauri-apps/plugin-global-shortcut": "^2.3.2", - "@tauri-apps/plugin-opener": "^2", + "@tauri-apps/api": "^2.11.1", "clsx": "^2.1.1", - "lucide-react": "^1.17.0", - "react": "^19.1.0", - "react-dom": "^19.1.0" + "lucide-react": "^1.31.0", + "react": "^19.2.8", + "react-dom": "^19.2.8" }, "devDependencies": { - "@tailwindcss/vite": "^4.3.0", - "@tauri-apps/cli": "^2", - "@types/react": "^19.1.8", - "@types/react-dom": "^19.1.6", - "@vitejs/plugin-react": "^4.6.0", - "tailwindcss": "^4.3.0", + "@tailwindcss/vite": "^4.3.3", + "@tauri-apps/cli": "^2.11.4", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^4.7.0", + "tailwindcss": "^4.3.3", "typescript": "~5.8.3", - "vite": "^7.0.4" + "vite": "^7.3.6" } }, "node_modules/@babel/code-frame": { @@ -58,7 +56,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -85,14 +82,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", - "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -215,13 +212,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.7" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -278,18 +275,18 @@ } }, "node_modules/@babel/traverse": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", - "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", + "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.7", + "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -297,9 +294,9 @@ } }, "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { @@ -310,21 +307,10 @@ "node": ">=6.9.0" } }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", "cpu": [ "ppc64" ], @@ -339,9 +325,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", "cpu": [ "arm" ], @@ -356,9 +342,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", "cpu": [ "arm64" ], @@ -373,9 +359,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", "cpu": [ "x64" ], @@ -390,9 +376,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", "cpu": [ "arm64" ], @@ -407,9 +393,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", "cpu": [ "x64" ], @@ -424,9 +410,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", - "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", "cpu": [ "arm64" ], @@ -441,9 +427,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", - "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", "cpu": [ "x64" ], @@ -458,9 +444,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", - "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", "cpu": [ "arm" ], @@ -475,9 +461,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", - "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", "cpu": [ "arm64" ], @@ -492,9 +478,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", - "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", "cpu": [ "ia32" ], @@ -509,9 +495,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", - "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", "cpu": [ "loong64" ], @@ -526,9 +512,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", - "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", "cpu": [ "mips64el" ], @@ -543,9 +529,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", - "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", "cpu": [ "ppc64" ], @@ -560,9 +546,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", - "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", "cpu": [ "riscv64" ], @@ -577,9 +563,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", - "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", "cpu": [ "s390x" ], @@ -594,9 +580,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", "cpu": [ "x64" ], @@ -611,9 +597,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", - "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", "cpu": [ "arm64" ], @@ -628,9 +614,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", - "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", "cpu": [ "x64" ], @@ -645,9 +631,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", - "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", "cpu": [ "arm64" ], @@ -662,9 +648,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", - "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", "cpu": [ "x64" ], @@ -679,9 +665,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", "cpu": [ "arm64" ], @@ -696,9 +682,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", "cpu": [ "x64" ], @@ -713,9 +699,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", "cpu": [ "arm64" ], @@ -730,9 +716,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", "cpu": [ "ia32" ], @@ -747,9 +733,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", - "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", "cpu": [ "x64" ], @@ -813,6 +799,23 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -821,9 +824,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", - "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", "cpu": [ "arm" ], @@ -835,9 +838,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", - "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", "cpu": [ "arm64" ], @@ -849,9 +852,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", - "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", "cpu": [ "arm64" ], @@ -863,9 +866,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", - "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", "cpu": [ "x64" ], @@ -877,9 +880,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", - "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", "cpu": [ "arm64" ], @@ -891,9 +894,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", - "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", "cpu": [ "x64" ], @@ -905,9 +908,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", - "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", "cpu": [ "arm" ], @@ -919,9 +922,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", - "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", "cpu": [ "arm" ], @@ -933,9 +936,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", - "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", "cpu": [ "arm64" ], @@ -947,9 +950,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", - "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", "cpu": [ "arm64" ], @@ -961,9 +964,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", - "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", "cpu": [ "loong64" ], @@ -975,9 +978,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", - "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", "cpu": [ "loong64" ], @@ -989,9 +992,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", - "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", "cpu": [ "ppc64" ], @@ -1003,9 +1006,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", - "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", "cpu": [ "ppc64" ], @@ -1017,9 +1020,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", - "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", "cpu": [ "riscv64" ], @@ -1031,9 +1034,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", - "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", "cpu": [ "riscv64" ], @@ -1045,9 +1048,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", - "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", "cpu": [ "s390x" ], @@ -1059,9 +1062,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", - "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", "cpu": [ "x64" ], @@ -1073,9 +1076,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", - "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", "cpu": [ "x64" ], @@ -1087,9 +1090,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", - "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", "cpu": [ "x64" ], @@ -1101,9 +1104,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", - "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", "cpu": [ "arm64" ], @@ -1115,9 +1118,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", - "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", "cpu": [ "arm64" ], @@ -1129,9 +1132,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", - "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", "cpu": [ "ia32" ], @@ -1143,9 +1146,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", - "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", "cpu": [ "x64" ], @@ -1157,9 +1160,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", - "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", "cpu": [ "x64" ], @@ -1171,49 +1174,49 @@ ] }, "node_modules/@tailwindcss/node": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", - "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.21.0", - "jiti": "^2.6.1", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.3.0" + "tailwindcss": "4.3.3" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", - "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", "dev": true, "license": "MIT", "engines": { "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.3.0", - "@tailwindcss/oxide-darwin-arm64": "4.3.0", - "@tailwindcss/oxide-darwin-x64": "4.3.0", - "@tailwindcss/oxide-freebsd-x64": "4.3.0", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", - "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", - "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", - "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", - "@tailwindcss/oxide-linux-x64-musl": "4.3.0", - "@tailwindcss/oxide-wasm32-wasi": "4.3.0", - "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", - "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", - "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", "cpu": [ "arm64" ], @@ -1228,9 +1231,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", - "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", "cpu": [ "arm64" ], @@ -1245,9 +1248,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", - "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", "cpu": [ "x64" ], @@ -1262,9 +1265,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", - "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", "cpu": [ "x64" ], @@ -1279,9 +1282,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", - "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", "cpu": [ "arm" ], @@ -1296,9 +1299,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", - "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", "cpu": [ "arm64" ], @@ -1313,9 +1316,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", - "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", "cpu": [ "arm64" ], @@ -1330,9 +1333,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", - "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", "cpu": [ "x64" ], @@ -1347,9 +1350,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", - "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", "cpu": [ "x64" ], @@ -1364,9 +1367,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", - "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -1382,11 +1385,11 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.10.0", - "@emnapi/runtime": "^1.10.0", - "@emnapi/wasi-threads": "^1.2.1", + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", - "@tybys/wasm-util": "^0.10.1", + "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "engines": { @@ -1415,7 +1418,7 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", + "version": "1.2.2", "dev": true, "inBundle": true, "license": "MIT", @@ -1443,7 +1446,7 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { - "version": "0.10.1", + "version": "0.10.2", "dev": true, "inBundle": true, "license": "MIT", @@ -1460,9 +1463,9 @@ "optional": true }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", - "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", "cpu": [ "arm64" ], @@ -1477,9 +1480,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", - "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", "cpu": [ "x64" ], @@ -1494,24 +1497,24 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz", - "integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", "dev": true, "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.3.0", - "@tailwindcss/oxide": "4.3.0", - "tailwindcss": "4.3.0" + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "node_modules/@tauri-apps/api": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.0.tgz", - "integrity": "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==", + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz", + "integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==", "license": "Apache-2.0 OR MIT", "funding": { "type": "opencollective", @@ -1519,9 +1522,9 @@ } }, "node_modules/@tauri-apps/cli": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.2.tgz", - "integrity": "sha512-bk3HemqvGRoy+5D/dVMUQHKMYLglD0jVnMm/0iGMH6ufZ+p8r14m6BpIixwij3PBvZdvORUp1YifTD8QxVZ1Nw==", + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz", + "integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==", "dev": true, "license": "Apache-2.0 OR MIT", "bin": { @@ -1535,23 +1538,23 @@ "url": "https://opencollective.com/tauri" }, "optionalDependencies": { - "@tauri-apps/cli-darwin-arm64": "2.11.2", - "@tauri-apps/cli-darwin-x64": "2.11.2", - "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.2", - "@tauri-apps/cli-linux-arm64-gnu": "2.11.2", - "@tauri-apps/cli-linux-arm64-musl": "2.11.2", - "@tauri-apps/cli-linux-riscv64-gnu": "2.11.2", - "@tauri-apps/cli-linux-x64-gnu": "2.11.2", - "@tauri-apps/cli-linux-x64-musl": "2.11.2", - "@tauri-apps/cli-win32-arm64-msvc": "2.11.2", - "@tauri-apps/cli-win32-ia32-msvc": "2.11.2", - "@tauri-apps/cli-win32-x64-msvc": "2.11.2" + "@tauri-apps/cli-darwin-arm64": "2.11.4", + "@tauri-apps/cli-darwin-x64": "2.11.4", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4", + "@tauri-apps/cli-linux-arm64-gnu": "2.11.4", + "@tauri-apps/cli-linux-arm64-musl": "2.11.4", + "@tauri-apps/cli-linux-riscv64-gnu": "2.11.4", + "@tauri-apps/cli-linux-x64-gnu": "2.11.4", + "@tauri-apps/cli-linux-x64-musl": "2.11.4", + "@tauri-apps/cli-win32-arm64-msvc": "2.11.4", + "@tauri-apps/cli-win32-ia32-msvc": "2.11.4", + "@tauri-apps/cli-win32-x64-msvc": "2.11.4" } }, "node_modules/@tauri-apps/cli-darwin-arm64": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.2.tgz", - "integrity": "sha512-+4UZzLt+eOAEQCwgd+TqKgyUJMrvx+BgdXLLaqJYmPqzP+nE6YZr/hY6CWLYGQb8jFn99jEkmC6uA3tNvamA1w==", + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz", + "integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==", "cpu": [ "arm64" ], @@ -1566,9 +1569,9 @@ } }, "node_modules/@tauri-apps/cli-darwin-x64": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.2.tgz", - "integrity": "sha512-VjYYtZUPqDMLutSfJEyxFE3Bz+DPi7c8wC3imckgvciLDZLq4qwKJxBicg0BXGhXjJsl8vKWgWRFNMPELQ+Xyg==", + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz", + "integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==", "cpu": [ "x64" ], @@ -1583,9 +1586,9 @@ } }, "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.2.tgz", - "integrity": "sha512-yMemD6f4i95AQriS8EazyOFzbE34yjnP16i3IOzpHGQvBoy2DjypFMFBq0NtPuITURv/cOGguRtHR5d79/9CSA==", + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz", + "integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==", "cpu": [ "arm" ], @@ -1600,9 +1603,9 @@ } }, "node_modules/@tauri-apps/cli-linux-arm64-gnu": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.2.tgz", - "integrity": "sha512-cgI91D2wL8GSgoWwZXDqt+DwnuZCP2/bz03QAE4TrhgAKIsrB4hX26W/H1EONPUUNkqrsgeCD0wU6pcNjV/5kw==", + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz", + "integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==", "cpu": [ "arm64" ], @@ -1617,9 +1620,9 @@ } }, "node_modules/@tauri-apps/cli-linux-arm64-musl": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.2.tgz", - "integrity": "sha512-X1rm0BERqAAggtYTESSgXrS3sz4Sb/OiPiz54UqISlXW+GkR3vNIGnsy/lejNmoXGVqri3Q53BCfQiclOIyRPw==", + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz", + "integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==", "cpu": [ "arm64" ], @@ -1634,9 +1637,9 @@ } }, "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.2.tgz", - "integrity": "sha512-usbMLJbT3KtkOrBMDVeGYNM35aTHXx38SJSzTMSqqjeUIOQ+iVPjb2yAGNAE+KqmBbAx4FOFIyMeKXx2M/JKGQ==", + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz", + "integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==", "cpu": [ "riscv64" ], @@ -1651,9 +1654,9 @@ } }, "node_modules/@tauri-apps/cli-linux-x64-gnu": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.2.tgz", - "integrity": "sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw==", + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz", + "integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==", "cpu": [ "x64" ], @@ -1668,9 +1671,9 @@ } }, "node_modules/@tauri-apps/cli-linux-x64-musl": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.2.tgz", - "integrity": "sha512-eUm7T6clN1MMmNSRQ9gaWsQdyehQx2Gmn5hht/QUlqZQI/qcP2OJK5dnaxqwFzCr2HdsEo9ydxaqcS1oJzMvUw==", + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz", + "integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==", "cpu": [ "x64" ], @@ -1685,9 +1688,9 @@ } }, "node_modules/@tauri-apps/cli-win32-arm64-msvc": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.2.tgz", - "integrity": "sha512-HeeZW80jU+gVTOEX4X/hC6NVSAdDVXajwP5fxIZ/3z9WvUC7qrudX2GMTilYq6Dg0e0sk0XgsAJD1hZ5wPBXUA==", + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz", + "integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==", "cpu": [ "arm64" ], @@ -1702,9 +1705,9 @@ } }, "node_modules/@tauri-apps/cli-win32-ia32-msvc": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.2.tgz", - "integrity": "sha512-YhjQNZcXfbkCLyazSv1nPnJ9iRFE1wm6kc51FDbU10/Dk09io+6PAGMLjkxnX2GdM0qMnDmTjstY8mTDVvtKeA==", + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz", + "integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==", "cpu": [ "ia32" ], @@ -1719,9 +1722,9 @@ } }, "node_modules/@tauri-apps/cli-win32-x64-msvc": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.2.tgz", - "integrity": "sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA==", + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz", + "integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==", "cpu": [ "x64" ], @@ -1735,24 +1738,6 @@ "node": ">= 10" } }, - "node_modules/@tauri-apps/plugin-global-shortcut": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-global-shortcut/-/plugin-global-shortcut-2.3.2.tgz", - "integrity": "sha512-UReHNXrLvpEjylE4jb4oCYiy96uRykPUthoCQCmRXYrd5hs5X9DrW+qOn7GLW57EJN4tdK8bgK5twBTz2NOxzA==", - "license": "MIT OR Apache-2.0", - "dependencies": { - "@tauri-apps/api": "^2.11.0" - } - }, - "node_modules/@tauri-apps/plugin-opener": { - "version": "2.5.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz", - "integrity": "sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ==", - "license": "MIT OR Apache-2.0", - "dependencies": { - "@tauri-apps/api": "^2.11.0" - } - }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1799,27 +1784,26 @@ } }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.15", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", - "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } }, "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", "dev": true, "license": "MIT", "peerDependencies": { @@ -1848,9 +1832,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.32", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", - "integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==", + "version": "2.11.14", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.14.tgz", + "integrity": "sha512-JyJ954WzuIR8/FFzX0o5krdSTrBAkcCSRfWSleRsIHSWV+cZe2FI1PKggVkFke1hBldRs+LRxUczzE9iPmgZww==", "dev": true, "license": "Apache-2.0", "bin": { @@ -1861,9 +1845,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", "dev": true, "funding": [ { @@ -1880,13 +1864,12 @@ } ], "license": "MIT", - "peer": true, "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" @@ -1896,9 +1879,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001793", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", - "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", "dev": true, "funding": [ { @@ -1968,16 +1951,16 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.364", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz", - "integrity": "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==", + "version": "1.5.407", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.407.tgz", + "integrity": "sha512-4R8XgQOdfxexCd/u63lRm6wCHjECwI45MV9wxAs2ggtfWe2hwlo1ql97jKsju2IcJ+jFSTwBssyYoiWhh7mauQ==", "dev": true, "license": "ISC" }, "node_modules/enhanced-resolve": { - "version": "5.22.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.1.tgz", - "integrity": "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww==", + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", "dev": true, "license": "MIT", "dependencies": { @@ -1989,9 +1972,9 @@ } }, "node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2002,32 +1985,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" } }, "node_modules/escalade": { @@ -2405,9 +2388,9 @@ } }, "node_modules/lucide-react": { - "version": "1.17.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.17.0.tgz", - "integrity": "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w==", + "version": "1.31.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.31.0.tgz", + "integrity": "sha512-G8u2eEtoHUnUa9f8lbvqDhCiORMnYLdUEo06EEG9MQvHQrInKcX3Pa2TH39MM5qyzRcWETxB0+aOwAPI1g1kEg==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -2431,9 +2414,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { @@ -2450,9 +2433,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.46", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", - "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", "dev": true, "license": "MIT", "engines": { @@ -2467,12 +2450,11 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2481,9 +2463,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "dev": true, "funding": [ { @@ -2501,7 +2483,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -2510,25 +2492,24 @@ } }, "node_modules/react": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", - "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", - "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.6" + "react": "^19.2.8" } }, "node_modules/react-refresh": { @@ -2542,13 +2523,13 @@ } }, "node_modules/rollup": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", - "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@types/estree": "1.0.9" }, "bin": { "rollup": "dist/bin/rollup" @@ -2558,31 +2539,32 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.4", - "@rollup/rollup-android-arm64": "4.60.4", - "@rollup/rollup-darwin-arm64": "4.60.4", - "@rollup/rollup-darwin-x64": "4.60.4", - "@rollup/rollup-freebsd-arm64": "4.60.4", - "@rollup/rollup-freebsd-x64": "4.60.4", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", - "@rollup/rollup-linux-arm-musleabihf": "4.60.4", - "@rollup/rollup-linux-arm64-gnu": "4.60.4", - "@rollup/rollup-linux-arm64-musl": "4.60.4", - "@rollup/rollup-linux-loong64-gnu": "4.60.4", - "@rollup/rollup-linux-loong64-musl": "4.60.4", - "@rollup/rollup-linux-ppc64-gnu": "4.60.4", - "@rollup/rollup-linux-ppc64-musl": "4.60.4", - "@rollup/rollup-linux-riscv64-gnu": "4.60.4", - "@rollup/rollup-linux-riscv64-musl": "4.60.4", - "@rollup/rollup-linux-s390x-gnu": "4.60.4", - "@rollup/rollup-linux-x64-gnu": "4.60.4", - "@rollup/rollup-linux-x64-musl": "4.60.4", - "@rollup/rollup-openbsd-x64": "4.60.4", - "@rollup/rollup-openharmony-arm64": "4.60.4", - "@rollup/rollup-win32-arm64-msvc": "4.60.4", - "@rollup/rollup-win32-ia32-msvc": "4.60.4", - "@rollup/rollup-win32-x64-gnu": "4.60.4", - "@rollup/rollup-win32-x64-msvc": "4.60.4", + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", "fsevents": "~2.3.2" } }, @@ -2613,9 +2595,9 @@ } }, "node_modules/tailwindcss": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", - "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", "dev": true, "license": "MIT" }, @@ -2634,9 +2616,9 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { @@ -2650,14 +2632,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true - }, "node_modules/typescript": { "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", @@ -2673,9 +2647,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", "dev": true, "funding": [ { @@ -2709,7 +2683,6 @@ "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", diff --git a/package.json b/package.json index 6d20e69..0b3f705 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "windowautolayout", "private": true, - "version": "0.1.18", + "version": "0.1.28", "type": "module", "scripts": { "dev": "vite", @@ -14,22 +14,20 @@ "check": "tsc --noEmit" }, "dependencies": { - "@tauri-apps/api": "^2", - "@tauri-apps/plugin-global-shortcut": "^2.3.2", - "@tauri-apps/plugin-opener": "^2", + "@tauri-apps/api": "^2.11.1", "clsx": "^2.1.1", - "lucide-react": "^1.17.0", - "react": "^19.1.0", - "react-dom": "^19.1.0" + "lucide-react": "^1.31.0", + "react": "^19.2.8", + "react-dom": "^19.2.8" }, "devDependencies": { - "@tailwindcss/vite": "^4.3.0", - "@tauri-apps/cli": "^2", - "@types/react": "^19.1.8", - "@types/react-dom": "^19.1.6", - "@vitejs/plugin-react": "^4.6.0", - "tailwindcss": "^4.3.0", + "@tailwindcss/vite": "^4.3.3", + "@tauri-apps/cli": "^2.11.4", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^4.7.0", + "tailwindcss": "^4.3.3", "typescript": "~5.8.3", - "vite": "^7.0.4" + "vite": "^7.3.6" } } diff --git a/scripts/install-current.ps1 b/scripts/install-current.ps1 index 4acb9fa..c2acba8 100644 --- a/scripts/install-current.ps1 +++ b/scripts/install-current.ps1 @@ -101,16 +101,11 @@ try { $stayedAlive = $null if (-not $NoLaunchSmoke) { - $app = Start-Process -FilePath $exe -PassThru -WindowStyle Hidden + Get-Process -Name "WindowAutoLayout", "windowautolayout" -ErrorAction SilentlyContinue | + Stop-Process -Force -ErrorAction SilentlyContinue + $app = Start-Process -FilePath $exe -ArgumentList "--startup-restore" -PassThru -WindowStyle Hidden Start-Sleep -Seconds 5 $stayedAlive = -not $app.HasExited - if ($stayedAlive) { - $app.CloseMainWindow() | Out-Null - Start-Sleep -Seconds 2 - if (-not $app.HasExited) { - Stop-Process -Id $app.Id -Force - } - } if (-not $stayedAlive) { throw "Installed app exited during launch smoke" } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 553ae2b..f8f5311 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -25,9 +25,9 @@ checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" [[package]] name = "alloc-stdlib" -version = "0.2.2" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" dependencies = [ "alloc-no-stdlib", ] @@ -43,140 +43,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" - -[[package]] -name = "async-broadcast" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" -dependencies = [ - "event-listener", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-channel" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" -dependencies = [ - "concurrent-queue", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-executor" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" -dependencies = [ - "async-task", - "concurrent-queue", - "fastrand", - "futures-lite", - "pin-project-lite", - "slab", -] - -[[package]] -name = "async-io" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" -dependencies = [ - "autocfg", - "cfg-if", - "concurrent-queue", - "futures-io", - "futures-lite", - "parking", - "polling", - "rustix", - "slab", - "windows-sys 0.61.2", -] - -[[package]] -name = "async-lock" -version = "3.4.2" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" -dependencies = [ - "event-listener", - "event-listener-strategy", - "pin-project-lite", -] - -[[package]] -name = "async-process" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" -dependencies = [ - "async-channel", - "async-io", - "async-lock", - "async-signal", - "async-task", - "blocking", - "cfg-if", - "event-listener", - "futures-lite", - "rustix", -] - -[[package]] -name = "async-recursion" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "async-signal" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" -dependencies = [ - "async-io", - "async-lock", - "atomic-waker", - "cfg-if", - "futures-core", - "futures-io", - "rustix", - "signal-hook-registry", - "slab", - "windows-sys 0.61.2", -] - -[[package]] -name = "async-task" -version = "4.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" - -[[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "atk" @@ -248,9 +117,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" dependencies = [ "serde_core", ] @@ -273,24 +142,11 @@ dependencies = [ "objc2", ] -[[package]] -name = "blocking" -version = "1.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" -dependencies = [ - "async-channel", - "async-task", - "futures-io", - "futures-lite", - "piper", -] - [[package]] name = "brotli" -version = "8.0.3" +version = "8.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -299,9 +155,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.1" +version = "5.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -324,9 +180,9 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytemuck" -version = "1.25.0" +version = "1.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424" [[package]] name = "byteorder" @@ -336,9 +192,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" dependencies = [ "serde", ] @@ -349,7 +205,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "cairo-sys-rs", "glib", "libc", @@ -370,9 +226,9 @@ dependencies = [ [[package]] name = "camino" -version = "1.2.2" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0" dependencies = [ "serde_core", ] @@ -412,9 +268,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.63" +version = "1.2.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" dependencies = [ "find-msvc-tools", "shlex", @@ -455,9 +311,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -477,15 +333,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "concurrent-queue" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "cookie" version = "0.18.1" @@ -518,7 +365,7 @@ version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "core-foundation", "core-graphics-types", "foreign-types", @@ -531,7 +378,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "core-foundation", "libc", ] @@ -556,18 +403,18 @@ dependencies = [ [[package]] name = "crossbeam-channel" -version = "0.5.15" +version = "0.5.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "crypto-common" @@ -599,7 +446,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -638,7 +485,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -649,14 +496,14 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "dbus" -version = "0.9.11" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" dependencies = [ "libc", "libdbus-sys", @@ -669,7 +516,6 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ - "powerfmt", "serde_core", ] @@ -691,7 +537,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -731,7 +577,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "block2", "libc", "objc2", @@ -745,7 +591,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -768,7 +614,7 @@ checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -779,7 +625,7 @@ checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" dependencies = [ "bit-set", "cssparser", - "foldhash 0.2.0", + "foldhash", "html5ever", "precomputed-hash", "selectors", @@ -839,9 +685,9 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "embed-resource" -version = "3.0.9" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31a88c8d26de40ed18fe748c547845aa39de1db3afd958f8cb91579f3644bcb" +checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" dependencies = [ "cc", "memchr", @@ -857,33 +703,6 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" -[[package]] -name = "endi" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" - -[[package]] -name = "enumflags2" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" -dependencies = [ - "enumflags2_derive", - "serde", -] - -[[package]] -name = "enumflags2_derive" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -901,37 +720,6 @@ dependencies = [ "typeid", ] -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "event-listener" -version = "5.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" -dependencies = [ - "concurrent-queue", - "parking", - "pin-project-lite", -] - -[[package]] -name = "event-listener-strategy" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" -dependencies = [ - "event-listener", - "pin-project-lite", -] - [[package]] name = "fastrand" version = "2.4.1" @@ -979,12 +767,6 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - [[package]] name = "foldhash" version = "0.2.0" @@ -1009,7 +791,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1059,19 +841,6 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" -[[package]] -name = "futures-lite" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" -dependencies = [ - "fastrand", - "futures-core", - "futures-io", - "parking", - "pin-project-lite", -] - [[package]] name = "futures-macro" version = "0.3.32" @@ -1080,7 +849,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1220,16 +989,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "gethostname" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" -dependencies = [ - "rustix", - "windows-link 0.2.1", -] - [[package]] name = "getrandom" version = "0.2.17" @@ -1255,15 +1014,13 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", - "wasip2", - "wasip3", ] [[package]] @@ -1304,7 +1061,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "futures-channel", "futures-core", "futures-executor", @@ -1332,7 +1089,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1351,24 +1108,6 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" -[[package]] -name = "global-hotkey" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c386b0a4a70cb2d39fffd74480f985b6f0bfbcb934b6a6b6b7e630e448f242e" -dependencies = [ - "crossbeam-channel", - "keyboard-types", - "objc2", - "objc2-app-kit", - "once_cell", - "serde", - "thiserror 2.0.18", - "windows-sys 0.59.0", - "x11rb", - "xkeysym", -] - [[package]] name = "gobject-sys" version = "0.18.0" @@ -1429,7 +1168,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1438,15 +1177,6 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash 0.1.5", -] - [[package]] name = "hashbrown" version = "0.17.1" @@ -1465,12 +1195,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hermit-abi" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" - [[package]] name = "hex" version = "0.4.3" @@ -1489,9 +1213,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.1" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ "bytes", "itoa", @@ -1685,12 +1409,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "ident_case" version = "1.0.1" @@ -1756,25 +1474,6 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" -[[package]] -name = "is-docker" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" -dependencies = [ - "once_cell", -] - -[[package]] -name = "is-wsl" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" -dependencies = [ - "is-docker", - "once_cell", -] - [[package]] name = "itoa" version = "1.0.18" @@ -1845,18 +1544,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "js-sys" -version = "0.3.99" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -1888,17 +1586,11 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "serde", "unicode-segmentation", ] -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "libappindicator" version = "0.9.0" @@ -1950,19 +1642,13 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.17" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" dependencies = [ "libc", ] -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - [[package]] name = "litemap" version = "0.8.2" @@ -1980,9 +1666,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.30" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "markup5ever" @@ -1997,9 +1683,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.1" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memoffset" @@ -2039,9 +1725,9 @@ dependencies = [ [[package]] name = "muda" -version = "0.19.2" +version = "0.19.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47a2e3dff89cd322c66647942668faee0a2b1f88ea6cbb4d374b4a8d7e92528c" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" dependencies = [ "crossbeam-channel", "dpi", @@ -2064,7 +1750,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "jni-sys 0.3.1", "log", "ndk-sys", @@ -2122,7 +1808,7 @@ dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2141,7 +1827,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "block2", "objc2", "objc2-core-foundation", @@ -2154,7 +1840,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "objc2", "objc2-foundation", ] @@ -2175,7 +1861,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "dispatch2", "objc2", ] @@ -2186,7 +1872,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "dispatch2", "objc2", "objc2-core-foundation", @@ -2219,7 +1905,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "objc2", "objc2-core-foundation", "objc2-core-graphics", @@ -2246,7 +1932,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "block2", "objc2", "objc2-core-foundation", @@ -2258,7 +1944,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "objc2", "objc2-core-foundation", ] @@ -2269,7 +1955,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "objc2", "objc2-core-foundation", "objc2-foundation", @@ -2281,7 +1967,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "block2", "objc2", "objc2-cloud-kit", @@ -2312,7 +1998,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "block2", "objc2", "objc2-app-kit", @@ -2326,34 +2012,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -[[package]] -name = "open" -version = "5.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c" -dependencies = [ - "dunce", - "is-wsl", - "libc", - "pathdiff", -] - [[package]] name = "option-ext" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" -[[package]] -name = "ordered-stream" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" -dependencies = [ - "futures-core", - "pin-project-lite", -] - [[package]] name = "pango" version = "0.18.3" @@ -2379,12 +2043,6 @@ dependencies = [ "system-deps", ] -[[package]] -name = "parking" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" - [[package]] name = "parking_lot" version = "0.12.5" @@ -2408,12 +2066,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "pathdiff" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" - [[package]] name = "percent-encoding" version = "2.3.2" @@ -2461,7 +2113,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2479,17 +2131,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "piper" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" -dependencies = [ - "atomic-waker", - "fastrand", - "futures-io", -] - [[package]] name = "pkg-config" version = "0.3.33" @@ -2498,9 +2139,9 @@ checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "plist" -version = "1.9.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" +checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" dependencies = [ "base64 0.22.1", "indexmap 2.14.0", @@ -2528,27 +2169,13 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "crc32fast", "fdeflate", "flate2", "miniz_oxide", ] -[[package]] -name = "polling" -version = "3.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" -dependencies = [ - "cfg-if", - "concurrent-queue", - "hermit-abi", - "pin-project-lite", - "rustix", - "windows-sys 0.61.2", -] - [[package]] name = "potential_utf" version = "0.1.5" @@ -2570,16 +2197,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn 2.0.117", -] - [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -2644,18 +2261,18 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.39.4" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" dependencies = [ "memchr", ] [[package]] name = "quote" -version = "1.0.45" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] @@ -2684,7 +2301,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", ] [[package]] @@ -2715,14 +2332,14 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "regex" -version = "1.12.3" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2" dependencies = [ "aho-corasick", "memchr", @@ -2732,9 +2349,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db" dependencies = [ "aho-corasick", "memchr", @@ -2743,9 +2360,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" @@ -2783,9 +2400,9 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc_version" @@ -2796,24 +2413,11 @@ dependencies = [ "semver", ] -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags 2.11.1", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "same-file" @@ -2872,7 +2476,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2887,7 +2491,7 @@ version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "cssparser", "derive_more", "log", @@ -2949,7 +2553,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2960,7 +2564,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2984,7 +2588,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3007,9 +2611,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.20.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" dependencies = [ "base64 0.22.1", "bs58", @@ -3027,14 +2631,14 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.20.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3056,7 +2660,7 @@ checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3085,16 +2689,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - [[package]] name = "simd-adler32" version = "0.3.9" @@ -3115,9 +2709,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "socket2" @@ -3236,9 +2830,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.117" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", @@ -3262,7 +2856,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3284,7 +2878,7 @@ version = "0.35.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "block2", "core-foundation", "core-graphics", @@ -3326,7 +2920,7 @@ checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3337,9 +2931,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tauri" -version = "2.11.2" +version = "2.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "437404997acf375d85f1177afa7e11bb971f274ed6a7b83a2a3e339015f4cc28" +checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5" dependencies = [ "anyhow", "bytes", @@ -3388,9 +2982,9 @@ dependencies = [ [[package]] name = "tauri-build" -version = "2.6.2" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4aa1f9055fc23919a54e4e125052bed16ed04aef0487086e758fe01a67b451c7" +checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" dependencies = [ "anyhow", "cargo_toml", @@ -3409,9 +3003,9 @@ dependencies = [ [[package]] name = "tauri-codegen" -version = "2.6.2" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4a0319528a025a38c4078e7dae2c446f4e63620ddb0659a643ede1cb38f90e9" +checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" dependencies = [ "base64 0.22.1", "brotli", @@ -3425,7 +3019,7 @@ dependencies = [ "serde", "serde_json", "sha2", - "syn 2.0.117", + "syn 2.0.118", "tauri-utils", "thiserror 2.0.18", "time", @@ -3436,76 +3030,23 @@ dependencies = [ [[package]] name = "tauri-macros" -version = "2.6.2" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae6cb4e3896c21d2f6da5b31251d2faea0153bba56ed0e970f918115dbee4924" +checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "tauri-codegen", "tauri-utils", ] -[[package]] -name = "tauri-plugin" -version = "2.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e126abc9e84e35cdfd01596140a73a1850cdb0df0a23acf0185776c30b469a6e" -dependencies = [ - "anyhow", - "glob", - "plist", - "schemars 0.8.22", - "serde", - "serde_json", - "tauri-utils", - "walkdir", -] - -[[package]] -name = "tauri-plugin-global-shortcut" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4dd9f4c5136c09cd962da0c86dc4accd4666db2ea591cf16e6597435843bd2b" -dependencies = [ - "global-hotkey", - "log", - "serde", - "serde_json", - "tauri", - "tauri-plugin", - "thiserror 2.0.18", -] - -[[package]] -name = "tauri-plugin-opener" -version = "2.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29" -dependencies = [ - "dunce", - "glob", - "objc2-app-kit", - "objc2-foundation", - "open", - "schemars 0.8.22", - "serde", - "serde_json", - "tauri", - "tauri-plugin", - "thiserror 2.0.18", - "url", - "windows 0.61.3", - "zbus", -] - [[package]] name = "tauri-runtime" -version = "2.11.2" +version = "2.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48222d7116c8807eaa6fe2f372e023fae125084e61e6eca6d70b7961cdf129ef" +checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" dependencies = [ "cookie", "dpi", @@ -3528,9 +3069,9 @@ dependencies = [ [[package]] name = "tauri-runtime-wry" -version = "2.11.2" +version = "2.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9" +checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" dependencies = [ "gtk", "http", @@ -3554,9 +3095,9 @@ dependencies = [ [[package]] name = "tauri-utils" -version = "2.9.2" +version = "2.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "092379df9a707631978e6c56b1bc2401d387f01e2d4a3c123360d167bbb9aa95" +checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" dependencies = [ "anyhow", "brotli", @@ -3601,27 +3142,13 @@ dependencies = [ "toml 1.1.2+spec-1.1.0", ] -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.2", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - [[package]] name = "tendril" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" dependencies = [ "new_debug_unreachable", - "utf-8", ] [[package]] @@ -3650,7 +3177,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3661,17 +3188,16 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "time" -version = "0.3.47" +version = "0.3.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", "serde_core", @@ -3681,15 +3207,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" dependencies = [ "num-conv", "time-core", @@ -3707,9 +3233,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" dependencies = [ "tinyvec_macros", ] @@ -3888,7 +3414,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "bytes", "futures-util", "http", @@ -3919,21 +3445,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", - "tracing-attributes", "tracing-core", ] -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "tracing-core" version = "0.1.36" @@ -3945,9 +3459,9 @@ dependencies = [ [[package]] name = "tray-icon" -version = "0.23.1" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15edbb0d80583e85ee8df283410038e17314df5cba30da2087a54a85216c0773" +checksum = "65ba1e5f6b9ef9fd87e21b9c6f351554dbd717960089168fcfdef854686961dc" dependencies = [ "crossbeam-channel", "dirs", @@ -3983,17 +3497,6 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" -[[package]] -name = "uds_windows" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" -dependencies = [ - "memoffset", - "tempfile", - "windows-sys 0.61.2", -] - [[package]] name = "unic-char-property" version = "0.9.0" @@ -4043,15 +3546,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" - -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "url" @@ -4078,12 +3575,6 @@ dependencies = [ "url", ] -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -4092,11 +3583,11 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.23.2" +version = "1.23.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" +checksum = "ea5fab0d6c3c01ae70085a09cb03d4c7a1d6314e2b3e075392783396d724ca0a" dependencies = [ - "getrandom 0.4.2", + "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", @@ -4161,27 +3652,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" -dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", @@ -4192,9 +3674,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.72" +version = "0.4.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" dependencies = [ "js-sys", "wasm-bindgen", @@ -4202,9 +3684,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4212,48 +3694,26 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap 2.14.0", - "wasm-encoder", - "wasmparser", -] - [[package]] name = "wasm-streams" version = "0.5.0" @@ -4267,23 +3727,11 @@ dependencies = [ "web-sys", ] -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.11.1", - "hashbrown 0.15.5", - "indexmap 2.14.0", - "semver", -] - [[package]] name = "web-sys" -version = "0.3.99" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" dependencies = [ "js-sys", "wasm-bindgen", @@ -4291,9 +3739,9 @@ dependencies = [ [[package]] name = "web_atoms" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538" +checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297" dependencies = [ "phf", "phf_codegen", @@ -4367,7 +3815,7 @@ checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4429,7 +3877,7 @@ dependencies = [ [[package]] name = "windowautolayout" -version = "0.1.18" +version = "0.1.28" dependencies = [ "chrono", "regex", @@ -4437,8 +3885,6 @@ dependencies = [ "serde_json", "tauri", "tauri-build", - "tauri-plugin-global-shortcut", - "tauri-plugin-opener", "thiserror 2.0.18", "uuid", "windows 0.62.2", @@ -4544,7 +3990,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4555,7 +4001,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4845,100 +4291,12 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck 0.5.0", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck 0.5.0", - "indexmap 2.14.0", - "prettyplease", - "syn 2.0.117", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.117", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.11.1", - "indexmap 2.14.0", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap 2.14.0", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - [[package]] name = "writeable" version = "0.6.3" @@ -5010,34 +4368,11 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "x11rb" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" -dependencies = [ - "gethostname", - "rustix", - "x11rb-protocol", -] - -[[package]] -name = "x11rb-protocol" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" - -[[package]] -name = "xkeysym" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" - [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -5052,71 +4387,10 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "synstructure", ] -[[package]] -name = "zbus" -version = "5.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285" -dependencies = [ - "async-broadcast", - "async-executor", - "async-io", - "async-lock", - "async-process", - "async-recursion", - "async-task", - "async-trait", - "blocking", - "enumflags2", - "event-listener", - "futures-core", - "futures-lite", - "hex", - "libc", - "ordered-stream", - "rustix", - "serde", - "serde_repr", - "tracing", - "uds_windows", - "uuid", - "windows-sys 0.61.2", - "winnow 1.0.3", - "zbus_macros", - "zbus_names", - "zvariant", -] - -[[package]] -name = "zbus_macros" -version = "5.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6" -dependencies = [ - "proc-macro-crate 3.5.0", - "proc-macro2", - "quote", - "syn 2.0.117", - "zbus_names", - "zvariant", - "zvariant_utils", -] - -[[package]] -name = "zbus_names" -version = "4.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" -dependencies = [ - "serde", - "winnow 1.0.3", - "zvariant", -] - [[package]] name = "zerofrom" version = "0.1.8" @@ -5134,7 +4408,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "synstructure", ] @@ -5168,51 +4442,11 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" - -[[package]] -name = "zvariant" -version = "5.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0" -dependencies = [ - "endi", - "enumflags2", - "serde", - "winnow 1.0.3", - "zvariant_derive", - "zvariant_utils", -] - -[[package]] -name = "zvariant_derive" -version = "5.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737" -dependencies = [ - "proc-macro-crate 3.5.0", - "proc-macro2", - "quote", - "syn 2.0.117", - "zvariant_utils", -] - -[[package]] -name = "zvariant_utils" -version = "3.4.0" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6" -dependencies = [ - "proc-macro2", - "quote", - "serde", - "syn 2.0.117", - "winnow 1.0.3", -] +checksum = "bd2f034a4bebf216c9e4b7083603e024cf930873fd67830cfb083c9fa33129d9" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 1fc0119..6a834a0 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windowautolayout" -version = "0.1.18" +version = "0.1.28" description = "Windows workspace layout restore utility" authors = ["Rique"] edition = "2021" @@ -23,17 +23,18 @@ regex = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" tauri = { version = "2", features = ["tray-icon"] } -tauri-plugin-global-shortcut = "2.3.2" -tauri-plugin-opener = "2" thiserror = "2" uuid = { version = "1", features = ["v4", "serde"] } windows = { version = "0.62.2", features = [ "Win32_Foundation", "Win32_Graphics_Dwm", "Win32_Graphics_Gdi", + "Win32_Security", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Threading", + "Win32_UI_Accessibility", "Win32_UI_HiDpi", + "Win32_UI_Input", "Win32_UI_WindowsAndMessaging" ] } winreg = "0.56" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index c54480b..b9ec4c1 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -4,11 +4,6 @@ "description": "Capability for the main window", "windows": ["main"], "permissions": [ - "core:default", - "global-shortcut:allow-is-registered", - "global-shortcut:allow-register", - "global-shortcut:allow-unregister", - "global-shortcut:allow-unregister-all", - "opener:default" + "core:default" ] } diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index d5d5f94..40b4fa8 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,19 +1,19 @@ use std::{collections::HashMap, path::PathBuf, process::Command}; -use tauri::{AppHandle, Manager, State}; +use tauri::{AppHandle, State}; use crate::{ config, errors::{AppError, AppResult}, layout_lock, logging, models::{ - preset_apps, AppConfig, CaptureLayoutResult, CapturedWindowSummary, LogEntry, LogSeverity, - MatchRule, MonitorInfo, RestoreResult, TitleMatchMode, WindowAutoLayoutConfig, WindowInfo, - WindowStatePreference, + preset_apps, AppConfig, CaptureLayoutResult, CapturedDisplay, CapturedWindowSummary, + LogEntry, LogSeverity, MatchRule, MonitorInfo, RestoreResult, RuntimeStatus, + TitleMatchMode, WindowAutoLayoutConfig, WindowInfo, WindowStatePreference, }, monitors, profiles, startup, state::AppState, - window_actions, windows_enum, + tray_ui, window_actions, windows_enum, }; #[tauri::command] @@ -25,35 +25,41 @@ pub fn get_config(state: State<'_, AppState>) -> AppResult AppResult { + config::parse_json(&raw) +} + #[tauri::command] pub fn save_config( app: AppHandle, state: State<'_, AppState>, mut next_config: WindowAutoLayoutConfig, ) -> AppResult { - next_config.schema_version = crate::models::CONFIG_SCHEMA_VERSION; - next_config.app_version = crate::models::APP_VERSION.to_string(); - next_config.startup.launch_missing_apps = true; - for profile in &mut next_config.profiles { - for app in &mut profile.apps { - app.launch_if_missing = true; - } - } - startup::set_startup_enabled(next_config.startup.enabled)?; + config::normalize_config(&mut next_config); + let previous_config = state + .config + .lock() + .map_err(|_| AppError::Config("Config lock was poisoned".to_string()))? + .clone(); config::save(&state.config_dir, &next_config)?; + if let Err(error) = startup::set_startup_enabled(next_config.startup.enabled) { + let _ = config::save(&state.config_dir, &previous_config); + return Err(error); + } *state .config .lock() .map_err(|_| AppError::Config("Config lock was poisoned".to_string()))? = next_config.clone(); layout_lock::sync_from_config(&app, &next_config)?; - logging::append( + let _ = logging::append( &state.config_dir, LogSeverity::Info, None, None, "Settings saved", - )?; + ); Ok(next_config) } @@ -102,33 +108,6 @@ pub async fn restore_profile( .map_err(|error| AppError::Config(format!("Restore task failed: {error}")))? } -#[tauri::command] -pub async fn lock_layout_temporarily( - state: State<'_, AppState>, - profile_id: Option, - duration_seconds: u64, -) -> AppResult { - let config_dir = state.config_dir.clone(); - let config = state - .config - .lock() - .map_err(|_| AppError::Config("Config lock was poisoned".to_string()))? - .clone(); - let interval_ms = config.enforcement.interval_ms; - - tauri::async_runtime::spawn_blocking(move || { - profiles::enforce_profile_for( - &config_dir, - &config, - profile_id, - duration_seconds, - interval_ms, - ) - }) - .await - .map_err(|error| AppError::Config(format!("Lock task failed: {error}")))? -} - #[tauri::command] pub fn set_layout_lock( app: AppHandle, @@ -143,6 +122,11 @@ pub fn layout_lock_enabled(app: AppHandle) -> AppResult { layout_lock::enabled(&app) } +#[tauri::command] +pub fn runtime_status(app: AppHandle) -> AppResult { + tray_ui::runtime_status(&app) +} + #[tauri::command] pub fn save_window_layout( state: State<'_, AppState>, @@ -156,7 +140,7 @@ pub fn save_window_layout( let monitor_id = window.monitor_id.clone().ok_or(AppError::MonitorNotFound)?; let monitor = monitors::list_monitors()? .into_iter() - .find(|monitor| monitor.id == monitor_id) + .find(|monitor| monitors::monitor_id_matches(monitor, &monitor_id)) .ok_or(AppError::MonitorNotFound)?; let mut next_config = state @@ -189,6 +173,7 @@ pub fn save_window_layout( bottom: window.y + window.height, }, ); + app.captured_display = Some(CapturedDisplay::from_monitor(&monitor)); app.target_monitor_id = Some(monitor.id); if app .process_name @@ -208,13 +193,13 @@ pub fn save_window_layout( .lock() .map_err(|_| AppError::Config("Config lock was poisoned".to_string()))? = next_config.clone(); - logging::append( + let _ = logging::append( &state.config_dir, LogSeverity::Info, Some(&profile_id), Some(&app_id), "Captured window layout", - )?; + ); Ok(next_config) } @@ -245,7 +230,10 @@ pub fn save_all_current_layouts( let Some(monitor_id) = &window.monitor_id else { continue; }; - let Some(monitor) = monitors.iter().find(|monitor| &monitor.id == monitor_id) else { + let Some(monitor) = monitors + .iter() + .find(|monitor| monitors::monitor_id_matches(monitor, monitor_id)) + else { continue; }; app.layout = window_actions::relative_rect( @@ -257,6 +245,7 @@ pub fn save_all_current_layouts( bottom: window.y + window.height, }, ); + app.captured_display = Some(CapturedDisplay::from_monitor(monitor)); app.target_monitor_id = Some(monitor.id.clone()); if profile.target_monitor_id.is_none() { profile.target_monitor_id = Some(monitor.id.clone()); @@ -269,13 +258,13 @@ pub fn save_all_current_layouts( .lock() .map_err(|_| AppError::Config("Config lock was poisoned".to_string()))? = next_config.clone(); - logging::append( + let _ = logging::append( &state.config_dir, LogSeverity::Info, Some(&profile_id), None, "Captured layouts for matching configured apps", - )?; + ); Ok(next_config) } @@ -288,7 +277,7 @@ pub fn capture_current_layout( let monitors = monitors::list_monitors()?; let monitor = monitors .iter() - .find(|monitor| monitor.id == monitor_id) + .find(|monitor| monitors::monitor_id_matches(monitor, &monitor_id)) .cloned() .ok_or(AppError::MonitorNotFound)?; @@ -350,7 +339,7 @@ pub fn capture_current_layout( .lock() .map_err(|_| AppError::Config("Config lock was poisoned".to_string()))? = next_config.clone(); - logging::append( + let _ = logging::append( &state.config_dir, LogSeverity::Info, Some(&profile_id), @@ -360,7 +349,7 @@ pub fn capture_current_layout( summaries.len(), monitor.name ), - )?; + ); Ok(CaptureLayoutResult { config: next_config, @@ -399,6 +388,7 @@ fn captured_apps_for_windows(monitor: &MonitorInfo, windows: &[WindowInfo]) -> V title_rule: title_rule_for_window(window, duplicate_process), class_name, target_monitor_id: Some(monitor.id.clone()), + captured_display: Some(CapturedDisplay::from_monitor(monitor)), layout: window_actions::relative_rect( monitor, windows::Win32::Foundation::RECT { @@ -408,11 +398,14 @@ fn captured_apps_for_windows(monitor: &MonitorInfo, windows: &[WindowInfo]) -> V bottom: window.y + window.height, }, ), - window_state: WindowStatePreference::Normal, + window_state: if window.is_maximized { + WindowStatePreference::Maximized + } else { + WindowStatePreference::Normal + }, launch_delay_seconds: 0, detection_timeout_seconds: 25, retry_interval_ms: 700, - launch_if_missing: true, move_if_running: true, force_resize: true, apply_to_all_matching_windows: false, @@ -439,20 +432,19 @@ fn window_matches_capture_monitor(window: &WindowInfo, monitor: &MonitorInfo) -> .monitor_id .as_ref() .map(|id| id == &monitor.id) - .unwrap_or(false) - || window_intersects_monitor(window, monitor) + .unwrap_or_else(|| window_center_is_on_monitor(window, monitor)) } -fn window_intersects_monitor(window: &WindowInfo, monitor: &MonitorInfo) -> bool { - let window_right = window.x + window.width; - let window_bottom = window.y + window.height; +fn window_center_is_on_monitor(window: &WindowInfo, monitor: &MonitorInfo) -> bool { + let center_x = window.x + window.width / 2; + let center_y = window.y + window.height / 2; let monitor_right = monitor.x + monitor.width; let monitor_bottom = monitor.y + monitor.height; - window.x < monitor_right - && window_right > monitor.x - && window.y < monitor_bottom - && window_bottom > monitor.y + center_x >= monitor.x + && center_x < monitor_right + && center_y >= monitor.y + && center_y < monitor_bottom } fn is_self_window(window: &WindowInfo) -> bool { @@ -563,6 +555,7 @@ pub fn startup_enabled() -> bool { #[tauri::command] pub fn set_startup_enabled( + app: AppHandle, state: State<'_, AppState>, enabled: bool, ) -> AppResult { @@ -579,6 +572,7 @@ pub fn set_startup_enabled( .lock() .map_err(|_| AppError::Config("Config lock was poisoned".to_string()))? = next_config.clone(); + let _ = tray_ui::sync(&app); Ok(next_config) } @@ -613,17 +607,7 @@ pub fn open_log_file(state: State<'_, AppState>) -> AppResult<()> { #[tauri::command] pub fn show_main_window(app: AppHandle) -> AppResult<()> { - if let Some(window) = app.get_webview_window("main") { - window - .show() - .map_err(|error| AppError::Config(error.to_string()))?; - window - .unminimize() - .map_err(|error| AppError::Config(error.to_string()))?; - window - .set_focus() - .map_err(|error| AppError::Config(error.to_string()))?; - } + crate::show_main_window(&app); Ok(()) } @@ -680,6 +664,7 @@ mod tests { height, is_visible: true, is_minimized: false, + is_maximized: false, } } @@ -707,6 +692,26 @@ mod tests { assert!(!capture_window_candidate(&other_monitor, &target)); } + #[test] + fn capture_uses_monitor_id_before_overlap() { + let target = monitor("display-2", -1920, 0, 1920, 1080); + let barely_overlapping = window("Chat.exe", "Chat", "display-1", -10, 100, 600, 500); + + assert!(!capture_window_candidate(&barely_overlapping, &target)); + } + + #[test] + fn capture_uses_window_center_when_monitor_id_is_missing() { + let target = monitor("display-2", -1920, 0, 1920, 1080); + let mut centered = window("Chat.exe", "Chat", "display-unknown", -1000, 100, 600, 500); + centered.monitor_id = None; + assert!(capture_window_candidate(¢ered, &target)); + + let mut off_target = window("Chat.exe", "Chat", "display-unknown", 100, 100, 600, 500); + off_target.monitor_id = None; + assert!(!capture_window_candidate(&off_target, &target)); + } + #[test] fn captured_apps_use_target_monitor_layout_and_duplicate_title_rules() { let target = monitor("display-2", -1920, 160, 1920, 1080); @@ -742,4 +747,15 @@ mod tests { assert_eq!(obs_rule.value, "OBS"); assert_eq!(apps[2].display_name, "OBS Studio"); } + + #[test] + fn capture_preserves_maximized_window_state() { + let target = monitor("display-1", 0, 0, 1920, 1080); + let mut maximized = window("Editor.exe", "Editor", "display-1", 0, 0, 1920, 1080); + maximized.is_maximized = true; + + let apps = captured_apps_for_windows(&target, &[maximized]); + + assert_eq!(apps[0].window_state, WindowStatePreference::Maximized); + } } diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 1a6520d..c830d3f 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -1,16 +1,27 @@ use std::{ + collections::HashSet, fs, path::{Path, PathBuf}, }; use chrono::Utc; +use regex::RegexBuilder; +use uuid::Uuid; use crate::{ errors::{AppError, AppResult}, - models::{MonitorMissingBehavior, WindowAutoLayoutConfig, APP_VERSION, CONFIG_SCHEMA_VERSION}, + models::{ + AppConfig, CapturedDisplay, MonitorInfo, MonitorMissingBehavior, Profile, + WindowAutoLayoutConfig, APP_VERSION, CONFIG_SCHEMA_VERSION, + }, + monitors::monitor_id_matches, }; pub const CONFIG_FILE_NAME: &str = "config.json"; +const MAX_STARTUP_DELAY_SECONDS: u64 = 300; +const MAX_LAUNCH_DELAY_SECONDS: u64 = 120; +const MAX_LAYOUT_DIMENSION: i32 = 32_768; +const MAX_LAYOUT_OFFSET: i32 = 131_072; pub fn config_file_path(config_dir: &Path) -> PathBuf { config_dir.join(CONFIG_FILE_NAME) @@ -28,7 +39,8 @@ pub fn load_or_create(config_dir: &Path) -> AppResult { } let raw = fs::read_to_string(&path)?; - match serde_json::from_str::(&raw) { + let raw = raw.trim_start_matches('\u{feff}'); + match serde_json::from_str::(raw) { Ok(mut config) => { let mut should_save = false; if config.schema_version != CONFIG_SCHEMA_VERSION { @@ -73,43 +85,425 @@ pub fn save(config_dir: &Path, config: &WindowAutoLayoutConfig) -> AppResult<()> Ok(()) } -fn normalize_config(config: &mut WindowAutoLayoutConfig) -> bool { +pub fn parse_json(raw: &str) -> AppResult { + let mut config = + serde_json::from_str::(raw.trim_start_matches('\u{feff}'))?; + normalize_config(&mut config); + Ok(config) +} + +pub fn normalize_config(config: &mut WindowAutoLayoutConfig) -> bool { let mut changed = false; - if !config.startup.launch_missing_apps { - config.startup.launch_missing_apps = true; + if config.schema_version != CONFIG_SCHEMA_VERSION { + config.schema_version = CONFIG_SCHEMA_VERSION; + changed = true; + } + if config.app_version != APP_VERSION { + config.app_version = APP_VERSION.to_string(); + changed = true; + } + + let startup_delay = config.startup.delay_seconds.min(MAX_STARTUP_DELAY_SECONDS); + if config.startup.delay_seconds != startup_delay { + config.startup.delay_seconds = startup_delay; + changed = true; + } + + if config.profiles.is_empty() { + config.profiles = WindowAutoLayoutConfig::default().profiles; + changed = true; + } + + let mut profile_ids = HashSet::new(); + for (profile_index, profile) in config.profiles.iter_mut().enumerate() { + if profile.id.trim().is_empty() || !profile_ids.insert(profile.id.clone()) { + profile.id = unique_id("profile", &mut profile_ids); + changed = true; + } + if profile.name.trim().is_empty() { + profile.name = format!("Profile {}", profile_index + 1); + changed = true; + } + + let mut app_ids = HashSet::new(); + for (app_index, app) in profile.apps.iter_mut().enumerate() { + if app.id.trim().is_empty() || !app_ids.insert(app.id.clone()) { + app.id = unique_id("app", &mut app_ids); + changed = true; + } + if app.display_name.trim().is_empty() { + app.display_name = app + .process_name + .as_deref() + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(|name| name.trim_end_matches(".exe").to_string()) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| format!("App {}", app_index + 1)); + changed = true; + } + + let launch_delay_seconds = app.launch_delay_seconds.min(MAX_LAUNCH_DELAY_SECONDS); + if app.launch_delay_seconds != launch_delay_seconds { + app.launch_delay_seconds = launch_delay_seconds; + changed = true; + } + + let retry_interval_ms = app.retry_interval_ms.clamp(250, 5000); + if app.retry_interval_ms != retry_interval_ms { + app.retry_interval_ms = retry_interval_ms; + changed = true; + } + + let detection_timeout_seconds = app.detection_timeout_seconds.clamp(1, 120); + if app.detection_timeout_seconds != detection_timeout_seconds { + app.detection_timeout_seconds = detection_timeout_seconds; + changed = true; + } + + let x = app.layout.x.clamp(-MAX_LAYOUT_OFFSET, MAX_LAYOUT_OFFSET); + let y = app.layout.y.clamp(-MAX_LAYOUT_OFFSET, MAX_LAYOUT_OFFSET); + let width = app.layout.width.clamp(80, MAX_LAYOUT_DIMENSION); + let height = app.layout.height.clamp(80, MAX_LAYOUT_DIMENSION); + if ( + app.layout.x, + app.layout.y, + app.layout.width, + app.layout.height, + ) != (x, y, width, height) + { + app.layout.x = x; + app.layout.y = y; + app.layout.width = width; + app.layout.height = height; + changed = true; + } + + let invalid_captured_display = app.captured_display.as_ref().is_some_and(|display| { + !(display.width > 0 + && display.height > 0 + && display.work_width > 0 + && display.work_height > 0) + }); + if invalid_captured_display { + app.captured_display = None; + changed = true; + } else if let Some(display) = &mut app.captured_display { + let width = display.width.clamp(1, MAX_LAYOUT_DIMENSION); + let height = display.height.clamp(1, MAX_LAYOUT_DIMENSION); + let work_x = display.work_x.clamp(0, width.saturating_sub(1)); + let work_y = display.work_y.clamp(0, height.saturating_sub(1)); + let work_width = display.work_width.clamp(1, width - work_x); + let work_height = display.work_height.clamp(1, height - work_y); + let scale_percent = display.scale_percent.clamp(50, 500); + if ( + display.width, + display.height, + display.work_x, + display.work_y, + display.work_width, + display.work_height, + display.scale_percent, + ) != ( + width, + height, + work_x, + work_y, + work_width, + work_height, + scale_percent, + ) { + display.width = width; + display.height = height; + display.work_x = work_x; + display.work_y = work_y; + display.work_width = work_width; + display.work_height = work_height; + display.scale_percent = scale_percent; + changed = true; + } + } + } + } + + let first_profile_id = config.profiles[0].id.clone(); + if !config + .startup + .default_profile_id + .as_ref() + .is_some_and(|id| config.profiles.iter().any(|profile| &profile.id == id)) + { + config.startup.default_profile_id = Some(first_profile_id.clone()); + changed = true; + } + + if !config + .enforcement + .profile_id + .as_ref() + .is_some_and(|id| config.profiles.iter().any(|profile| &profile.id == id)) + { + config.enforcement.profile_id = config + .startup + .default_profile_id + .clone() + .or(Some(first_profile_id)); changed = true; } + changed +} + +pub fn hydrate_captured_displays( + config: &mut WindowAutoLayoutConfig, + monitors: &[MonitorInfo], +) -> bool { + let default_monitor_id = config.global.default_monitor_id.clone(); + let mut changed = false; for profile in &mut config.profiles { + let profile_monitor_id = profile + .target_monitor_id + .as_ref() + .or(default_monitor_id.as_ref()) + .cloned(); for app in &mut profile.apps { - if !app.launch_if_missing { - app.launch_if_missing = true; - changed = true; + if app.captured_display.is_some() { + continue; } + let monitor_id = app + .target_monitor_id + .as_ref() + .or(profile_monitor_id.as_ref()); + let Some(monitor) = monitor_id.and_then(|id| { + monitors + .iter() + .find(|monitor| monitor_id_matches(monitor, id)) + }) else { + continue; + }; + app.captured_display = Some(CapturedDisplay::from_monitor(monitor)); + changed = true; } } + changed +} - if let Some(profile_id) = &config.enforcement.profile_id { - if !config - .profiles - .iter() - .any(|profile| &profile.id == profile_id) - { - config.enforcement.profile_id = None; +pub fn reconcile_monitor_targets( + config: &mut WindowAutoLayoutConfig, + monitors: &[MonitorInfo], +) -> bool { + if monitors.is_empty() { + return false; + } + + let default_profile_size = config + .startup + .default_profile_id + .as_ref() + .and_then(|id| config.profiles.iter().find(|profile| &profile.id == id)) + .or_else(|| config.profiles.first()) + .and_then(profile_target_size); + let default_resolution = config + .global + .default_monitor_id + .as_deref() + .and_then(|id| resolve_saved_monitor(id, default_profile_size, monitors)); + let default_retargeted = default_resolution + .as_ref() + .is_some_and(|resolution| resolution.legacy_retargeted); + let mut changed = false; + if let (Some(saved), Some(resolution)) = ( + config.global.default_monitor_id.as_ref(), + default_resolution.as_ref(), + ) { + if !saved.eq_ignore_ascii_case(&resolution.monitor.id) { + config.global.default_monitor_id = Some(resolution.monitor.id.clone()); changed = true; } } - let interval_ms = config.enforcement.interval_ms.clamp(2000, 5000); - if config.enforcement.interval_ms != interval_ms { - config.enforcement.interval_ms = interval_ms; - changed = true; + let default_monitor_id = config.global.default_monitor_id.clone(); + for profile in &mut config.profiles { + let explicit_profile_target = profile.target_monitor_id.clone(); + let requested_profile_target = explicit_profile_target + .as_deref() + .or(default_monitor_id.as_deref()); + let profile_resolution = requested_profile_target + .and_then(|id| resolve_saved_monitor(id, profile_target_size(profile), monitors)); + let inherited_default_retarget = explicit_profile_target.is_none() && default_retargeted; + let profile_retargeted = profile_resolution + .as_ref() + .is_some_and(|resolution| resolution.legacy_retargeted) + || inherited_default_retarget; + + if explicit_profile_target.is_some() { + if let Some(resolution) = &profile_resolution { + if explicit_profile_target + .as_ref() + .is_some_and(|id| !id.eq_ignore_ascii_case(&resolution.monitor.id)) + { + profile.target_monitor_id = Some(resolution.monitor.id.clone()); + changed = true; + } + } + } + + let inherited_monitor = profile_resolution + .as_ref() + .map(|resolution| &resolution.monitor); + for app in &mut profile.apps { + let explicit_app_target = app.target_monitor_id.clone(); + let app_resolution = explicit_app_target + .as_deref() + .and_then(|id| resolve_saved_monitor(id, app_target_size(app), monitors)); + let app_monitor = app_resolution + .as_ref() + .map(|resolution| &resolution.monitor) + .or(inherited_monitor); + let app_retargeted = app_resolution + .as_ref() + .is_some_and(|resolution| resolution.legacy_retargeted) + || (explicit_app_target.is_none() && profile_retargeted); + + if explicit_app_target.is_some() { + if let Some(resolution) = &app_resolution { + if explicit_app_target + .as_ref() + .is_some_and(|id| !id.eq_ignore_ascii_case(&resolution.monitor.id)) + { + app.target_monitor_id = Some(resolution.monitor.id.clone()); + changed = true; + } + } + } + + if app_retargeted { + if let Some(monitor) = app_monitor { + let captured = CapturedDisplay::from_monitor(monitor); + if app.captured_display.as_ref() != Some(&captured) { + app.captured_display = Some(captured); + changed = true; + } + } + } + } } changed } +struct SavedMonitorResolution<'a> { + monitor: &'a MonitorInfo, + legacy_retargeted: bool, +} + +fn resolve_saved_monitor<'a>( + saved_id: &str, + target_size: Option<(i32, i32)>, + monitors: &'a [MonitorInfo], +) -> Option> { + if let Some(monitor) = monitors.iter().find(|monitor| { + monitor.id.eq_ignore_ascii_case(saved_id) + && !monitor.device_name.eq_ignore_ascii_case(saved_id) + }) { + return Some(SavedMonitorResolution { + monitor, + legacy_retargeted: false, + }); + } + + let legacy_match = monitors + .iter() + .find(|monitor| monitor.device_name.eq_ignore_ascii_case(saved_id)) + .or_else(|| { + monitors + .iter() + .find(|monitor| monitor.id.eq_ignore_ascii_case(saved_id)) + })?; + let Some((target_width, target_height)) = target_size else { + return Some(SavedMonitorResolution { + monitor: legacy_match, + legacy_retargeted: false, + }); + }; + + let overflows_legacy = target_width > legacy_match.width.saturating_add(16) + || target_height > legacy_match.height.saturating_add(16); + if overflows_legacy { + if let Some(best) = monitors + .iter() + .filter(|monitor| { + target_width <= monitor.width.saturating_add(16) + && target_height <= monitor.height.saturating_add(16) + }) + .min_by_key(|monitor| monitor_size_score(monitor, target_width, target_height)) + { + if best.id != legacy_match.id { + return Some(SavedMonitorResolution { + monitor: best, + legacy_retargeted: true, + }); + } + } + } + + Some(SavedMonitorResolution { + monitor: legacy_match, + legacy_retargeted: false, + }) +} + +fn profile_target_size(profile: &Profile) -> Option<(i32, i32)> { + let left = profile.apps.iter().map(|app| app.layout.x).min()?.min(0); + let top = profile.apps.iter().map(|app| app.layout.y).min()?.min(0); + let right = profile + .apps + .iter() + .map(|app| app.layout.x.saturating_add(app.layout.width)) + .max()?; + let bottom = profile + .apps + .iter() + .map(|app| app.layout.y.saturating_add(app.layout.height)) + .max()?; + Some(( + right.saturating_sub(left).max(1), + bottom.saturating_sub(top).max(1), + )) +} + +fn app_target_size(app: &AppConfig) -> Option<(i32, i32)> { + let left = app.layout.x.min(0); + let top = app.layout.y.min(0); + Some(( + app.layout + .x + .saturating_add(app.layout.width) + .saturating_sub(left) + .max(1), + app.layout + .y + .saturating_add(app.layout.height) + .saturating_sub(top) + .max(1), + )) +} + +fn monitor_size_score(monitor: &MonitorInfo, width: i32, height: i32) -> u64 { + i64::from(monitor.width).abs_diff(i64::from(width)) + + i64::from(monitor.height).abs_diff(i64::from(height)) +} + +fn unique_id(prefix: &str, seen: &mut HashSet) -> String { + loop { + let candidate = format!("{prefix}-{}", Uuid::new_v4()); + if seen.insert(candidate.clone()) { + return candidate; + } + } +} + fn migrate_config(config: &mut WindowAutoLayoutConfig) { if config.schema_version < 3 && matches!( @@ -130,12 +524,26 @@ pub fn backup_config(path: &Path, reason: &str) -> AppResult { pub fn validate_config(config: &WindowAutoLayoutConfig) -> Vec { let mut issues = Vec::new(); + let mut profile_ids = HashSet::new(); + if config.profiles.is_empty() { + issues.push("At least one profile is required".to_string()); + } for profile in &config.profiles { + if profile.id.trim().is_empty() || !profile_ids.insert(profile.id.as_str()) { + issues.push("Profile IDs must be present and unique".to_string()); + } if profile.name.trim().is_empty() { issues.push(format!("Profile {} has an empty name", profile.id)); } + let mut app_ids = HashSet::new(); for app in &profile.apps { + if app.id.trim().is_empty() || !app_ids.insert(app.id.as_str()) { + issues.push(format!( + "{} contains duplicate or empty app IDs", + profile.name + )); + } if app.display_name.trim().is_empty() { issues.push(format!( "Profile {} contains an app with an empty display name", @@ -148,12 +556,40 @@ pub fn validate_config(config: &WindowAutoLayoutConfig) -> Vec { app.display_name, app.layout.width, app.layout.height )); } - if app.retry_interval_ms < 100 { + if app.retry_interval_ms < 250 { issues.push(format!( - "{} has a retry interval below 100 ms", + "{} has a retry interval below 250 ms", app.display_name )); } + if app + .process_name + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_none() + && app + .executable_path + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_none() + { + issues.push(format!( + "{} needs a process name or executable path", + app.display_name + )); + } + if let Some(rule) = &app.title_rule { + if matches!(rule.mode, crate::models::TitleMatchMode::Regex) + && RegexBuilder::new(&rule.value) + .case_insensitive(!rule.case_sensitive) + .build() + .is_err() + { + issues.push(format!("{} has an invalid title regex", app.display_name)); + } + } } } issues @@ -163,6 +599,31 @@ pub fn validate_config(config: &WindowAutoLayoutConfig) -> Vec { mod tests { use super::*; + fn test_monitor( + id: &str, + device_name: &str, + width: i32, + height: i32, + work_height: i32, + is_primary: bool, + ) -> MonitorInfo { + MonitorInfo { + id: id.into(), + name: device_name.into(), + device_name: device_name.into(), + x: 0, + y: 0, + width, + height, + work_x: 0, + work_y: 0, + work_width: width, + work_height, + scale_factor: if width >= 3840 { 1.5 } else { 1.25 }, + is_primary, + } + } + #[test] fn default_config_round_trips() { let config = WindowAutoLayoutConfig::default(); @@ -174,8 +635,6 @@ mod tests { parsed.global.monitor_missing_behavior, MonitorMissingBehavior::NearestMatch ); - assert_eq!(parsed.enforcement.interval_ms, 2000); - assert!(parsed.enforcement.pause_for_fullscreen_games); } #[test] @@ -203,11 +662,247 @@ mod tests { } #[test] - fn normalizes_lock_interval_for_low_impact_polling() { + fn normalizes_active_restore_retry_limits() { + let mut config = WindowAutoLayoutConfig::default(); + config.profiles[0].apps[0].retry_interval_ms = 10; + config.profiles[0].apps[0].detection_timeout_seconds = 900; + + assert!(normalize_config(&mut config)); + assert_eq!(config.profiles[0].apps[0].retry_interval_ms, 250); + assert_eq!(config.profiles[0].apps[0].detection_timeout_seconds, 120); + } + + #[test] + fn preserves_launch_missing_apps_preference() { + let mut config = WindowAutoLayoutConfig::default(); + config.startup.launch_missing_apps = false; + + normalize_config(&mut config); + + assert!(!config.startup.launch_missing_apps); + } + + #[test] + fn normalizes_captured_work_area_inside_display_bounds() { let mut config = WindowAutoLayoutConfig::default(); - config.enforcement.interval_ms = 150; + config.profiles[0].apps[0].captured_display = Some(CapturedDisplay { + width: 1920, + height: 1080, + work_x: -50, + work_y: 2000, + work_width: 4000, + work_height: 4000, + scale_percent: 900, + }); assert!(normalize_config(&mut config)); - assert_eq!(config.enforcement.interval_ms, 2000); + + let display = config.profiles[0].apps[0] + .captured_display + .as_ref() + .expect("captured display"); + assert_eq!(display.work_x, 0); + assert_eq!(display.work_y, 1079); + assert_eq!(display.work_width, 1920); + assert_eq!(display.work_height, 1); + assert_eq!(display.scale_percent, 500); + } + + #[test] + fn save_replaces_an_existing_config() { + let config_dir = + std::env::temp_dir().join(format!("windowautolayout-config-test-{}", Uuid::new_v4())); + let first = WindowAutoLayoutConfig::default(); + save(&config_dir, &first).expect("save initial config"); + + let mut second = first; + second.startup.launch_missing_apps = false; + save(&config_dir, &second).expect("replace config"); + + let raw = fs::read_to_string(config_file_path(&config_dir)).expect("read config"); + let loaded: WindowAutoLayoutConfig = serde_json::from_str(&raw).expect("parse config"); + assert!(!loaded.startup.launch_missing_apps); + let _ = fs::remove_dir_all(config_dir); + } + + #[test] + fn hydrates_legacy_app_display_geometry_from_its_saved_target() { + let mut config = WindowAutoLayoutConfig::default(); + config.profiles[0].target_monitor_id = Some("display-2".into()); + let monitor = MonitorInfo { + id: "display-2".into(), + name: "Display 2".into(), + device_name: "DISPLAY2".into(), + x: -3840, + y: 0, + width: 3840, + height: 2160, + work_x: -3840, + work_y: 0, + work_width: 3840, + work_height: 2080, + scale_factor: 1.5, + is_primary: false, + }; + + assert!(hydrate_captured_displays(&mut config, &[monitor])); + let captured = config.profiles[0].apps[0] + .captured_display + .as_ref() + .expect("captured display"); + assert_eq!(captured.width, 3840); + assert_eq!(captured.work_height, 2080); + assert_eq!(captured.scale_percent, 150); + } + + #[test] + fn repairs_legacy_display_number_after_windows_swaps_monitor_names() { + let mut config = WindowAutoLayoutConfig::default(); + config.global.default_monitor_id = Some(r"\\.\DISPLAY2".into()); + let profile = &mut config.profiles[0]; + profile.target_monitor_id = Some(r"\\.\DISPLAY2".into()); + profile.apps[0].target_monitor_id = Some(r"\\.\DISPLAY2".into()); + profile.apps[0].layout = crate::models::LayoutRect { + x: 1920, + y: 0, + width: 1920, + height: 1080, + }; + profile.apps[1].target_monitor_id = Some(r"\\.\DISPLAY2".into()); + profile.apps[1].layout = crate::models::LayoutRect { + x: -6, + y: 1078, + width: 1914, + height: 1007, + }; + let wrong_capture = CapturedDisplay::from_monitor(&test_monitor( + "hardware-1440", + r"\\.\DISPLAY2", + 2560, + 1440, + 1380, + true, + )); + for app in &mut profile.apps { + app.captured_display = Some(wrong_capture.clone()); + } + + let monitors = vec![ + test_monitor("hardware-1440", r"\\.\DISPLAY2", 2560, 1440, 1380, true), + test_monitor("hardware-4k", r"\\.\DISPLAY1", 3840, 2160, 2088, false), + ]; + + assert!(reconcile_monitor_targets(&mut config, &monitors)); + assert_eq!( + config.global.default_monitor_id.as_deref(), + Some("hardware-4k") + ); + assert_eq!( + config.profiles[0].target_monitor_id.as_deref(), + Some("hardware-4k") + ); + for app in &config.profiles[0].apps { + assert_eq!(app.target_monitor_id.as_deref(), Some("hardware-4k")); + assert_eq!( + app.captured_display.as_ref().map(|display| display.width), + Some(3840) + ); + } + } + + #[test] + fn trusts_an_explicit_stable_monitor_identity() { + let mut config = WindowAutoLayoutConfig::default(); + config.global.default_monitor_id = Some("hardware-1440".into()); + config.profiles[0].target_monitor_id = Some("hardware-1440".into()); + config.profiles[0].apps[0].layout = crate::models::LayoutRect { + x: 1920, + y: 0, + width: 1920, + height: 1080, + }; + let monitors = vec![ + test_monitor("hardware-1440", r"\\.\DISPLAY2", 2560, 1440, 1380, true), + test_monitor("hardware-4k", r"\\.\DISPLAY1", 3840, 2160, 2088, false), + ]; + + reconcile_monitor_targets(&mut config, &monitors); + + assert_eq!( + config.global.default_monitor_id.as_deref(), + Some("hardware-1440") + ); + assert_eq!( + config.profiles[0].target_monitor_id.as_deref(), + Some("hardware-1440") + ); + } + + #[test] + fn legacy_ask_next_open_value_migrates_to_do_nothing() { + let json = serde_json::to_string(&WindowAutoLayoutConfig::default()) + .expect("serialize") + .replace("nearestMatch", "askNextOpen"); + + let parsed = parse_json(&json).expect("parse legacy config"); + + assert_eq!( + parsed.global.monitor_missing_behavior, + MonitorMissingBehavior::DoNothing + ); + } + + #[test] + fn normalization_repairs_ids_delays_and_unsafe_layout_bounds() { + let mut config = WindowAutoLayoutConfig::default(); + let duplicate = config.profiles[0].apps[0].clone(); + config.profiles[0].apps.push(duplicate); + config.profiles[0].id.clear(); + config.profiles[0].name.clear(); + config.profiles[0].apps[0].id.clear(); + config.profiles[0].apps[0].launch_delay_seconds = u64::MAX; + config.profiles[0].apps[0].layout.width = i32::MAX; + config.startup.default_profile_id = Some("missing".into()); + config.enforcement.profile_id = Some("missing".into()); + + assert!(normalize_config(&mut config)); + + let profile = &config.profiles[0]; + assert!(!profile.id.is_empty()); + assert_eq!(profile.name, "Profile 1"); + assert_ne!(profile.apps[0].id, profile.apps[1].id); + assert_eq!( + profile.apps[0].launch_delay_seconds, + MAX_LAUNCH_DELAY_SECONDS + ); + assert_eq!(profile.apps[0].layout.width, MAX_LAYOUT_DIMENSION); + assert_eq!( + config.startup.default_profile_id.as_deref(), + Some(profile.id.as_str()) + ); + assert_eq!( + config.enforcement.profile_id.as_deref(), + Some(profile.id.as_str()) + ); + } + + #[test] + fn validation_reports_invalid_regex_and_missing_process_identity() { + let mut config = WindowAutoLayoutConfig::default(); + let app = &mut config.profiles[0].apps[0]; + app.process_name = None; + app.executable_path = None; + app.title_rule = Some(crate::models::MatchRule { + mode: crate::models::TitleMatchMode::Regex, + value: "[".into(), + case_sensitive: false, + }); + + let issues = validate_config(&config); + + assert!(issues.iter().any(|issue| issue.contains("process name"))); + assert!(issues + .iter() + .any(|issue| issue.contains("invalid title regex"))); } } diff --git a/src-tauri/src/desktop_guard.rs b/src-tauri/src/desktop_guard.rs new file mode 100644 index 0000000..a52f4d3 --- /dev/null +++ b/src-tauri/src/desktop_guard.rs @@ -0,0 +1,412 @@ +use std::{ + sync::{Arc, Condvar, Mutex, OnceLock}, + thread, + time::{Duration, Instant}, +}; + +use tauri::{AppHandle, Manager}; +use windows::Win32::{ + Foundation::HWND, + UI::{ + Accessibility::{SetWinEventHook, HWINEVENTHOOK}, + WindowsAndMessaging::{ + DispatchMessageW, GetMessageW, TranslateMessage, EVENT_SYSTEM_FOREGROUND, + EVENT_SYSTEM_MINIMIZESTART, MSG, WINEVENT_OUTOFCONTEXT, WINEVENT_SKIPOWNPROCESS, + }, + }, +}; + +use crate::{ + layout_lock, logging, + models::{LogSeverity, RestoreStatus, WindowAutoLayoutConfig, WindowInfo}, + performance, profiles, + state::AppState, + windows_enum, +}; + +const DESKTOP_RESTORE_DELAY: Duration = Duration::from_millis(500); +const GAME_EXIT_RESTORE_DELAY: Duration = Duration::from_millis(750); +const RESTORE_DEBOUNCE: Duration = Duration::from_secs(3); +const RECENT_MINIMIZE_WINDOW: Duration = Duration::from_secs(2); + +static GUARD: OnceLock> = OnceLock::new(); + +pub fn start(app: AppHandle) { + let guard = Arc::new(DesktopGuard { + app, + timing: Mutex::new(GuardTiming { + latency_sensitive_foreground: performance::foreground_is_latency_sensitive(), + ..GuardTiming::default() + }), + schedule: Mutex::new(None), + schedule_changed: Condvar::new(), + }); + if GUARD.set(guard.clone()).is_err() { + return; + } + + let restore_guard = guard.clone(); + if let Err(error) = thread::Builder::new() + .name("windowautolayout-restore-worker".into()) + .spawn(move || restore_guard.run_restore_worker()) + { + let state = guard.app.state::(); + let _ = logging::append( + &state.config_dir, + LogSeverity::Error, + None, + None, + format!("Could not start restore worker: {error}"), + ); + } + let config_dir = guard.app.state::().config_dir.clone(); + if let Err(error) = thread::Builder::new() + .name("windowautolayout-desktop-events".into()) + .spawn(move || run_event_thread(config_dir)) + { + let state = guard.app.state::(); + let _ = logging::append( + &state.config_dir, + LogSeverity::Error, + None, + None, + format!("Could not start desktop event worker: {error}"), + ); + } +} + +struct DesktopGuard { + app: AppHandle, + timing: Mutex, + schedule: Mutex>, + schedule_changed: Condvar, +} + +#[derive(Default)] +struct GuardTiming { + latency_sensitive_foreground: bool, + last_minimize_start: Option, + last_restore: Option, +} + +#[derive(Clone, Copy)] +enum RestoreTrigger { + DesktopReveal, + GameExit, +} + +impl DesktopGuard { + fn handle_event(&self, event: u32, hwnd: HWND) { + if hwnd.0.is_null() || profiles::restore_events_suppressed() { + return; + } + + match event { + EVENT_SYSTEM_FOREGROUND => self.handle_foreground(hwnd), + EVENT_SYSTEM_MINIMIZESTART => self.handle_minimize_start(), + _ => {} + } + } + + fn handle_foreground(&self, hwnd: HWND) { + let Some(window) = windows_enum::window_info_from_handle_lightweight(hwnd) else { + return; + }; + let is_latency_sensitive = performance::is_latency_sensitive_window(&window); + let (left_latency_sensitive, desktop_reveal) = { + let Ok(mut timing) = self.timing.lock() else { + return; + }; + let was_latency_sensitive = timing.latency_sensitive_foreground; + timing.latency_sensitive_foreground = is_latency_sensitive; + let recent_minimize = timing + .last_minimize_start + .map(|at| at.elapsed() <= RECENT_MINIMIZE_WINDOW) + .unwrap_or(false); + + ( + was_latency_sensitive && !is_latency_sensitive && !is_own_window(&window), + is_desktop_shell_window(&window) + || (recent_minimize && is_taskbar_shell_window(&window)), + ) + }; + + if desktop_reveal { + self.request_restore(DESKTOP_RESTORE_DELAY, RestoreTrigger::DesktopReveal); + } else if left_latency_sensitive { + self.request_restore(GAME_EXIT_RESTORE_DELAY, RestoreTrigger::GameExit); + } + } + + fn handle_minimize_start(&self) { + let Ok(mut timing) = self.timing.lock() else { + return; + }; + timing.last_minimize_start = Some(Instant::now()); + } + + fn request_restore(&self, delay: Duration, trigger: RestoreTrigger) { + if !layout_lock::enabled(&self.app).unwrap_or(false) { + return; + } + let trigger_enabled = self + .app + .try_state::() + .and_then(|state| { + state + .config + .lock() + .ok() + .map(|config| restore_trigger_enabled(&config, trigger)) + }) + .unwrap_or(false); + if !trigger_enabled { + return; + } + + { + let Ok(timing) = self.timing.lock() else { + return; + }; + if timing + .last_restore + .map(|at| at.elapsed() < RESTORE_DEBOUNCE) + .unwrap_or(false) + { + return; + } + } + + let Ok(mut schedule) = self.schedule.lock() else { + return; + }; + if schedule.is_none() { + *schedule = Some(Instant::now() + delay); + self.schedule_changed.notify_one(); + } + } + + fn run_restore_worker(&self) { + performance::lower_current_thread_priority(); + + loop { + { + let Ok(mut schedule) = self.schedule.lock() else { + return; + }; + loop { + let Some(deadline) = *schedule else { + let Ok(next) = self.schedule_changed.wait(schedule) else { + return; + }; + schedule = next; + continue; + }; + let now = Instant::now(); + if deadline <= now { + *schedule = None; + break; + } + + let Ok((next, wait)) = self + .schedule_changed + .wait_timeout(schedule, deadline.saturating_duration_since(now)) + else { + return; + }; + schedule = next; + if wait.timed_out() && *schedule == Some(deadline) { + *schedule = None; + break; + } + } + } + + if profiles::restore_events_suppressed() + || !layout_lock::enabled(&self.app).unwrap_or(false) + || performance::foreground_is_latency_sensitive() + { + continue; + } + + let state = self.app.state::(); + let config_dir = state.config_dir.clone(); + let Ok(config) = state.config.lock().map(|config| config.clone()) else { + continue; + }; + let profile_id = config + .enforcement + .profile_id + .clone() + .or_else(|| config.startup.default_profile_id.clone()); + if profiles::restore_profile_silent(&config_dir, &config, profile_id, Some(false)) + .is_ok_and(|result| !matches!(result.status, RestoreStatus::Paused)) + { + if let Ok(mut timing) = self.timing.lock() { + timing.last_restore = Some(Instant::now()); + } + } + } + } +} + +fn restore_trigger_enabled(config: &WindowAutoLayoutConfig, trigger: RestoreTrigger) -> bool { + match trigger { + RestoreTrigger::DesktopReveal => config.enforcement.restore_on_desktop_reveal, + RestoreTrigger::GameExit => config.enforcement.restore_after_game_exit, + } +} + +fn run_event_thread(config_dir: std::path::PathBuf) { + performance::lower_current_thread_priority(); + let hook_foreground = install_hook(EVENT_SYSTEM_FOREGROUND); + let hook_minimize = install_hook(EVENT_SYSTEM_MINIMIZESTART); + if hook_foreground.0.is_null() && hook_minimize.0.is_null() { + let _ = logging::append( + &config_dir, + LogSeverity::Error, + None, + None, + "Could not register Windows desktop event hooks", + ); + return; + } + if hook_foreground.0.is_null() || hook_minimize.0.is_null() { + let _ = logging::append( + &config_dir, + LogSeverity::Warn, + None, + None, + "One Windows desktop event hook could not be registered", + ); + } + + let mut message = MSG::default(); + unsafe { + loop { + let result = GetMessageW(&mut message, None, 0, 0); + if result.0 <= 0 { + break; + } + let _ = TranslateMessage(&message); + DispatchMessageW(&message); + } + if !hook_foreground.0.is_null() { + let _ = windows::Win32::UI::Accessibility::UnhookWinEvent(hook_foreground); + } + if !hook_minimize.0.is_null() { + let _ = windows::Win32::UI::Accessibility::UnhookWinEvent(hook_minimize); + } + } +} + +fn install_hook(event: u32) -> HWINEVENTHOOK { + unsafe { + SetWinEventHook( + event, + event, + None, + Some(win_event_proc), + 0, + 0, + WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS, + ) + } +} + +unsafe extern "system" fn win_event_proc( + _hook: HWINEVENTHOOK, + event: u32, + hwnd: HWND, + _object_id: i32, + _child_id: i32, + _event_thread: u32, + _event_time: u32, +) { + if let Some(guard) = GUARD.get() { + guard.handle_event(event, hwnd); + } +} + +fn is_own_window(window: &WindowInfo) -> bool { + matches!( + window.process_name.trim().to_ascii_lowercase().as_str(), + "windowautolayout.exe" | "windowautolayout" + ) +} + +fn is_desktop_shell_window(window: &WindowInfo) -> bool { + let class = window.class_name.trim().to_ascii_lowercase(); + let title = window.title.trim().to_ascii_lowercase(); + let process = window.process_name.trim().to_ascii_lowercase(); + + process == "explorer.exe" + && (class == "progman" || class == "workerw" || title == "program manager") +} + +fn is_taskbar_shell_window(window: &WindowInfo) -> bool { + let class = window.class_name.trim().to_ascii_lowercase(); + let process = window.process_name.trim().to_ascii_lowercase(); + + process == "explorer.exe" && class == "shell_traywnd" +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_desktop_shell_windows() { + let window = test_window("explorer.exe", "Program Manager", "Progman"); + + assert!(is_desktop_shell_window(&window)); + assert!(!is_taskbar_shell_window(&window)); + } + + #[test] + fn detects_taskbar_shell_window_separately() { + let window = test_window("explorer.exe", "", "Shell_TrayWnd"); + + assert!(!is_desktop_shell_window(&window)); + assert!(is_taskbar_shell_window(&window)); + } + + #[test] + fn ignores_own_windows() { + let window = test_window("WindowAutoLayout.exe", "WindowAutoLayout", "Tauri Window"); + + assert!(is_own_window(&window)); + } + + #[test] + fn trigger_preferences_are_independent() { + let mut config = WindowAutoLayoutConfig::default(); + config.enforcement.restore_on_desktop_reveal = false; + config.enforcement.restore_after_game_exit = true; + + assert!(!restore_trigger_enabled( + &config, + RestoreTrigger::DesktopReveal + )); + assert!(restore_trigger_enabled(&config, RestoreTrigger::GameExit)); + } + + fn test_window(process_name: &str, title: &str, class_name: &str) -> WindowInfo { + WindowInfo { + handle: "0x1".into(), + title: title.into(), + class_name: class_name.into(), + process_id: 10, + process_name: process_name.into(), + executable_path: None, + monitor_id: None, + x: 0, + y: 0, + width: 100, + height: 100, + is_visible: true, + is_minimized: false, + is_maximized: false, + } + } +} diff --git a/src-tauri/src/errors.rs b/src-tauri/src/errors.rs index 6b83bd4..93bf3b7 100644 --- a/src-tauri/src/errors.rs +++ b/src-tauri/src/errors.rs @@ -21,6 +21,8 @@ pub enum AppError { InvalidWindowHandle(String), #[error("Invalid executable path: {0}")] InvalidExecutablePath(String), + #[error("A layout restore is already in progress")] + RestoreInProgress, } impl Serialize for AppError { diff --git a/src-tauri/src/launcher.rs b/src-tauri/src/launcher.rs index f5fecd5..da2dc54 100644 --- a/src-tauri/src/launcher.rs +++ b/src-tauri/src/launcher.rs @@ -14,6 +14,7 @@ use winreg::{ use crate::{ errors::{AppError, AppResult}, models::AppConfig, + processes, }; #[derive(Debug, Clone, PartialEq, Eq)] @@ -47,13 +48,8 @@ pub fn launch_app_with_path( command.args(&target.arguments); command.args(&app.arguments); - if let Some(working_directory) = &app.working_directory { - let working_directory = working_directory.trim(); - if !working_directory.is_empty() && Path::new(working_directory).exists() { - command.current_dir(working_directory); - } - } else if let Some(parent) = target.path.parent() { - command.current_dir(parent); + if let Some(working_directory) = launch_working_directory(app, &target.path) { + command.current_dir(working_directory); } let child = command.spawn()?; @@ -71,6 +67,17 @@ fn launch_candidates(app: &AppConfig, preferred_path: Option<&str>) -> Vec, path: Option<&str>) { } fn resolve_candidate(candidate: LaunchTarget) -> Option { + if candidate.use_shell { + return Some(candidate); + } if candidate.path.exists() { return Some(candidate); } @@ -383,13 +393,21 @@ fn start_menu_candidates(app: &AppConfig, process_name: &str) -> Vec) { +fn collect_shortcuts( + root: &Path, + targets: &[String], + candidates: &mut Vec, + depth: usize, +) { + if depth >= 6 || candidates.len() >= 64 { + return; + } let Ok(entries) = fs::read_dir(root) else { return; }; @@ -397,7 +415,7 @@ fn collect_shortcuts(root: &Path, targets: &[String], candidates: &mut Vec= 64 { + return; + } } } +fn launch_working_directory(app: &AppConfig, target: &Path) -> Option { + app.working_directory + .as_deref() + .map(str::trim) + .filter(|path| !path.is_empty()) + .map(PathBuf::from) + .filter(|path| path.is_dir()) + .or_else(|| target.parent().map(Path::to_path_buf)) +} + fn common_install_roots() -> Vec { [ "LOCALAPPDATA", @@ -523,6 +554,13 @@ fn has_path_separator(path: &Path) -> bool { } fn is_shell_item(path: &str) -> bool { + if path + .trim() + .to_ascii_lowercase() + .starts_with("shell:appsfolder\\") + { + return true; + } let ext = Path::new(path) .extension() .map(|ext| ext.to_string_lossy().to_ascii_lowercase()) @@ -592,12 +630,12 @@ mod tests { title_rule: None, class_name: None, target_monitor_id: None, + captured_display: None, layout: LayoutRect::default(), window_state: WindowStatePreference::Normal, launch_delay_seconds: 0, detection_timeout_seconds: 1, retry_interval_ms: 100, - launch_if_missing: true, move_if_running: true, force_resize: true, apply_to_all_matching_windows: false, @@ -661,4 +699,31 @@ mod tests { .contains("githubdesktop") })); } + + #[test] + fn store_app_candidates_use_package_family_shell_identity() { + let mut codex = app("Codex", "Codex.exe"); + codex.executable_path = Some( + r"C:\Program Files\WindowsApps\OpenAI.Codex_26.623.5546.0_x64__2p2nqsd0c76g0\app\Codex.exe" + .into(), + ); + + let candidates = launch_candidates(&codex, None); + assert!(candidates.iter().any(|candidate| { + candidate.use_shell + && candidate.path.to_string_lossy() + == r"shell:AppsFolder\OpenAI.Codex_2p2nqsd0c76g0!App" + })); + } + + #[test] + fn invalid_working_directory_falls_back_to_executable_parent() { + let mut app = app("Editor", "Editor.exe"); + app.working_directory = Some(r"Z:\missing\folder".into()); + + assert_eq!( + launch_working_directory(&app, Path::new(r"C:\Apps\Editor\Editor.exe")), + Some(PathBuf::from(r"C:\Apps\Editor")) + ); + } } diff --git a/src-tauri/src/layout_lock.rs b/src-tauri/src/layout_lock.rs index 0ae5b67..df03dea 100644 --- a/src-tauri/src/layout_lock.rs +++ b/src-tauri/src/layout_lock.rs @@ -1,21 +1,14 @@ -use std::{thread, time::Duration}; - use tauri::{AppHandle, Manager}; use crate::{ config, errors::{AppError, AppResult}, logging, - models::{LogSeverity, MonitorInfo, WindowAutoLayoutConfig, WindowInfo}, - monitors, profiles, + models::{LogSeverity, WindowAutoLayoutConfig}, state::AppState, - windows_enum, + tray_ui, }; -const MIN_LOCK_INTERVAL_MS: u64 = 2_000; -const MAX_LOCK_INTERVAL_MS: u64 = 5_000; -const FULLSCREEN_PAUSE_INTERVAL_MS: u64 = 10_000; - pub fn enabled(app: &AppHandle) -> AppResult { let state = app.state::(); let enabled = state @@ -28,7 +21,7 @@ pub fn enabled(app: &AppHandle) -> AppResult { pub fn set(app: &AppHandle, enabled: bool, profile_id: Option) -> AppResult { let state = app.state::(); - let (generation, next_config) = { + { let mut lock = state .layout_lock .lock() @@ -38,252 +31,56 @@ pub fn set(app: &AppHandle, enabled: bool, profile_id: Option) -> AppRes .lock() .map_err(|_| AppError::Config("Config lock was poisoned".to_string()))?; let next_profile_id = profile_id - .clone() .or_else(|| lock.profile_id.clone()) .or_else(|| config.enforcement.profile_id.clone()) .or_else(|| config.startup.default_profile_id.clone()); + let mut next_config = config.clone(); + next_config.enforcement.enabled = enabled; + next_config.enforcement.profile_id = next_profile_id.clone(); + config::save(&state.config_dir, &next_config)?; + lock.generation = lock.generation.wrapping_add(1); lock.enabled = enabled; lock.profile_id = next_profile_id.clone(); - config.enforcement.enabled = enabled; - config.enforcement.profile_id = next_profile_id; - (lock.generation, config.clone()) - }; - config::save(&state.config_dir, &next_config)?; + *config = next_config; + } - logging::append( + let _ = logging::append( &state.config_dir, LogSeverity::Info, None, None, if enabled { - "Layout lock enabled" + "Automatic restore enabled" } else { - "Layout lock disabled" + "Automatic restore disabled" }, - )?; - - if enabled { - spawn(app.clone(), generation); - } + ); + let _ = tray_ui::sync(app); Ok(enabled) } pub fn sync_from_config(app: &AppHandle, config: &WindowAutoLayoutConfig) -> AppResult<()> { let state = app.state::(); - let (enabled, generation) = { - let mut lock = state - .layout_lock - .lock() - .map_err(|_| AppError::Config("Layout lock state was poisoned".to_string()))?; - let next_profile_id = config - .enforcement - .profile_id - .clone() - .or_else(|| config.startup.default_profile_id.clone()); - - if lock.enabled == config.enforcement.enabled && lock.profile_id == next_profile_id { - return Ok(()); - } - + let mut lock = state + .layout_lock + .lock() + .map_err(|_| AppError::Config("Layout lock state was poisoned".to_string()))?; + let next_profile_id = config + .enforcement + .profile_id + .clone() + .or_else(|| config.startup.default_profile_id.clone()); + + if lock.enabled != config.enforcement.enabled || lock.profile_id != next_profile_id { lock.generation = lock.generation.wrapping_add(1); lock.enabled = config.enforcement.enabled; lock.profile_id = next_profile_id; - (lock.enabled, lock.generation) - }; - - if enabled { - spawn(app.clone(), generation); } + drop(lock); + let _ = tray_ui::sync(app); Ok(()) } - -pub fn toggle(app: &AppHandle, profile_id: Option) -> AppResult { - let next = !enabled(app)?; - set(app, next, profile_id) -} - -fn spawn(app: AppHandle, generation: u64) { - let mut allow_launch_missing = true; - let mut last_signature: Option = None; - let mut force_restore = true; - thread::spawn(move || loop { - let (config_dir, config, profile_id, interval_ms) = { - let state = app.state::(); - let lock = match state.layout_lock.lock() { - Ok(lock) => lock.clone(), - Err(_) => break, - }; - if !lock.enabled || lock.generation != generation { - break; - } - - let config = match state.config.lock() { - Ok(config) => config.clone(), - Err(_) => break, - }; - let interval_ms = config - .enforcement - .interval_ms - .clamp(MIN_LOCK_INTERVAL_MS, MAX_LOCK_INTERVAL_MS); - ( - state.config_dir.clone(), - config, - lock.profile_id.clone(), - interval_ms, - ) - }; - - let sleep_ms = if should_pause_for_fullscreen(&config, profile_id.as_deref()) { - FULLSCREEN_PAUSE_INTERVAL_MS - } else { - let current_signature = - profiles::profile_window_signature(&config, profile_id.as_deref()).ok(); - let should_restore = should_restore_for_signature( - force_restore, - current_signature.as_ref(), - last_signature.as_ref(), - ); - - if should_restore { - let _ = profiles::restore_profile_silent( - &config_dir, - &config, - profile_id.clone(), - Some(allow_launch_missing), - ); - allow_launch_missing = false; - force_restore = false; - last_signature = profiles::profile_window_signature(&config, profile_id.as_deref()) - .ok() - .or(current_signature); - } - - interval_ms - }; - - thread::sleep(Duration::from_millis(sleep_ms)); - }); -} - -fn should_restore_for_signature( - force_restore: bool, - current_signature: Option<&String>, - last_signature: Option<&String>, -) -> bool { - force_restore - || current_signature - .zip(last_signature) - .map(|(current, last)| current != last) - .unwrap_or(true) -} - -fn should_pause_for_fullscreen(config: &WindowAutoLayoutConfig, profile_id: Option<&str>) -> bool { - if !config.enforcement.pause_for_fullscreen_games { - return false; - } - - let Some(window) = windows_enum::foreground_window() else { - return false; - }; - if !is_fullscreen_window(&window) { - return false; - } - - profiles::resolve_profile(config, profile_id) - .map(|profile| !profiles::window_matches_profile_app(profile, &window)) - .unwrap_or(true) -} - -fn is_fullscreen_window(window: &WindowInfo) -> bool { - if !window.is_visible || window.is_minimized { - return false; - } - - monitors::list_monitors() - .unwrap_or_default() - .iter() - .any(|monitor| covers_monitor(window, monitor)) -} - -fn covers_monitor(window: &WindowInfo, monitor: &MonitorInfo) -> bool { - let tolerance = 4; - (window.x - monitor.x).abs() <= tolerance - && (window.y - monitor.y).abs() <= tolerance - && window.width >= monitor.width - tolerance - && window.height >= monitor.height - tolerance -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn detects_monitor_covering_windows() { - let monitor = MonitorInfo { - id: "display".into(), - name: "Display".into(), - device_name: "DISPLAY1".into(), - x: 0, - y: 0, - width: 2560, - height: 1440, - work_x: 0, - work_y: 0, - work_width: 2560, - work_height: 1392, - scale_factor: 1.25, - is_primary: true, - }; - let fullscreen = WindowInfo { - handle: "0x1".into(), - title: "Game".into(), - class_name: "GameWindow".into(), - process_id: 10, - process_name: "game.exe".into(), - executable_path: None, - monitor_id: Some("display".into()), - x: 0, - y: 0, - width: 2560, - height: 1440, - is_visible: true, - is_minimized: false, - }; - let maximized = WindowInfo { - height: 1392, - ..fullscreen.clone() - }; - - assert!(covers_monitor(&fullscreen, &monitor)); - assert!(!covers_monitor(&maximized, &monitor)); - } - - #[test] - fn restores_when_signature_changes_or_first_pass_is_forced() { - let current = "app:0x1:true:false:0:0:100:100".to_string(); - let same = current.clone(); - let moved = "app:0x1:true:false:10:0:100:100".to_string(); - - assert!(should_restore_for_signature( - true, - Some(¤t), - Some(&same) - )); - assert!(!should_restore_for_signature( - false, - Some(¤t), - Some(&same) - )); - assert!(should_restore_for_signature( - false, - Some(&moved), - Some(&same) - )); - assert!(should_restore_for_signature(false, None, Some(&same))); - assert!(should_restore_for_signature(false, Some(¤t), None)); - } -} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 63755d6..b4df8ff 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,40 +1,52 @@ mod commands; mod config; +mod desktop_guard; mod errors; mod launcher; mod layout_lock; mod logging; mod models; mod monitors; +mod performance; mod processes; mod profiles; +mod single_instance; mod startup; mod state; +mod tray_ui; mod window_actions; mod windows_enum; +use std::sync::atomic::{AtomicBool, Ordering}; + use tauri::{ - menu::{Menu, MenuItem}, + menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem}, tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, - Manager, WindowEvent, + Manager, WebviewWindow, WebviewWindowBuilder, WindowEvent, }; use crate::{ logging::append, - models::{LogSeverity, WindowAutoLayoutConfig}, + models::{LogSeverity, RestoreStatus, TrayClickAction, WindowAutoLayoutConfig}, state::AppState, }; +static MAIN_WINDOW_OPENING: AtomicBool = AtomicBool::new(false); + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - tauri::Builder::default() - .plugin(tauri_plugin_opener::init()) - .setup(|app| { - app.handle() - .plugin(tauri_plugin_global_shortcut::Builder::new().build())?; + let Some(single_instance) = single_instance::acquire() else { + return; + }; + let activation_event_address = single_instance.activation_event_address(); + + let app = tauri::Builder::default() + .device_event_filter(tauri::DeviceEventFilter::Always) + .setup(move |app| { + performance::lower_process_priority(); let config_dir = app.path().app_config_dir()?; - let config = match config::load_or_create(&config_dir) { + let mut config = match config::load_or_create(&config_dir) { Ok(config) => config, Err(error) => { let fallback = WindowAutoLayoutConfig::default(); @@ -48,6 +60,23 @@ pub fn run() { fallback } }; + if let Ok(connected_monitors) = monitors::list_monitors() { + let targets_changed = + config::reconcile_monitor_targets(&mut config, &connected_monitors); + let display_metadata_changed = + config::hydrate_captured_displays(&mut config, &connected_monitors); + if targets_changed || display_metadata_changed { + if let Err(error) = config::save(&config_dir, &config) { + let _ = append( + &config_dir, + LogSeverity::Warn, + None, + None, + format!("Could not save reconciled monitor settings: {error}"), + ); + } + } + } app.manage(AppState::new(config_dir.clone(), config)); let _ = append( @@ -57,6 +86,18 @@ pub fn run() { None, "WindowAutoLayout started", ); + let (severity, input_status) = match performance::registered_raw_input_device_count() { + Some(0) => (LogSeverity::Info, "Raw input capture disabled".to_string()), + Some(count) => ( + LogSeverity::Warn, + format!("Unexpected raw input registration count: {count}"), + ), + None => ( + LogSeverity::Warn, + "Could not verify raw input registration state".to_string(), + ), + }; + let _ = append(&config_dir, severity, None, None, input_status); if let Some(state) = app.try_state::() { if let Ok(config) = state.config.lock() { if let Err(error) = startup::set_startup_enabled(config.startup.enabled) { @@ -71,10 +112,25 @@ pub fn run() { } } - wire_close_to_tray(app); build_tray(app)?; + if let Some(event_address) = activation_event_address { + if let Err(error) = + single_instance::start_activation_listener(event_address, app.handle().clone()) + { + let _ = append( + &config_dir, + LogSeverity::Warn, + None, + None, + format!("Could not start second-launch listener: {error}"), + ); + } + } + desktop_guard::start(app.handle().clone()); maybe_startup_restore(app.handle().clone()); - maybe_start_layout_lock(app.handle().clone()); + if !is_startup_restore() { + show_main_window(app.handle()); + } Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -86,11 +142,12 @@ pub fn run() { commands::list_monitors, commands::list_windows, commands::layout_lock_enabled, - commands::lock_layout_temporarily, commands::log_path, commands::open_log_file, + commands::parse_config_json, commands::read_logs, commands::restore_profile, + commands::runtime_status, commands::save_all_current_layouts, commands::save_config, commands::save_window_layout, @@ -100,15 +157,19 @@ pub fn run() { commands::startup_enabled, commands::validate_current_config ]) - .run(tauri::generate_context!()) - .expect("error while running WindowAutoLayout"); + .build(tauri::generate_context!()) + .expect("error while building WindowAutoLayout"); + + app.run(|_app, event| { + if let tauri::RunEvent::ExitRequested { code, api, .. } = event { + if code.is_none() { + api.prevent_exit(); + } + } + }); } -fn wire_close_to_tray(app: &tauri::App) { - let Some(window) = app.get_webview_window("main") else { - return; - }; - let app_handle = app.handle().clone(); +fn wire_close_to_tray(app_handle: tauri::AppHandle, window: &WebviewWindow) { let window_to_hide = window.clone(); window.on_window_event(move |event| { @@ -127,32 +188,68 @@ fn wire_close_to_tray(app: &tauri::App) { if should_hide { api.prevent_close(); let _ = window_to_hide.hide(); + let window_to_destroy = window_to_hide.clone(); + tauri::async_runtime::spawn(async move { + let _ = window_to_destroy.destroy(); + }); + } else { + api.prevent_close(); + app_handle.exit(0); } } }); } fn build_tray(app: &tauri::App) -> tauri::Result<()> { + let automatic_restore_enabled = layout_lock::enabled(app.handle()).unwrap_or(false); let restore = MenuItem::with_id( app, "restore_default", - "Restore default profile", + "Restore windows now", true, None::<&str>, )?; let open = MenuItem::with_id(app, "open", "Open WindowAutoLayout", true, None::<&str>)?; - let logs = MenuItem::with_id(app, "logs", "Open logs", true, None::<&str>)?; - let lock = MenuItem::with_id(app, "layout_lock", "Toggle layout lock", true, None::<&str>)?; + let logs = MenuItem::with_id(app, "logs", "Open activity log", true, None::<&str>)?; + let automatic = CheckMenuItem::with_id( + app, + "layout_lock", + if automatic_restore_enabled { + "Automatic restore: On" + } else { + "Automatic restore: Off" + }, + true, + automatic_restore_enabled, + None::<&str>, + )?; + let separator_one = PredefinedMenuItem::separator(app)?; + let separator_two = PredefinedMenuItem::separator(app)?; let quit = MenuItem::with_id(app, "quit", "Exit", true, None::<&str>)?; - let menu = Menu::with_items(app, &[&restore, &lock, &open, &logs, &quit])?; + let menu = Menu::with_items( + app, + &[ + &restore, + &automatic, + &separator_one, + &open, + &logs, + &separator_two, + &quit, + ], + )?; - let mut builder = TrayIconBuilder::new() - .tooltip("WindowAutoLayout") + let mut builder = TrayIconBuilder::with_id(tray_ui::TRAY_ID) + .tooltip(if automatic_restore_enabled { + "WindowAutoLayout - Automatic restore on" + } else { + "WindowAutoLayout - Automatic restore off" + }) .menu(&menu) .show_menu_on_left_click(false) .on_menu_event(|app, event| match event.id.as_ref() { - "restore_default" => restore_default_in_background(app.clone(), None, Some(true)), - "layout_lock" => toggle_layout_lock(app.clone(), None), + "restore_default" => restore_default_in_background(app.clone(), None, None), + "layout_lock" => toggle_layout_lock(app), "open" => show_main_window(app), "logs" => open_logs(app), "quit" => app.exit(0), @@ -165,7 +262,11 @@ fn build_tray(app: &tauri::App) -> tauri::Result<()> { .. } = event { - show_main_window(tray.app_handle()); + if tray_left_click_restores(tray.app_handle()) { + restore_default_in_background(tray.app_handle().clone(), None, None); + } else { + show_main_window(tray.app_handle()); + } } }); @@ -174,11 +275,33 @@ fn build_tray(app: &tauri::App) -> tauri::Result<()> { } builder.build(app)?; + if let Err(error) = tray_ui::register(app.handle().clone(), restore, automatic) { + let state = app.state::(); + let _ = append( + &state.config_dir, + LogSeverity::Warn, + None, + None, + format!("Could not initialize tray status controls: {error}"), + ); + } Ok(()) } +fn tray_left_click_restores(app: &tauri::AppHandle) -> bool { + app.try_state::() + .and_then(|state| { + state + .config + .lock() + .ok() + .map(|config| config.tray.left_click_action.clone()) + }) + .is_some_and(|action| matches!(action, TrayClickAction::RestoreLayout)) +} + fn maybe_startup_restore(app: tauri::AppHandle) { - let is_startup = std::env::args().any(|arg| arg == "--startup-restore"); + let is_startup = is_startup_restore(); if !is_startup { return; } @@ -193,7 +316,7 @@ fn maybe_startup_restore(app: tauri::AppHandle) { let _ = window.hide(); } } - if !config.startup.restore_on_launch { + if !config.startup.restore_on_launch && !config.enforcement.enabled { return; } @@ -202,38 +325,31 @@ fn maybe_startup_restore(app: tauri::AppHandle) { if delay > 0 { tokio_sleep(delay).await; } - restore_default_in_background(app, None, Some(true)); + let state = app.state::(); + let config_dir = state.config_dir.clone(); + let config = match state.config.lock() { + Ok(config) => config.clone(), + Err(_) => return, + }; + let _ = tauri::async_runtime::spawn_blocking(move || { + if let Err(error) = + profiles::restore_profile_background(&config_dir, &config, None, None) + { + let _ = append( + &config_dir, + LogSeverity::Warn, + None, + None, + format!("Startup restore did not run: {error}"), + ); + } + }) + .await; }); } -fn maybe_start_layout_lock(app: tauri::AppHandle) { - let is_startup = std::env::args().any(|arg| arg == "--startup-restore"); - let state = app.state::(); - let config = match state.config.lock() { - Ok(config) => config.clone(), - Err(_) => return, - }; - if !config.enforcement.enabled { - return; - } - - let profile_id = config - .enforcement - .profile_id - .clone() - .or_else(|| config.startup.default_profile_id.clone()); - let delay = if is_startup { - config.startup.delay_seconds - } else { - 0 - }; - - tauri::async_runtime::spawn(async move { - if delay > 0 { - tokio_sleep(delay).await; - } - let _ = layout_lock::set(&app, true, profile_id); - }); +fn is_startup_restore() -> bool { + std::env::args().any(|arg| arg == "--startup-restore") } async fn tokio_sleep(seconds: u64) { @@ -249,6 +365,9 @@ fn restore_default_in_background( profile_id: Option, launch_missing: Option, ) { + if tray_ui::restoring() { + return; + } let state = app.state::(); let config_dir = state.config_dir.clone(); let config = match state.config.lock() { @@ -258,26 +377,162 @@ fn restore_default_in_background( tauri::async_runtime::spawn(async move { let _ = tauri::async_runtime::spawn_blocking(move || { - profiles::restore_profile(&config_dir, &config, profile_id, launch_missing) + if let Err(error) = + profiles::restore_profile(&config_dir, &config, profile_id, launch_missing) + { + let _ = append( + &config_dir, + LogSeverity::Warn, + None, + None, + format!("Tray restore did not run: {error}"), + ); + } + }) + .await; + }); +} + +fn toggle_layout_lock(app: &tauri::AppHandle) { + if tray_ui::restoring() { + return; + } + match layout_lock::enabled(app) { + Ok(true) => { + if let Err(error) = layout_lock::set(app, false, None) { + log_layout_lock_error(app, error); + } + } + Ok(false) => enable_layout_lock_in_background(app.clone()), + Err(error) => log_layout_lock_error(app, error), + } +} + +fn enable_layout_lock_in_background(app: tauri::AppHandle) { + let state = app.state::(); + let config_dir = state.config_dir.clone(); + let config = match state.config.lock() { + Ok(config) => config.clone(), + Err(_) => return, + }; + let profile_id = config + .enforcement + .profile_id + .clone() + .or_else(|| config.startup.default_profile_id.clone()); + + tauri::async_runtime::spawn(async move { + let restore_profile_id = profile_id.clone(); + let restore = tauri::async_runtime::spawn_blocking(move || { + profiles::restore_profile(&config_dir, &config, restore_profile_id, None) }) .await; + match restore { + Ok(Ok(result)) + if !matches!( + result.status, + RestoreStatus::Paused | RestoreStatus::Failed | RestoreStatus::MonitorMissing + ) => + { + if let Err(error) = layout_lock::set(&app, true, Some(result.profile_id)) { + log_layout_lock_error(&app, error); + } + } + Ok(Err(error)) => log_layout_lock_error(&app, error), + Err(error) => { + let state = app.state::(); + let _ = append( + &state.config_dir, + LogSeverity::Error, + None, + None, + format!("Could not enable automatic restore: {error}"), + ); + } + _ => { + let _ = tray_ui::sync(&app); + } + } }); } -fn toggle_layout_lock(app: tauri::AppHandle, profile_id: Option) { - let _ = layout_lock::toggle(&app, profile_id); +fn log_layout_lock_error(app: &tauri::AppHandle, error: impl std::fmt::Display) { + let state = app.state::(); + let _ = append( + &state.config_dir, + LogSeverity::Error, + None, + None, + format!("Could not change automatic restore: {error}"), + ); } -fn show_main_window(app: &tauri::AppHandle) { +pub(crate) fn show_main_window(app: &tauri::AppHandle) { if let Some(window) = app.get_webview_window("main") { - let _ = window.show(); - let _ = window.unminimize(); - let _ = window.set_focus(); + present_main_window(&window); + return; + } + if MAIN_WINDOW_OPENING.swap(true, Ordering::AcqRel) { + return; + } + + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let result = create_main_window(&app); + MAIN_WINDOW_OPENING.store(false, Ordering::Release); + if let Err(error) = result { + let state = app.state::(); + let _ = append( + &state.config_dir, + LogSeverity::Error, + None, + None, + format!("Could not open WindowAutoLayout: {error}"), + ); + } + }); +} + +fn create_main_window(app: &tauri::AppHandle) -> tauri::Result<()> { + if let Some(window) = app.get_webview_window("main") { + present_main_window(&window); + return Ok(()); + } + let config = app + .config() + .app + .windows + .iter() + .find(|config| config.label == "main") + .ok_or(tauri::Error::WindowNotFound)?; + let window = WebviewWindowBuilder::from_config(app, config)?.build()?; + wire_close_to_tray(app.clone(), &window); + present_main_window(&window); + Ok(()) +} + +fn present_main_window(window: &WebviewWindow) { + let _ = window.show(); + let _ = window.unminimize(); + if let Ok(hwnd) = window.hwnd() { + let hwnd = windows::Win32::Foundation::HWND(hwnd.0 as usize as *mut std::ffi::c_void); + unsafe { + let _ = windows::Win32::UI::WindowsAndMessaging::SetForegroundWindow(hwnd); + } } } fn open_logs(app: &tauri::AppHandle) { let state = app.state::(); let path = logging::log_file_path(&state.config_dir); + if !path.exists() { + let _ = append( + &state.config_dir, + LogSeverity::Info, + None, + None, + "Created log file", + ); + } let _ = std::process::Command::new("explorer").arg(path).spawn(); } diff --git a/src-tauri/src/logging.rs b/src-tauri/src/logging.rs index ea6b58c..7df1cfd 100644 --- a/src-tauri/src/logging.rs +++ b/src-tauri/src/logging.rs @@ -2,6 +2,7 @@ use std::{ fs::{self, OpenOptions}, io::Write, path::{Path, PathBuf}, + sync::Mutex, }; use chrono::Utc; @@ -14,6 +15,7 @@ use crate::{ const LOG_DIR_NAME: &str = "logs"; const LOG_FILE_NAME: &str = "windowautolayout.log"; const MAX_LOG_BYTES: u64 = 1_048_576; +static LOG_LOCK: Mutex<()> = Mutex::new(()); pub fn log_file_path(config_dir: &Path) -> PathBuf { config_dir.join(LOG_DIR_NAME).join(LOG_FILE_NAME) @@ -26,6 +28,7 @@ pub fn append( app: Option<&str>, message: impl AsRef, ) -> AppResult<()> { + let _guard = LOG_LOCK.lock().unwrap_or_else(|error| error.into_inner()); let path = log_file_path(config_dir); if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; @@ -46,6 +49,7 @@ pub fn append( } pub fn read(config_dir: &Path, max_lines: usize) -> AppResult> { + let _guard = LOG_LOCK.lock().unwrap_or_else(|error| error.into_inner()); let path = log_file_path(config_dir); if !path.exists() { return Ok(Vec::new()); @@ -63,6 +67,7 @@ pub fn read(config_dir: &Path, max_lines: usize) -> AppResult> { } pub fn clear(config_dir: &Path) -> AppResult<()> { + let _guard = LOG_LOCK.lock().unwrap_or_else(|error| error.into_inner()); let path = log_file_path(config_dir); if path.exists() { fs::write(path, "")?; diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 3184575..1b03c8d 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -1,15 +1,15 @@ use serde::{Deserialize, Serialize}; -pub const CONFIG_SCHEMA_VERSION: u32 = 3; +pub const CONFIG_SCHEMA_VERSION: u32 = 7; pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub enum MonitorMissingBehavior { + #[serde(alias = "askNextOpen")] DoNothing, UsePrimary, NearestMatch, - AskNextOpen, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -35,6 +35,7 @@ pub enum TitleMatchMode { pub enum RestoreStatus { Success, PartialSuccess, + Paused, Failed, MonitorMissing, } @@ -44,6 +45,7 @@ pub enum RestoreStatus { pub enum AppRestoreStatus { Success, Skipped, + Paused, Launched, LaunchedWindowNotFound, ProcessRunningWindowNotFound, @@ -62,6 +64,14 @@ pub enum LogSeverity { Error, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "camelCase")] +pub enum TrayClickAction { + #[default] + OpenWindow, + RestoreLayout, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct LayoutRect { @@ -71,6 +81,32 @@ pub struct LayoutRect { pub height: i32, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CapturedDisplay { + pub width: i32, + pub height: i32, + pub work_x: i32, + pub work_y: i32, + pub work_width: i32, + pub work_height: i32, + pub scale_percent: u32, +} + +impl CapturedDisplay { + pub fn from_monitor(monitor: &MonitorInfo) -> Self { + Self { + width: monitor.width, + height: monitor.height, + work_x: monitor.work_x - monitor.x, + work_y: monitor.work_y - monitor.y, + work_width: monitor.work_width, + work_height: monitor.work_height, + scale_percent: (monitor.scale_factor * 100.0).round().max(1.0) as u32, + } + } +} + impl Default for LayoutRect { fn default() -> Self { Self { @@ -102,12 +138,13 @@ pub struct AppConfig { pub title_rule: Option, pub class_name: Option, pub target_monitor_id: Option, + #[serde(default)] + pub captured_display: Option, pub layout: LayoutRect, pub window_state: WindowStatePreference, pub launch_delay_seconds: u64, pub detection_timeout_seconds: u64, pub retry_interval_ms: u64, - pub launch_if_missing: bool, pub move_if_running: bool, pub force_resize: bool, pub apply_to_all_matching_windows: bool, @@ -137,12 +174,12 @@ impl AppConfig { title_rule: None, class_name: None, target_monitor_id: None, + captured_display: None, layout, window_state: WindowStatePreference::Normal, launch_delay_seconds: 0, detection_timeout_seconds: 25, retry_interval_ms: 700, - launch_if_missing: true, move_if_running: true, force_resize: true, apply_to_all_matching_windows: false, @@ -167,8 +204,6 @@ pub struct Profile { pub description: Option, pub target_monitor_id: Option, pub apps: Vec, - pub startup_restore: bool, - pub enforce_after_restore: bool, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -176,8 +211,6 @@ pub struct Profile { pub struct GlobalSettings { pub default_monitor_id: Option, pub monitor_missing_behavior: MonitorMissingBehavior, - pub warn_when_monitor_missing: bool, - pub advanced_mode: bool, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -189,22 +222,14 @@ pub struct StartupSettings { pub delay_seconds: u64, pub restore_on_launch: bool, pub launch_missing_apps: bool, - pub enforce_after_startup: bool, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct TraySettings { pub minimize_to_tray_on_close: bool, - pub show_restore_status: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct HotkeySettings { - pub enabled: bool, - pub accelerator: String, - pub restore_without_opening: bool, + #[serde(default)] + pub left_click_action: TrayClickAction, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -213,10 +238,10 @@ pub struct EnforcementSettings { pub enabled: bool, #[serde(default)] pub profile_id: Option, - pub duration_seconds: u64, - pub interval_ms: u64, #[serde(default = "default_true")] - pub pause_for_fullscreen_games: bool, + pub restore_on_desktop_reveal: bool, + #[serde(default = "default_true")] + pub restore_after_game_exit: bool, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -227,7 +252,6 @@ pub struct WindowAutoLayoutConfig { pub global: GlobalSettings, pub startup: StartupSettings, pub tray: TraySettings, - pub hotkey: HotkeySettings, pub enforcement: EnforcementSettings, pub profiles: Vec, } @@ -266,6 +290,7 @@ pub struct WindowInfo { pub height: i32, pub is_visible: bool, pub is_minimized: bool, + pub is_maximized: bool, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -290,6 +315,16 @@ pub struct RestoreResult { pub results: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeStatus { + pub automatic_restore_enabled: bool, + pub automatic_restore_profile_id: Option, + pub automatic_restore_profile_name: Option, + pub restoring: bool, + pub startup_registered: bool, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct CapturedWindowSummary { @@ -349,7 +384,6 @@ pub fn preset_apps() -> Vec { value: "GitHub Desktop".to_string(), case_sensitive: false, }); - github_desktop.launch_if_missing = true; github_desktop.detection_timeout_seconds = 45; vec![ @@ -405,8 +439,6 @@ impl Default for WindowAutoLayoutConfig { global: GlobalSettings { default_monitor_id: None, monitor_missing_behavior: MonitorMissingBehavior::NearestMatch, - warn_when_monitor_missing: true, - advanced_mode: false, }, startup: StartupSettings { enabled: false, @@ -415,23 +447,16 @@ impl Default for WindowAutoLayoutConfig { delay_seconds: 8, restore_on_launch: true, launch_missing_apps: true, - enforce_after_startup: true, }, tray: TraySettings { minimize_to_tray_on_close: true, - show_restore_status: true, - }, - hotkey: HotkeySettings { - enabled: true, - accelerator: "Ctrl+Alt+L".to_string(), - restore_without_opening: true, + left_click_action: TrayClickAction::OpenWindow, }, enforcement: EnforcementSettings { enabled: false, profile_id: Some(default_profile_id.clone()), - duration_seconds: 30, - interval_ms: 2000, - pause_for_fullscreen_games: true, + restore_on_desktop_reveal: true, + restore_after_game_exit: true, }, profiles: vec![Profile { id: default_profile_id, @@ -439,8 +464,6 @@ impl Default for WindowAutoLayoutConfig { description: Some("OBS and Discord arranged on a selected monitor.".to_string()), target_monitor_id: None, apps: streaming_apps, - startup_restore: true, - enforce_after_restore: true, }], } } diff --git a/src-tauri/src/monitors.rs b/src-tauri/src/monitors.rs index 20811f1..c1905f6 100644 --- a/src-tauri/src/monitors.rs +++ b/src-tauri/src/monitors.rs @@ -1,12 +1,13 @@ use windows::{ - core::BOOL, + core::{BOOL, PCWSTR}, Win32::{ Foundation::{LPARAM, RECT}, Graphics::Gdi::{ - EnumDisplayMonitors, GetMonitorInfoW, MonitorFromRect, HDC, HMONITOR, MONITORINFOEXW, - MONITOR_DEFAULTTONEAREST, + EnumDisplayDevicesW, EnumDisplayMonitors, GetMonitorInfoW, MonitorFromRect, + DISPLAY_DEVICEW, HDC, HMONITOR, MONITORINFOEXW, MONITOR_DEFAULTTONEAREST, }, UI::HiDpi::{GetDpiForMonitor, MDT_EFFECTIVE_DPI}, + UI::WindowsAndMessaging::EDD_GET_DEVICE_INTERFACE_NAME, }, }; @@ -33,21 +34,7 @@ pub fn monitor_for_rect(rect: RECT) -> Option { return None; } - list_monitors().ok().and_then(|monitors| { - monitors - .into_iter() - .find(|monitor| monitor_matches_handle(monitor, handle)) - }) -} - -fn monitor_matches_handle(monitor: &MonitorInfo, handle: HMONITOR) -> bool { - let mut info = MONITORINFOEXW::default(); - info.monitorInfo.cbSize = std::mem::size_of::() as u32; - let ok = unsafe { GetMonitorInfoW(handle, &mut info as *mut MONITORINFOEXW as *mut _) }; - if !ok.as_bool() { - return false; - } - monitor.device_name == wide_to_string(&info.szDevice) + monitor_info(handle) } unsafe extern "system" fn enum_monitor_proc( @@ -75,16 +62,13 @@ fn monitor_info(monitor: HMONITOR) -> Option { let bounds = info.monitorInfo.rcMonitor; let work = info.monitorInfo.rcWork; let device_name = wide_to_string(&info.szDevice); + let (id, name) = monitor_identity(&device_name, &bounds); let is_primary = (info.monitorInfo.dwFlags & 1) == 1; let scale_factor = scale_factor_for_monitor(monitor); Some(MonitorInfo { - id: stable_monitor_id(&device_name, &bounds), - name: if device_name.is_empty() { - "Display".to_string() - } else { - device_name.clone() - }, + id, + name, device_name, x: bounds.left, y: bounds.top, @@ -99,8 +83,45 @@ fn monitor_info(monitor: HMONITOR) -> Option { }) } -fn stable_monitor_id(device_name: &str, bounds: &RECT) -> String { - if device_name.trim().is_empty() { +pub fn monitor_id_matches(monitor: &MonitorInfo, saved_id: &str) -> bool { + monitor.id.eq_ignore_ascii_case(saved_id) || monitor.device_name.eq_ignore_ascii_case(saved_id) +} + +fn monitor_identity(device_name: &str, bounds: &RECT) -> (String, String) { + if !device_name.trim().is_empty() { + let encoded = device_name + .encode_utf16() + .chain(std::iter::once(0)) + .collect::>(); + let mut display = DISPLAY_DEVICEW { + cb: std::mem::size_of::() as u32, + ..Default::default() + }; + let found = unsafe { + EnumDisplayDevicesW( + PCWSTR(encoded.as_ptr()), + 0, + &mut display, + EDD_GET_DEVICE_INTERFACE_NAME, + ) + }; + if found.as_bool() { + let interface_id = wide_to_string(&display.DeviceID); + let friendly_name = wide_to_string(&display.DeviceString); + if !interface_id.trim().is_empty() { + return ( + interface_id.to_ascii_lowercase(), + if friendly_name.trim().is_empty() { + device_name.to_string() + } else { + friendly_name + }, + ); + } + } + } + + let fallback_id = if device_name.trim().is_empty() { format!( "bounds:{}:{}:{}:{}", bounds.left, @@ -110,7 +131,13 @@ fn stable_monitor_id(device_name: &str, bounds: &RECT) -> String { ) } else { device_name.to_ascii_lowercase() - } + }; + let fallback_name = if device_name.trim().is_empty() { + "Display".to_string() + } else { + device_name.to_string() + }; + (fallback_id, fallback_name) } fn scale_factor_for_monitor(monitor: HMONITOR) -> f64 { diff --git a/src-tauri/src/performance.rs b/src-tauri/src/performance.rs new file mode 100644 index 0000000..587f129 --- /dev/null +++ b/src-tauri/src/performance.rs @@ -0,0 +1,153 @@ +use windows::Win32::Foundation::RECT; +use windows::Win32::System::Threading::{ + GetCurrentProcess, GetCurrentThread, SetPriorityClass, SetThreadPriority, + BELOW_NORMAL_PRIORITY_CLASS, THREAD_PRIORITY_BELOW_NORMAL, +}; +use windows::Win32::UI::Input::{GetRegisteredRawInputDevices, RAWINPUTDEVICE}; + +use crate::{models::WindowInfo, monitors, windows_enum}; + +pub fn lower_process_priority() { + unsafe { + let _ = SetPriorityClass(GetCurrentProcess(), BELOW_NORMAL_PRIORITY_CLASS); + } +} + +pub fn lower_current_thread_priority() { + unsafe { + let _ = SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_BELOW_NORMAL); + } +} + +pub fn registered_raw_input_device_count() -> Option { + let mut count = 0u32; + let result = unsafe { + GetRegisteredRawInputDevices( + None, + &mut count, + std::mem::size_of::() as u32, + ) + }; + if result == u32::MAX { + None + } else { + Some(count) + } +} + +pub fn foreground_is_latency_sensitive() -> bool { + windows_enum::foreground_window() + .map(|window| is_latency_sensitive_window(&window)) + .unwrap_or(false) +} + +pub fn is_latency_sensitive_window(window: &WindowInfo) -> bool { + if is_latency_sensitive_game_process(&window.process_name) { + return true; + } + + let rect = RECT { + left: window.x, + top: window.y, + right: window.x + window.width, + bottom: window.y + window.height, + }; + monitors::monitor_for_rect(rect) + .map(|monitor| covers_monitor(window, &monitor)) + .unwrap_or(false) +} + +pub fn is_latency_sensitive_game_process(process_name: &str) -> bool { + matches!( + process_name.trim().to_ascii_lowercase().as_str(), + "valorant-win64-shipping.exe" + | "valorant.exe" + | "cs2.exe" + | "csgo.exe" + | "fortniteclient-win64-shipping.exe" + | "r5apex.exe" + | "overwatch.exe" + | "league of legends.exe" + | "rocketleague.exe" + | "tslgame.exe" + | "rainbowsix.exe" + | "rainbowsix_vulkan.exe" + | "escapefromtarkov.exe" + | "minecraft.windows.exe" + | "javaw.exe" + | "osu!.exe" + | "eldenring.exe" + | "gta5.exe" + | "fivem.exe" + | "helldivers2.exe" + ) +} + +fn covers_monitor(window: &WindowInfo, monitor: &crate::models::MonitorInfo) -> bool { + if !window.is_visible || window.is_minimized { + return false; + } + + let tolerance = 4; + (window.x - monitor.x).abs() <= tolerance + && (window.y - monitor.y).abs() <= tolerance + && window.width >= monitor.width - tolerance + && window.height >= monitor.height - tolerance +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::MonitorInfo; + + #[test] + fn recognizes_named_games_without_requiring_fullscreen() { + assert!(is_latency_sensitive_game_process( + "VALORANT-Win64-Shipping.exe" + )); + assert!(is_latency_sensitive_game_process("javaw.exe")); + assert!(!is_latency_sensitive_game_process("Discord.exe")); + } + + #[test] + fn distinguishes_fullscreen_from_work_area_windows() { + let monitor = MonitorInfo { + id: "display".into(), + name: "Display".into(), + device_name: "DISPLAY1".into(), + x: 0, + y: 0, + width: 2560, + height: 1440, + work_x: 0, + work_y: 0, + work_width: 2560, + work_height: 1392, + scale_factor: 1.25, + is_primary: true, + }; + let fullscreen = WindowInfo { + handle: "0x1".into(), + title: "Game".into(), + class_name: "GameWindow".into(), + process_id: 10, + process_name: "unknown-game.exe".into(), + executable_path: None, + monitor_id: Some("display".into()), + x: 0, + y: 0, + width: 2560, + height: 1440, + is_visible: true, + is_minimized: false, + is_maximized: false, + }; + let maximized = WindowInfo { + height: 1392, + ..fullscreen.clone() + }; + + assert!(covers_monitor(&fullscreen, &monitor)); + assert!(!covers_monitor(&maximized, &monitor)); + } +} diff --git a/src-tauri/src/processes.rs b/src-tauri/src/processes.rs index 401aa1b..35fa6d3 100644 --- a/src-tauri/src/processes.rs +++ b/src-tauri/src/processes.rs @@ -54,10 +54,15 @@ pub fn list_processes() -> AppResult> { } pub fn query_process_name(pid: u32) -> String { - list_processes() - .ok() - .and_then(|processes| processes.into_iter().find(|process| process.pid == pid)) - .map(|process| process.name) + query_process_path(pid) + .as_deref() + .and_then(file_name_from_path) + .or_else(|| { + list_processes() + .ok() + .and_then(|processes| processes.into_iter().find(|process| process.pid == pid)) + .map(|process| process.name) + }) .unwrap_or_default() } @@ -90,6 +95,28 @@ pub fn file_name_from_path(path: &str) -> Option { .map(|value| value.to_string_lossy().to_string()) } +pub fn windows_app_package_family(path: &str) -> Option { + let normalized = path.replace('/', "\\"); + let lower = normalized.to_ascii_lowercase(); + let marker = "\\windowsapps\\"; + let start = lower.find(marker)? + marker.len(); + let package_full_name = normalized[start..].split('\\').next()?; + let (name_and_version, publisher_id) = package_full_name.rsplit_once("__")?; + let package_name = name_and_version.split('_').next()?; + if package_name.is_empty() || publisher_id.is_empty() { + return None; + } + + Some(format!("{package_name}_{publisher_id}")) +} + +pub fn same_windows_app_package(left: &str, right: &str) -> bool { + windows_app_package_family(left) + .zip(windows_app_package_family(right)) + .map(|(left, right)| left.eq_ignore_ascii_case(&right)) + .unwrap_or(false) +} + fn wide_to_string(buffer: &[u16]) -> String { let len = buffer .iter() @@ -97,3 +124,21 @@ fn wide_to_string(buffer: &[u16]) -> String { .unwrap_or(buffer.len()); String::from_utf16_lossy(&buffer[..len]) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn store_package_identity_survives_versioned_folder_changes() { + let old = r"C:\Program Files\WindowsApps\OpenAI.Codex_26.623.5546.0_x64__2p2nqsd0c76g0\app\Codex.exe"; + let new = r"C:\Program Files\WindowsApps\OpenAI.Codex_26.707.3563.0_x64__2p2nqsd0c76g0\app\ChatGPT.exe"; + + assert_eq!( + windows_app_package_family(old).as_deref(), + Some("OpenAI.Codex_2p2nqsd0c76g0") + ); + assert!(same_windows_app_package(old, new)); + assert!(!same_windows_app_package(old, r"C:\Apps\Discord.exe")); + } +} diff --git a/src-tauri/src/profiles.rs b/src-tauri/src/profiles.rs index 98b4166..c6ce861 100644 --- a/src-tauri/src/profiles.rs +++ b/src-tauri/src/profiles.rs @@ -1,5 +1,9 @@ use std::{ path::Path, + sync::{ + atomic::{AtomicBool, Ordering}, + Mutex, TryLockError, + }, thread, time::{Duration, Instant}, }; @@ -11,13 +15,47 @@ use crate::{ errors::{AppError, AppResult}, launcher, logging, models::{ - AppConfig, AppRestoreResult, AppRestoreStatus, LayoutRect, LogSeverity, MatchRule, - MonitorInfo, MonitorMissingBehavior, Profile, RestoreResult, RestoreStatus, TitleMatchMode, - WindowAutoLayoutConfig, WindowInfo, + AppConfig, AppRestoreResult, AppRestoreStatus, CapturedDisplay, LayoutRect, LogSeverity, + MatchRule, MonitorInfo, MonitorMissingBehavior, Profile, RestoreResult, RestoreStatus, + TitleMatchMode, WindowAutoLayoutConfig, WindowInfo, }, - monitors, processes, window_actions, windows_enum, + monitors, performance, processes, tray_ui, window_actions, windows_enum, }; +static RESTORE_LOCK: Mutex<()> = Mutex::new(()); +static RESTORE_ACTIVE: AtomicBool = AtomicBool::new(false); +static LAST_RESTORE_FINISHED: Mutex> = Mutex::new(None); +const RESTORE_EVENT_SUPPRESSION: Duration = Duration::from_secs(1); + +struct RestoreActivityGuard; + +impl RestoreActivityGuard { + fn enter() -> Self { + RESTORE_ACTIVE.store(true, Ordering::Release); + tray_ui::set_restoring(true); + Self + } +} + +impl Drop for RestoreActivityGuard { + fn drop(&mut self) { + if let Ok(mut last_finished) = LAST_RESTORE_FINISHED.lock() { + *last_finished = Some(Instant::now()); + } + RESTORE_ACTIVE.store(false, Ordering::Release); + tray_ui::set_restoring(false); + } +} + +pub fn restore_events_suppressed() -> bool { + RESTORE_ACTIVE.load(Ordering::Acquire) + || LAST_RESTORE_FINISHED + .lock() + .ok() + .and_then(|last_finished| *last_finished) + .is_some_and(|finished| finished.elapsed() < RESTORE_EVENT_SUPPRESSION) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum AppPresence { NotRunning, @@ -42,6 +80,13 @@ struct RestoreTarget<'a> { layout: &'a LayoutRect, } +#[derive(Clone, Copy)] +struct RestoreMode { + log_events: bool, + activate_visible_windows: bool, + abort_for_latency_sensitive_foreground: bool, +} + pub fn restore_profile( config_dir: &Path, config: &WindowAutoLayoutConfig, @@ -53,8 +98,11 @@ pub fn restore_profile( config, profile_id, launch_missing_override, - true, - true, + RestoreMode { + log_events: true, + activate_visible_windows: true, + abort_for_latency_sensitive_foreground: true, + }, ) } @@ -69,60 +117,31 @@ pub fn restore_profile_silent( config, profile_id, launch_missing_override, - false, - false, + RestoreMode { + log_events: false, + activate_visible_windows: false, + abort_for_latency_sensitive_foreground: true, + }, ) } -pub fn profile_window_signature( +pub fn restore_profile_background( + config_dir: &Path, config: &WindowAutoLayoutConfig, - profile_id: Option<&str>, -) -> AppResult { - let profile = resolve_profile(config, profile_id)?; - let windows = windows_enum::list_windows_with_hidden(true).unwrap_or_default(); - Ok(profile_window_signature_from_windows(profile, &windows)) -} - -fn profile_window_signature_from_windows(profile: &Profile, windows: &[WindowInfo]) -> String { - profile - .apps - .iter() - .map(|app| app_window_signature(app, windows)) - .collect::>() - .join("|") -} - -fn app_window_signature(app: &AppConfig, windows: &[WindowInfo]) -> String { - let mut matched = windows - .iter() - .filter(|window| window_matches_app(window, app, None)) - .cloned() - .collect::>(); - sort_matching_windows(&mut matched); - let selected = selected_matching_windows(app, &matched); - - if selected.is_empty() { - return format!("{}:missing", app.id); - } - - let windows = selected - .iter() - .map(|window| { - format!( - "{}:{}:{}:{}:{}:{}:{}:{}", - window.handle, - window.process_id, - window.is_visible, - window.is_minimized, - window.x, - window.y, - window.width, - window.height - ) - }) - .collect::>() - .join(","); - format!("{}:{windows}", app.id) + profile_id: Option, + launch_missing_override: Option, +) -> AppResult { + restore_profile_inner( + config_dir, + config, + profile_id, + launch_missing_override, + RestoreMode { + log_events: true, + activate_visible_windows: false, + abort_for_latency_sensitive_foreground: true, + }, + ) } fn restore_profile_inner( @@ -130,19 +149,40 @@ fn restore_profile_inner( config: &WindowAutoLayoutConfig, profile_id: Option, launch_missing_override: Option, - log_events: bool, - activate_visible_windows: bool, + mode: RestoreMode, ) -> AppResult { + let _restore_guard = match RESTORE_LOCK.try_lock() { + Ok(guard) => guard, + Err(TryLockError::WouldBlock) => return Err(AppError::RestoreInProgress), + Err(TryLockError::Poisoned(_)) => { + return Err(AppError::Config("Restore lock was poisoned".to_string())) + } + }; + let _activity_guard = RestoreActivityGuard::enter(); + let log_events = mode.log_events; + let abort_for_latency_sensitive_foreground = mode.abort_for_latency_sensitive_foreground; let started_at = Utc::now(); let profile = resolve_profile(config, profile_id.as_deref())?.clone(); + if abort_for_latency_sensitive_foreground && performance::foreground_is_latency_sensitive() { + if log_events { + let _ = logging::append( + config_dir, + LogSeverity::Info, + Some(&profile.name), + None, + "Restore paused for a foreground game or fullscreen app", + ); + } + return Ok(paused_restore_result(profile, started_at)); + } if log_events { - logging::append( + let _ = logging::append( config_dir, LogSeverity::Info, Some(&profile.name), None, "Restore started", - )?; + ); } let monitors = monitors::list_monitors()?; @@ -160,49 +200,52 @@ fn restore_profile_inner( results, }; if log_events { - logging::append( + let _ = logging::append( config_dir, LogSeverity::Warn, Some(&profile.name), None, "Restore skipped because the saved monitor is missing", - )?; + ); } return Ok(result); } - let monitor = monitor.expect("checked above"); + let Some(monitor) = monitor else { + return Err(AppError::MonitorNotFound); + }; if monitor.is_fallback && log_events { - logging::append( + let _ = logging::append( config_dir, LogSeverity::Warn, Some(&profile.name), None, format!("Saved monitor is missing; using {}", monitor.monitor.name), - )?; + ); } + let mut paused = false; for app in &profile.apps { + if abort_for_latency_sensitive_foreground && performance::foreground_is_latency_sensitive() + { + paused = true; + break; + } let app_monitor = resolve_app_monitor(config, &profile, app, &monitors) .unwrap_or_else(|| monitor.clone()); - let layout = if app_monitor.is_fallback { - scaled_layout_for_monitor(&profile, app, &app_monitor.monitor) - } else { - app.layout.clone() - }; + let layout = restore_layout_for_monitor(&profile, app, &app_monitor); let launch_missing = launch_missing_override.unwrap_or(config.startup.launch_missing_apps); let target = RestoreTarget { monitor: &app_monitor.monitor, layout: &layout, }; - results.push(restore_app( - config_dir, - &profile, - app, - target, - launch_missing, - log_events, - activate_visible_windows, - )); + let result = restore_app(config_dir, &profile, app, target, launch_missing, mode); + if matches!(result.status, AppRestoreStatus::Paused) { + paused = true; + } + results.push(result); + if paused { + break; + } } let success_count = results @@ -210,11 +253,13 @@ fn restore_profile_inner( .filter(|result| { matches!( result.status, - AppRestoreStatus::Success | AppRestoreStatus::Launched + AppRestoreStatus::Success | AppRestoreStatus::Launched | AppRestoreStatus::Skipped ) }) .count(); - let status = if success_count == results.len() { + let status = if paused { + RestoreStatus::Paused + } else if success_count == results.len() { RestoreStatus::Success } else if success_count > 0 { RestoreStatus::PartialSuccess @@ -223,9 +268,9 @@ fn restore_profile_inner( }; if log_events { - logging::append( + let _ = logging::append( config_dir, - if matches!(status, RestoreStatus::Success) { + if matches!(status, RestoreStatus::Success | RestoreStatus::Paused) { LogSeverity::Info } else { LogSeverity::Warn @@ -233,7 +278,7 @@ fn restore_profile_inner( Some(&profile.name), None, format!("Restore finished with status {status:?}"), - )?; + ); } Ok(RestoreResult { @@ -247,23 +292,16 @@ fn restore_profile_inner( }) } -pub fn enforce_profile_for( - config_dir: &Path, - config: &WindowAutoLayoutConfig, - profile_id: Option, - duration_seconds: u64, - interval_ms: u64, -) -> AppResult { - let interval = interval_ms.clamp(2000, 5000); - let until = Instant::now() + Duration::from_secs(duration_seconds.max(1)); - let mut last = restore_profile(config_dir, config, profile_id.clone(), Some(false))?; - - while Instant::now() < until { - thread::sleep(Duration::from_millis(interval)); - last = restore_profile(config_dir, config, profile_id.clone(), Some(false))?; +fn paused_restore_result(profile: Profile, started_at: chrono::DateTime) -> RestoreResult { + RestoreResult { + profile_id: profile.id, + profile_name: profile.name, + status: RestoreStatus::Paused, + started_at: started_at.to_rfc3339(), + finished_at: Utc::now().to_rfc3339(), + monitor: None, + results: Vec::new(), } - - Ok(last) } pub fn resolve_profile<'a>( @@ -296,9 +334,14 @@ fn restore_app( app: &AppConfig, target: RestoreTarget<'_>, launch_missing: bool, - log_events: bool, - activate_visible_windows: bool, + mode: RestoreMode, ) -> AppRestoreResult { + let RestoreMode { + log_events, + activate_visible_windows, + abort_for_latency_sensitive_foreground, + } = mode; + let previous_foreground = window_actions::foreground_window(); let mut matched = find_matching_windows(app, None); let mut running_processes = matching_processes(app); let mut presence = if matched.is_empty() { @@ -311,9 +354,12 @@ fn restore_app( AppPresence::RunningWithWindow }; let mut launched = false; - let mut activate_after_hidden_restore = false; - - if launch_missing && should_show_matched_windows(app, &matched) { + let mut surface_refresh_needed = should_show_matched_windows(app, &matched); + if should_show_matched_windows(app, &matched) { + if abort_for_latency_sensitive_foreground && performance::foreground_is_latency_sensitive() + { + return background_restore_paused(app, matched); + } let selected = selected_matching_windows(app, &matched); if should_restore_through_qt_tray(app, &selected) { let mut activated_processes = Vec::new(); @@ -340,6 +386,8 @@ fn restore_app( None, app.detection_timeout_seconds, app.retry_interval_ms, + abort_for_latency_sensitive_foreground, + false, ); if restored .iter() @@ -357,7 +405,6 @@ fn restore_app( } else { AppPresence::RunningWithWindow }; - activate_after_hidden_restore = true; } } } @@ -374,6 +421,9 @@ fn restore_app( } } } + if show_error.is_none() && !is_obs_app(app) { + surface_refresh_needed = false; + } } if let Some(error) = show_error { @@ -401,6 +451,8 @@ fn restore_app( None, app.detection_timeout_seconds, app.retry_interval_ms, + abort_for_latency_sensitive_foreground, + false, ); settle_visible_window(app); matched = find_matching_windows(app, None); @@ -414,14 +466,13 @@ fn restore_app( } else { AppPresence::RunningWithWindow }; - activate_after_hidden_restore = true; } } let should_wake_running = matches!(presence, AppPresence::RunningWithoutWindow) && app.wake_running_process; - if matched.is_empty() && launch_missing && should_wake_running && is_obs_app(app) { + if matched.is_empty() && should_wake_running && is_obs_app(app) { let mut activated_processes = Vec::new(); for process in &running_processes { if !activated_processes.contains(&process.pid) @@ -432,6 +483,7 @@ fn restore_app( } if !activated_processes.is_empty() { + surface_refresh_needed = true; if log_events { let _ = logging::append( config_dir, @@ -446,6 +498,8 @@ fn restore_app( None, app.detection_timeout_seconds, app.retry_interval_ms, + abort_for_latency_sensitive_foreground, + false, ); settle_visible_window(app); matched = find_matching_windows(app, None); @@ -459,7 +513,6 @@ fn restore_app( } else { AppPresence::RunningWithWindow }; - activate_after_hidden_restore = true; } } @@ -472,72 +525,81 @@ fn restore_app( && launch_missing && (should_launch_missing || should_launch_running_process) { + if abort_for_latency_sensitive_foreground && performance::foreground_is_latency_sensitive() + { + return background_restore_paused(app, matched); + } let launch_path = if should_launch_running_process { running_process_launch_path(app, &running_processes) } else { app.executable_path.clone() }; - let launched_pid = match launcher::launch_app_with_path(app, launch_path.as_deref()) { - Ok(pid) => { - launched = true; - if log_events { - let _ = logging::append( - config_dir, - LogSeverity::Info, - Some(&profile.name), - Some(&app.display_name), - format!( - "{}{}", - if matches!(presence, AppPresence::RunningWithoutWindow) { - "Asked running app to show a window" - } else { - "Launched app" - }, - pid.map(|pid| format!(" with PID {pid}")) - .unwrap_or_default() - ), - ); + let launch_config = app_for_restore_launch(app); + let launched_pid = + match launcher::launch_app_with_path(&launch_config, launch_path.as_deref()) { + Ok(pid) => { + launched = should_launch_missing; + surface_refresh_needed = true; + if log_events { + let _ = logging::append( + config_dir, + LogSeverity::Info, + Some(&profile.name), + Some(&app.display_name), + format!( + "{}{}", + if matches!(presence, AppPresence::RunningWithoutWindow) { + "Asked running app to show a window" + } else { + "Launched app" + }, + pid.map(|pid| format!(" with PID {pid}")) + .unwrap_or_default() + ), + ); + } + pid } - pid - } - Err(AppError::InvalidExecutablePath(path)) => { - let message = format!("Invalid executable path: {path}"); - if log_events { - let _ = logging::append( - config_dir, - LogSeverity::Error, - Some(&profile.name), - Some(&app.display_name), - &message, + Err(AppError::InvalidExecutablePath(path)) => { + let message = format!("Invalid executable path: {path}"); + if log_events { + let _ = logging::append( + config_dir, + LogSeverity::Error, + Some(&profile.name), + Some(&app.display_name), + &message, + ); + } + return app_result( + app, + AppRestoreStatus::InvalidExecutablePath, + message, + matched, ); } - return app_result( - app, - AppRestoreStatus::InvalidExecutablePath, - message, - matched, - ); - } - Err(error) => { - let message = error.to_string(); - if log_events { - let _ = logging::append( - config_dir, - LogSeverity::Error, - Some(&profile.name), - Some(&app.display_name), - &message, - ); + Err(error) => { + let message = error.to_string(); + if log_events { + let _ = logging::append( + config_dir, + LogSeverity::Error, + Some(&profile.name), + Some(&app.display_name), + &message, + ); + } + return app_result(app, AppRestoreStatus::Failed, message, matched); } - return app_result(app, AppRestoreStatus::Failed, message, matched); - } - }; + }; let _ = wait_for_visible_windows( app, launched_pid, - app.detection_timeout_seconds, + launch_detection_timeout_seconds(app), app.retry_interval_ms, + abort_for_latency_sensitive_foreground, + true, ); settle_visible_window(app); matched = find_matching_windows(app, launched_pid); @@ -554,6 +616,35 @@ fn restore_app( } if matched.is_empty() { + if is_quick_tray_detection_app(app) && matches!(presence, AppPresence::RunningWithoutWindow) + { + let message = if launched { + "App launched and is running in the tray; no movable window was exposed" + } else { + "App is running in the tray; no movable window was exposed" + } + .to_string(); + if log_events { + let _ = logging::append( + config_dir, + LogSeverity::Info, + Some(&profile.name), + Some(&app.display_name), + &message, + ); + } + return app_result( + app, + if launched { + AppRestoreStatus::Launched + } else { + AppRestoreStatus::Success + }, + message, + matched, + ); + } + let status = if launched { AppRestoreStatus::LaunchedWindowNotFound } else { @@ -593,6 +684,10 @@ fn restore_app( let mut already_current_count = 0usize; for window in &selected { + if abort_for_latency_sensitive_foreground && performance::foreground_is_latency_sensitive() + { + return background_restore_paused(app, matched); + } let hwnd = match windows_enum::parse_handle(&window.handle) { Ok(hwnd) => hwnd, Err(error) => { @@ -601,10 +696,7 @@ fn restore_app( }; let pull_hidden_window = app.pull_hidden_windows && !window.is_visible; let restore_minimized_window = app.restore_if_minimized && window.is_minimized; - let activate_window = pull_hidden_window - || restore_minimized_window - || activate_after_hidden_restore - || activate_visible_windows; + let activate_window = activate_visible_windows; if window_layout_is_current( window, target.monitor, @@ -614,35 +706,48 @@ fn restore_app( restore_minimized_window, activate_window, ) { + if surface_refresh_needed + && !refresh_exposed_surface( + app, + hwnd, + activate_visible_windows, + previous_foreground, + ) + && log_events + { + let _ = logging::append( + config_dir, + LogSeverity::Warn, + Some(&profile.name), + Some(&app.display_name), + "OBS client surface stayed blank after bounded recovery", + ); + } already_current_count += 1; continue; } - if let Err(error) = window_actions::apply_layout( + if let Err(error) = apply_layout_verified( hwnd, target.monitor, target.layout, - &app.window_state, - app.force_resize, + app, restore_minimized_window, pull_hidden_window, activate_window, ) { - let message = error.to_string(); - let status = if message.to_ascii_lowercase().contains("access") { - AppRestoreStatus::PermissionDenied - } else { - AppRestoreStatus::MoveFailed - }; - if log_events { - let _ = logging::append( - config_dir, - LogSeverity::Error, - Some(&profile.name), - Some(&app.display_name), - &message, - ); - } - return app_result(app, status, message, matched); + return move_failed_result(config_dir, profile, app, matched, error, log_events); + } + if surface_refresh_needed + && !refresh_exposed_surface(app, hwnd, activate_visible_windows, previous_foreground) + && log_events + { + let _ = logging::append( + config_dir, + LogSeverity::Warn, + Some(&profile.name), + Some(&app.display_name), + "OBS client surface stayed blank after bounded recovery", + ); } moved_count += 1; } @@ -684,13 +789,6 @@ fn restore_app( app_result(app, status, message, selected) } -pub fn window_matches_profile_app(profile: &Profile, window: &WindowInfo) -> bool { - profile - .apps - .iter() - .any(|app| window_matches_app(window, app, None)) -} - pub fn find_matching_windows(app: &AppConfig, launched_pid: Option) -> Vec { let mut matched = windows_enum::list_windows_with_hidden(app.pull_hidden_windows || launched_pid.is_some()) @@ -708,7 +806,10 @@ fn sort_matching_windows(matched: &mut [WindowInfo]) { .is_visible .cmp(&left.is_visible) .then(left.is_minimized.cmp(&right.is_minimized)) - .then((right.width * right.height).cmp(&(left.width * left.height))) + .then( + (i64::from(right.width) * i64::from(right.height)) + .cmp(&(i64::from(left.width) * i64::from(left.height))), + ) .then(left.title.cmp(&right.title)) }); } @@ -730,6 +831,15 @@ fn window_layout_is_current( return false; } + window_geometry_matches_layout(window, monitor, app, layout) +} + +fn window_geometry_matches_layout( + window: &WindowInfo, + monitor: &MonitorInfo, + app: &AppConfig, + layout: &LayoutRect, +) -> bool { let rect = window_actions::absolute_rect(monitor, layout); nearly_equal(window.x, rect.left) && nearly_equal(window.y, rect.top) @@ -738,8 +848,83 @@ fn window_layout_is_current( && nearly_equal(window.height, rect.bottom - rect.top))) } +#[allow(clippy::too_many_arguments)] +fn apply_layout_verified( + hwnd: windows::Win32::Foundation::HWND, + monitor: &MonitorInfo, + layout: &LayoutRect, + app: &AppConfig, + restore_minimized_window: bool, + pull_hidden_window: bool, + activate_window: bool, +) -> AppResult<()> { + window_actions::apply_layout( + hwnd, + monitor, + layout, + &app.window_state, + app.force_resize, + restore_minimized_window, + pull_hidden_window, + activate_window, + )?; + if app.window_state != crate::models::WindowStatePreference::Normal { + return Ok(()); + } + + let delays = [80, 180, 320]; + let mut matched_once = false; + let mut last_window = None; + for (index, delay_ms) in delays.into_iter().enumerate() { + thread::sleep(Duration::from_millis(delay_ms)); + let Some(window) = windows_enum::window_info_from_handle_lightweight(hwnd) else { + continue; + }; + if window_geometry_matches_layout(&window, monitor, app, layout) { + if matched_once || index == delays.len() - 1 { + return Ok(()); + } + matched_once = true; + last_window = Some(window); + continue; + } + + matched_once = false; + last_window = Some(window); + if index < delays.len() - 1 { + window_actions::apply_layout( + hwnd, + monitor, + layout, + &app.window_state, + app.force_resize, + false, + false, + false, + )?; + } + } + + let expected = window_actions::absolute_rect(monitor, layout); + let actual = last_window + .map(|window| { + format!( + "{},{} {}x{}", + window.x, window.y, window.width, window.height + ) + }) + .unwrap_or_else(|| "window unavailable".to_string()); + Err(AppError::Windows(format!( + "Window did not hold the saved placement. Expected {},{} {}x{}; got {actual}", + expected.left, + expected.top, + expected.right - expected.left, + expected.bottom - expected.top + ))) +} + fn nearly_equal(left: i32, right: i32) -> bool { - (left - right).abs() <= 3 + left.abs_diff(right) <= 1 } fn should_show_matched_windows(app: &AppConfig, matched: &[WindowInfo]) -> bool { @@ -754,6 +939,20 @@ fn should_restore_through_qt_tray(app: &AppConfig, matched: &[WindowInfo]) -> bo is_obs_app(app) && !matched.is_empty() && matched.iter().all(|window| !window.is_visible) } +fn refresh_exposed_surface( + app: &AppConfig, + hwnd: windows::Win32::Foundation::HWND, + keep_active: bool, + previous_foreground: windows::Win32::Foundation::HWND, +) -> bool { + if is_obs_app(app) { + window_actions::wake_renderer_after_restore(hwnd, keep_active, previous_foreground) + } else { + window_actions::show_window_for_restore(hwnd); + true + } +} + fn selected_matching_windows(app: &AppConfig, matched: &[WindowInfo]) -> Vec { if app.apply_to_all_matching_windows { matched.to_vec() @@ -767,18 +966,25 @@ fn wait_for_visible_windows( launched_pid: Option, timeout_seconds: u64, retry_interval_ms: u64, + abort_for_latency_sensitive_foreground: bool, + require_launch_ready_size: bool, ) -> Vec { let timeout = Duration::from_secs(timeout_seconds.max(1)); - let interval = Duration::from_millis(retry_interval_ms.max(100)); + let interval = Duration::from_millis(retry_interval_ms.clamp(250, 5000)); let deadline = Instant::now() + timeout; let mut last_match = Vec::new(); loop { - let matched = find_matching_windows(app, launched_pid); - if matched - .iter() - .any(|window| window.is_visible && !window.is_minimized) + if abort_for_latency_sensitive_foreground && performance::foreground_is_latency_sensitive() { + return last_match; + } + let matched = find_matching_windows(app, launched_pid); + if matched.iter().any(|window| { + window.is_visible + && !window.is_minimized + && (!require_launch_ready_size || launch_window_is_ready(app, window)) + }) { return matched; } if !matched.is_empty() { @@ -791,6 +997,27 @@ fn wait_for_visible_windows( } } +fn launch_window_is_ready(app: &AppConfig, window: &WindowInfo) -> bool { + if !app.force_resize { + return true; + } + + let minimum_width = (app.layout.width / 2).clamp(160, 960).min(app.layout.width); + let minimum_height = (app.layout.height / 2) + .clamp(120, 720) + .min(app.layout.height); + window.width >= minimum_width && window.height >= minimum_height +} + +fn background_restore_paused(app: &AppConfig, matched: Vec) -> AppRestoreResult { + app_result( + app, + AppRestoreStatus::Paused, + "Restore paused for a foreground game or fullscreen app".to_string(), + matched, + ) +} + fn settle_visible_window(app: &AppConfig) { let delay = if is_obs_app(app) { app.retry_interval_ms.clamp(2500, 3500) @@ -806,6 +1033,34 @@ fn is_obs_app(app: &AppConfig) -> bool { .unwrap_or(false) } +fn launch_detection_timeout_seconds(app: &AppConfig) -> u64 { + if is_quick_tray_detection_app(app) { + app.detection_timeout_seconds.clamp(1, 5) + } else { + app.detection_timeout_seconds + } +} + +fn is_quick_tray_detection_app(app: &AppConfig) -> bool { + configured_process_name(app) + .map(|process_name| names_match("OpenLaunchDeck.exe", &process_name)) + .unwrap_or(false) +} + +fn app_for_restore_launch(app: &AppConfig) -> AppConfig { + let mut launch_config = app.clone(); + if is_quick_tray_detection_app(app) { + launch_config.arguments.retain(|argument| { + !matches!( + argument.trim().to_ascii_lowercase().as_str(), + "--background" | "--start-minimized" | "--show" | "--focus" + ) + }); + launch_config.arguments.push("--show".to_string()); + } + launch_config +} + fn window_matches_app(window: &WindowInfo, app: &AppConfig, launched_pid: Option) -> bool { if !window.is_visible && !app.pull_hidden_windows && launched_pid != Some(window.process_id) { return false; @@ -827,15 +1082,18 @@ fn window_matches_app(window: &WindowInfo, app: &AppConfig, launched_pid: Option .executable_path .as_ref() .zip(window.executable_path.as_ref()) - .map(|(expected, actual)| expected.eq_ignore_ascii_case(actual)) + .map(|(expected, actual)| { + expected.eq_ignore_ascii_case(actual) + || processes::same_windows_app_package(expected, actual) + }) .unwrap_or(false); if !process_matches { return false; } - if app.title_rule.is_none() - && is_known_process_tool_window(window, configured_process_name.as_deref()) + if is_known_process_tool_window(window, configured_process_name.as_deref()) + && !explicitly_targets_tool_window(app, window) { return false; } @@ -963,10 +1221,17 @@ fn is_known_process_tool_window( fn is_obs_main_window(window: &WindowInfo) -> bool { let title = window.title.trim().to_ascii_lowercase(); - title == "obs" - || title.starts_with("obs ") - || title.starts_with("obs-") - || title.contains(" - profile:") + let versioned_title = title + .strip_prefix("obs ") + .and_then(|suffix| suffix.chars().next()) + .is_some_and(|character| character.is_ascii_digit()); + title == "obs" || versioned_title || title.contains(" - profile:") +} + +fn explicitly_targets_tool_window(app: &AppConfig, window: &WindowInfo) -> bool { + app.title_rule.as_ref().is_some_and(|rule| { + matches!(rule.mode, TitleMatchMode::Exact) && title_matches(&window.title, rule) + }) } fn resolve_app_monitor( @@ -976,7 +1241,10 @@ fn resolve_app_monitor( monitors: &[MonitorInfo], ) -> Option { if let Some(id) = &app.target_monitor_id { - if let Some(monitor) = monitors.iter().find(|monitor| &monitor.id == id) { + if let Some(monitor) = monitors + .iter() + .find(|monitor| monitors::monitor_id_matches(monitor, id)) + { return Some(MonitorResolution { monitor: monitor.clone(), is_fallback: false, @@ -984,7 +1252,7 @@ fn resolve_app_monitor( } if matches!( config.global.monitor_missing_behavior, - MonitorMissingBehavior::DoNothing | MonitorMissingBehavior::AskNextOpen + MonitorMissingBehavior::DoNothing ) { return None; } @@ -1004,7 +1272,10 @@ fn resolve_profile_monitor( .or(config.global.default_monitor_id.as_ref()); if let Some(id) = requested { - if let Some(monitor) = monitors.iter().find(|monitor| &monitor.id == id) { + if let Some(monitor) = monitors + .iter() + .find(|monitor| monitors::monitor_id_matches(monitor, id)) + { return Some(MonitorResolution { monitor: monitor.clone(), is_fallback: false, @@ -1012,26 +1283,23 @@ fn resolve_profile_monitor( } return match config.global.monitor_missing_behavior { - MonitorMissingBehavior::DoNothing | MonitorMissingBehavior::AskNextOpen => None, + MonitorMissingBehavior::DoNothing => None, MonitorMissingBehavior::UsePrimary => monitors .iter() .find(|monitor| monitor.is_primary) .cloned() .map(fallback_monitor), - MonitorMissingBehavior::NearestMatch => monitors - .iter() - .find(|monitor| !monitor.is_primary) - .cloned() - .or_else(|| monitors.iter().find(|monitor| monitor.is_primary).cloned()) - .map(fallback_monitor), + MonitorMissingBehavior::NearestMatch => { + nearest_monitor_for_profile(profile, monitors).map(fallback_monitor) + } }; } monitors .iter() - .find(|monitor| !monitor.is_primary) + .find(|monitor| monitor.is_primary) .cloned() - .or_else(|| monitors.iter().find(|monitor| monitor.is_primary).cloned()) + .or_else(|| monitors.first().cloned()) .map(|monitor| MonitorResolution { monitor, is_fallback: false, @@ -1045,12 +1313,118 @@ fn fallback_monitor(monitor: MonitorInfo) -> MonitorResolution { } } +fn restore_layout_for_monitor( + profile: &Profile, + app: &AppConfig, + resolution: &MonitorResolution, +) -> LayoutRect { + let layout = if let Some(captured_display) = &app.captured_display { + scaled_layout_from_captured_display(&app.layout, captured_display, &resolution.monitor) + } else if resolution.is_fallback { + scaled_layout_for_monitor(profile, app, &resolution.monitor) + } else { + app.layout.clone() + }; + keep_layout_visible_on_monitor(&layout, &resolution.monitor) +} + +fn scaled_layout_from_captured_display( + layout: &LayoutRect, + captured: &CapturedDisplay, + monitor: &MonitorInfo, +) -> LayoutRect { + if captured.width <= 0 || captured.height <= 0 || monitor.width <= 0 || monitor.height <= 0 { + return layout.clone(); + } + + let monitor_work_x = monitor.work_x - monitor.x; + let monitor_work_y = monitor.work_y - monitor.y; + if captured.width == monitor.width + && captured.height == monitor.height + && captured.work_x == monitor_work_x + && captured.work_y == monitor_work_y + && captured.work_width == monitor.work_width + && captured.work_height == monitor.work_height + { + return layout.clone(); + } + + let saved_in_work_area = layout.x >= captured.work_x + && layout.y >= captured.work_y + && layout.x.saturating_add(layout.width) + <= captured.work_x.saturating_add(captured.work_width) + && layout.y.saturating_add(layout.height) + <= captured.work_y.saturating_add(captured.work_height) + && captured.work_width > 0 + && captured.work_height > 0 + && monitor.work_width > 0 + && monitor.work_height > 0; + + if saved_in_work_area { + let scale_x = monitor.work_width as f64 / captured.work_width as f64; + let scale_y = monitor.work_height as f64 / captured.work_height as f64; + return LayoutRect { + x: monitor_work_x + scale_i32(layout.x.saturating_sub(captured.work_x), scale_x), + y: monitor_work_y + scale_i32(layout.y.saturating_sub(captured.work_y), scale_y), + width: scale_i32(layout.width, scale_x), + height: scale_i32(layout.height, scale_y), + }; + } + + let scale_x = monitor.width as f64 / captured.width as f64; + let scale_y = monitor.height as f64 / captured.height as f64; + LayoutRect { + x: scale_i32(layout.x, scale_x), + y: scale_i32(layout.y, scale_y), + width: scale_i32(layout.width, scale_x), + height: scale_i32(layout.height, scale_y), + } +} + +fn keep_layout_visible_on_monitor(layout: &LayoutRect, monitor: &MonitorInfo) -> LayoutRect { + let monitor_width = monitor.width.max(1); + let monitor_height = monitor.height.max(1); + let width = layout.width.clamp(80.min(monitor_width), monitor_width); + let height = layout.height.clamp(80.min(monitor_height), monitor_height); + let candidate = LayoutRect { + x: layout.x, + y: layout.y, + width, + height, + }; + if layout_has_usable_visible_area(&candidate, monitor) { + return candidate; + } + + let x = layout.x.clamp(0, monitor_width.saturating_sub(width)); + let y = layout.y.clamp(0, monitor_height.saturating_sub(height)); + + LayoutRect { + x, + y, + width, + height, + } +} + +fn layout_has_usable_visible_area(layout: &LayoutRect, monitor: &MonitorInfo) -> bool { + let right = layout.x.saturating_add(layout.width); + let bottom = layout.y.saturating_add(layout.height); + let visible_width = right.min(monitor.width).saturating_sub(layout.x.max(0)); + let visible_height = bottom.min(monitor.height).saturating_sub(layout.y.max(0)); + let min_visible_width = layout.width.min(80); + let min_visible_height = layout.height.min(80); + + visible_width >= min_visible_width && visible_height >= min_visible_height +} + fn scaled_layout_for_monitor( profile: &Profile, app: &AppConfig, monitor: &MonitorInfo, ) -> LayoutRect { - let Some((canvas_width, canvas_height)) = profile_layout_canvas(profile) else { + let Some((canvas_x, canvas_y, canvas_width, canvas_height)) = profile_layout_canvas(profile) + else { return app.layout.clone(); }; if canvas_width <= 0 || canvas_height <= 0 || monitor.width <= 0 || monitor.height <= 0 { @@ -1059,8 +1433,10 @@ fn scaled_layout_for_monitor( let scale_x = monitor.width as f64 / canvas_width as f64; let scale_y = monitor.height as f64 / canvas_height as f64; - let x = scale_i32(app.layout.x, scale_x).clamp(0, monitor.width.saturating_sub(1)); - let y = scale_i32(app.layout.y, scale_y).clamp(0, monitor.height.saturating_sub(1)); + let x = scale_i32(app.layout.x.saturating_sub(canvas_x), scale_x) + .clamp(0, monitor.width.saturating_sub(1)); + let y = scale_i32(app.layout.y.saturating_sub(canvas_y), scale_y) + .clamp(0, monitor.height.saturating_sub(1)); let available_width = (monitor.width - x).max(1); let available_height = (monitor.height - y).max(1); let width = @@ -1076,7 +1452,9 @@ fn scaled_layout_for_monitor( } } -fn profile_layout_canvas(profile: &Profile) -> Option<(i32, i32)> { +fn profile_layout_canvas(profile: &Profile) -> Option<(i32, i32, i32, i32)> { + let left = profile.apps.iter().map(|app| app.layout.x).min()?.min(0); + let top = profile.apps.iter().map(|app| app.layout.y).min()?.min(0); let right = profile .apps .iter() @@ -1087,7 +1465,31 @@ fn profile_layout_canvas(profile: &Profile) -> Option<(i32, i32)> { .iter() .map(|app| app.layout.y.saturating_add(app.layout.height)) .max()?; - Some((right.max(1), bottom.max(1))) + Some(( + left, + top, + right.saturating_sub(left).max(1), + bottom.saturating_sub(top).max(1), + )) +} + +fn nearest_monitor_for_profile(profile: &Profile, monitors: &[MonitorInfo]) -> Option { + let Some((_, _, canvas_width, canvas_height)) = profile_layout_canvas(profile) else { + return monitors + .iter() + .find(|monitor| monitor.is_primary) + .cloned() + .or_else(|| monitors.first().cloned()); + }; + monitors + .iter() + .min_by_key(|monitor| { + let width_delta = i64::from(monitor.width).abs_diff(i64::from(canvas_width)); + let height_delta = i64::from(monitor.height).abs_diff(i64::from(canvas_height)); + (width_delta.saturating_add(height_delta), monitor.is_primary) + }) + .cloned() + .or_else(|| monitors.first().cloned()) } fn scale_i32(value: i32, scale: f64) -> i32 { @@ -1109,6 +1511,32 @@ fn app_result( } } +fn move_failed_result( + config_dir: &Path, + profile: &Profile, + app: &AppConfig, + matched: Vec, + error: AppError, + log_events: bool, +) -> AppRestoreResult { + let message = error.to_string(); + let status = if message.to_ascii_lowercase().contains("access") { + AppRestoreStatus::PermissionDenied + } else { + AppRestoreStatus::MoveFailed + }; + if log_events { + let _ = logging::append( + config_dir, + LogSeverity::Error, + Some(&profile.name), + Some(&app.display_name), + &message, + ); + } + app_result(app, status, message, matched) +} + #[cfg(test)] mod tests { use super::*; @@ -1144,12 +1572,12 @@ mod tests { title_rule: None, class_name: None, target_monitor_id: None, + captured_display: None, layout: LayoutRect::default(), window_state: WindowStatePreference::Normal, launch_delay_seconds: 0, detection_timeout_seconds: 1, retry_interval_ms: 100, - launch_if_missing: false, move_if_running: true, force_resize: true, apply_to_all_matching_windows: false, @@ -1173,6 +1601,7 @@ mod tests { height: 100, is_visible: true, is_minimized: false, + is_maximized: false, }; assert!(!window_matches_app(&window, &app, None)); @@ -1185,7 +1614,7 @@ mod tests { app.pull_hidden_windows = true; let window = WindowInfo { handle: "0x1".into(), - title: "OBS Studio".into(), + title: "OBS 32.1.2 - Profile: Untitled - Scenes: Untitled".into(), class_name: "QtWindow".into(), process_id: 10, process_name: "obs64.exe".into(), @@ -1197,6 +1626,7 @@ mod tests { height: 100, is_visible: false, is_minimized: false, + is_maximized: false, }; assert!(window_matches_app(&window, &app, None)); @@ -1220,6 +1650,7 @@ mod tests { height: 100, is_visible: true, is_minimized: false, + is_maximized: false, }; assert!(!should_show_matched_windows(&app, &[window])); @@ -1242,6 +1673,7 @@ mod tests { height: 100, is_visible: false, is_minimized: false, + is_maximized: false, }; assert!(should_restore_through_qt_tray(&app, &[window.clone()])); @@ -1251,6 +1683,88 @@ mod tests { assert!(!should_restore_through_qt_tray(&app, &[window])); } + #[test] + fn caps_openlaunchdeck_launch_detection_timeout() { + let mut openlaunchdeck = AppConfig::new_preset( + "openlaunchdeck", + "OpenLaunchDeck", + "OpenLaunchDeck.exe", + LayoutRect::default(), + ); + openlaunchdeck.detection_timeout_seconds = 25; + assert_eq!(launch_detection_timeout_seconds(&openlaunchdeck), 5); + + let mut obs = + AppConfig::new_preset("obs", "OBS Studio", "obs64.exe", LayoutRect::default()); + obs.detection_timeout_seconds = 25; + assert_eq!(launch_detection_timeout_seconds(&obs), 25); + } + + #[test] + fn launched_apps_wait_past_small_splash_windows() { + let mut app = AppConfig::new_preset( + "vesktop", + "Vesktop", + "vesktop.exe", + LayoutRect { + x: 1920, + y: 0, + width: 1920, + height: 1080, + }, + ); + let mut window = WindowInfo { + handle: "0x1".into(), + title: "Loading".into(), + class_name: "Chrome_WidgetWin_1".into(), + process_id: 10, + process_name: "vesktop.exe".into(), + executable_path: None, + monitor_id: None, + x: 0, + y: 0, + width: 320, + height: 320, + is_visible: true, + is_minimized: false, + is_maximized: false, + }; + + assert!(!launch_window_is_ready(&app, &window)); + + window.width = 1600; + window.height = 900; + assert!(launch_window_is_ready(&app, &window)); + + app.force_resize = false; + window.width = 320; + window.height = 320; + assert!(launch_window_is_ready(&app, &window)); + } + + #[test] + fn openlaunchdeck_restore_launch_requests_visible_window() { + let mut app = AppConfig::new_preset( + "openlaunchdeck", + "OpenLaunchDeck", + "OpenLaunchDeck.exe", + LayoutRect::default(), + ); + app.arguments = vec![ + "--background".into(), + "--start-minimized".into(), + "--custom-option".into(), + ]; + + let launch_config = app_for_restore_launch(&app); + + assert_eq!( + launch_config.arguments, + vec!["--custom-option".to_string(), "--show".to_string()] + ); + assert_eq!(app.arguments.len(), 3); + } + #[test] fn rejects_hidden_window_when_pull_is_disabled() { let mut app = @@ -1270,6 +1784,7 @@ mod tests { height: 100, is_visible: false, is_minimized: false, + is_maximized: false, }; assert!(!window_matches_app(&window, &app, None)); @@ -1294,6 +1809,7 @@ mod tests { height: 800, is_visible: true, is_minimized: false, + is_maximized: false, }; assert!(window_matches_app(&window, &app, None)); @@ -1318,6 +1834,36 @@ mod tests { height: 500, is_visible: true, is_minimized: false, + is_maximized: false, + }; + + assert!(!window_matches_app(&window, &app, None)); + } + + #[test] + fn broad_obs_title_rule_still_rejects_tool_windows() { + let mut app = + AppConfig::new_preset("obs", "OBS Studio", "obs64.exe", LayoutRect::default()); + app.title_rule = Some(MatchRule { + mode: TitleMatchMode::StartsWith, + value: "OBS".into(), + case_sensitive: false, + }); + let window = WindowInfo { + handle: "0x1".into(), + title: "OBS Stats".into(), + class_name: "Qt683QWindowIcon".into(), + process_id: 10, + process_name: "obs64.exe".into(), + executable_path: None, + monitor_id: None, + x: 0, + y: 0, + width: 420, + height: 500, + is_visible: true, + is_minimized: false, + is_maximized: false, }; assert!(!window_matches_app(&window, &app, None)); @@ -1346,6 +1892,7 @@ mod tests { height: 500, is_visible: true, is_minimized: false, + is_maximized: false, }; assert!(window_matches_app(&window, &app, None)); @@ -1393,6 +1940,7 @@ mod tests { height: 719, is_visible: true, is_minimized: false, + is_maximized: false, }; assert!(window_layout_is_current( @@ -1416,7 +1964,7 @@ mod tests { } #[test] - fn missing_profile_monitor_uses_secondary_nearest_match() { + fn missing_profile_monitor_uses_closest_resolution() { let mut config = WindowAutoLayoutConfig::default(); config.global.monitor_missing_behavior = MonitorMissingBehavior::NearestMatch; config.profiles[0].target_monitor_id = Some("missing-display".into()); @@ -1428,10 +1976,31 @@ mod tests { let resolved = resolve_profile_monitor(&config, &config.profiles[0], &monitors) .expect("fallback monitor"); - assert_eq!(resolved.monitor.id, "secondary"); + assert_eq!(resolved.monitor.id, "primary"); assert!(resolved.is_fallback); } + #[test] + fn nearest_monitor_prefers_matching_profile_canvas() { + let mut profile = WindowAutoLayoutConfig::default().profiles.remove(0); + profile.apps[0].layout = LayoutRect { + x: 0, + y: 0, + width: 2560, + height: 1440, + }; + profile.apps.truncate(1); + let monitors = vec![ + test_monitor("primary", true, 0, 0, 1920, 1080), + test_monitor("portrait", false, 1920, 0, 1080, 1920), + test_monitor("matching", false, -2560, 0, 2560, 1440), + ]; + + let resolved = nearest_monitor_for_profile(&profile, &monitors).expect("nearest monitor"); + + assert_eq!(resolved.id, "matching"); + } + #[test] fn fallback_layout_scales_profile_canvas_to_target_monitor() { let monitor = test_monitor("secondary", false, -3072, -261, 2560, 1440); @@ -1475,8 +2044,6 @@ mod tests { }, ), ], - startup_restore: true, - enforce_after_restore: true, }; let editor = profile.apps.pop().expect("editor app"); @@ -1494,56 +2061,218 @@ mod tests { } #[test] - fn profile_signature_changes_when_tracked_window_moves_or_minimizes() { + fn keeps_exact_layout_when_it_still_intersects_monitor() { + let monitor = test_monitor("display", true, 0, 0, 1920, 1080); + let layout = LayoutRect { + x: -20, + y: 10, + width: 800, + height: 600, + }; + + assert_eq!(keep_layout_visible_on_monitor(&layout, &monitor), layout); + } + + #[test] + fn clamps_layout_back_when_saved_bounds_miss_target_monitor() { + let monitor = test_monitor("display", true, 0, 0, 1920, 1080); + let layout = LayoutRect { + x: 2400, + y: 1300, + width: 900, + height: 700, + }; + + assert_eq!( + keep_layout_visible_on_monitor(&layout, &monitor), + LayoutRect { + x: 1020, + y: 380, + width: 900, + height: 700, + } + ); + } + + #[test] + fn clamps_layout_back_when_only_a_tiny_edge_is_visible() { + let monitor = test_monitor("display", true, 0, 0, 1920, 1080); + let layout = LayoutRect { + x: 1870, + y: 20, + width: 900, + height: 700, + }; + + assert_eq!( + keep_layout_visible_on_monitor(&layout, &monitor), + LayoutRect { + x: 1020, + y: 20, + width: 900, + height: 700, + } + ); + } + + #[test] + fn clamps_oversized_layout_to_current_monitor_resolution() { + let monitor = test_monitor("display", true, 0, 0, 1920, 1080); + let layout = LayoutRect { + x: 0, + y: 0, + width: 3840, + height: 2160, + }; + + assert_eq!( + keep_layout_visible_on_monitor(&layout, &monitor), + LayoutRect { + x: 0, + y: 0, + width: 1920, + height: 1080, + } + ); + } + + #[test] + fn fallback_scaling_accounts_for_negative_saved_offsets() { + let monitor = test_monitor("display", true, 0, 0, 1000, 500); + let app = AppConfig::new_preset( + "editor", + "Editor", + "Editor.exe", + LayoutRect { + x: -100, + y: -50, + width: 1000, + height: 500, + }, + ); let profile = Profile { id: "profile".into(), - name: "Streaming".into(), + name: "Profile".into(), description: None, target_monitor_id: None, - apps: vec![AppConfig::new_preset( - "discord", - "Discord", - "Discord.exe", - LayoutRect::default(), - )], - startup_restore: true, - enforce_after_restore: true, + apps: vec![app.clone()], }; - let window = WindowInfo { - handle: "0x1".into(), - title: "Discord".into(), - class_name: "Chrome_WidgetWin_1".into(), - process_id: 10, - process_name: "Discord.exe".into(), - executable_path: None, - monitor_id: None, + + assert_eq!( + scaled_layout_for_monitor(&profile, &app, &monitor), + LayoutRect { + x: 0, + y: 0, + width: 1000, + height: 500, + } + ); + } + + #[test] + fn captured_display_scales_full_monitor_layout_after_resolution_change() { + let monitor = test_monitor("display", true, 0, 0, 1920, 1080); + let captured = CapturedDisplay { + width: 3840, + height: 2160, + work_x: 0, + work_y: 0, + work_width: 3840, + work_height: 2080, + scale_percent: 150, + }; + let layout = LayoutRect { + x: 1920, + y: 1080, + width: 1920, + height: 1080, + }; + + assert_eq!( + scaled_layout_from_captured_display(&layout, &captured, &monitor), + LayoutRect { + x: 960, + y: 540, + width: 960, + height: 540, + } + ); + } + + #[test] + fn captured_display_maps_work_area_layout_around_the_current_taskbar() { + let mut monitor = test_monitor("display", true, 0, 0, 2560, 1440); + monitor.work_y = 48; + monitor.work_height = 1392; + let captured = CapturedDisplay { + width: 1920, + height: 1080, + work_x: 0, + work_y: 0, + work_width: 1920, + work_height: 1040, + scale_percent: 100, + }; + let layout = LayoutRect { x: 0, y: 0, - width: 1000, - height: 700, - is_visible: true, - is_minimized: false, + width: 960, + height: 1040, }; - let moved = WindowInfo { - x: 20, - ..window.clone() + + assert_eq!( + scaled_layout_from_captured_display(&layout, &captured, &monitor), + LayoutRect { + x: 0, + y: 48, + width: 1280, + height: 1392, + } + ); + } + + #[test] + fn captured_display_tracks_taskbar_moves_at_the_same_resolution() { + let mut monitor = test_monitor("display", true, 0, 0, 1920, 1080); + monitor.work_y = 40; + monitor.work_height = 1040; + let captured = CapturedDisplay { + width: 1920, + height: 1080, + work_x: 0, + work_y: 0, + work_width: 1920, + work_height: 1040, + scale_percent: 100, }; - let minimized = WindowInfo { - is_minimized: true, - ..window.clone() + let layout = LayoutRect { + x: 0, + y: 0, + width: 960, + height: 1040, }; - let base = profile_window_signature_from_windows(&profile, &[window]); - assert_ne!( - base, - profile_window_signature_from_windows(&profile, &[moved]) - ); - assert_ne!( - base, - profile_window_signature_from_windows(&profile, &[minimized]) + assert_eq!( + scaled_layout_from_captured_display(&layout, &captured, &monitor), + LayoutRect { + x: 0, + y: 40, + width: 960, + height: 1040, + } ); } + #[test] + fn paused_restore_has_an_explicit_non_success_status() { + let profile = WindowAutoLayoutConfig::default().profiles.remove(0); + + let result = paused_restore_result(profile, Utc::now()); + + assert_eq!(result.status, RestoreStatus::Paused); + assert!(result.results.is_empty()); + } + fn test_monitor( id: &str, is_primary: bool, diff --git a/src-tauri/src/single_instance.rs b/src-tauri/src/single_instance.rs new file mode 100644 index 0000000..f3ca7af --- /dev/null +++ b/src-tauri/src/single_instance.rs @@ -0,0 +1,92 @@ +use std::{ffi::c_void, thread}; + +use tauri::AppHandle; +use windows::{ + core::w, + Win32::{ + Foundation::{CloseHandle, GetLastError, ERROR_ALREADY_EXISTS, HANDLE, WAIT_OBJECT_0}, + System::Threading::{CreateEventW, CreateMutexW, SetEvent, WaitForSingleObject}, + }, +}; + +use crate::performance; + +pub struct SingleInstanceGuard { + mutex: HANDLE, + activation_event: Option, +} + +pub fn acquire() -> Option { + let handle = unsafe { CreateMutexW(None, true, w!("Local\\WindowAutoLayout")) }.ok()?; + if unsafe { GetLastError() } == ERROR_ALREADY_EXISTS { + signal_existing_instance(); + unsafe { + let _ = CloseHandle(handle); + } + return None; + } + + let activation_event = unsafe { + CreateEventW( + None, + false, + false, + w!("Local\\WindowAutoLayout.ShowMainWindow"), + ) + .ok() + }; + Some(SingleInstanceGuard { + mutex: handle, + activation_event, + }) +} + +impl SingleInstanceGuard { + pub fn activation_event_address(&self) -> Option { + self.activation_event.map(|event| event.0 as usize) + } +} + +pub fn start_activation_listener(event_address: usize, app: AppHandle) -> std::io::Result<()> { + thread::Builder::new() + .name("windowautolayout-activation".into()) + .spawn(move || { + performance::lower_current_thread_priority(); + let event = HANDLE(event_address as *mut c_void); + loop { + if unsafe { WaitForSingleObject(event, u32::MAX) } != WAIT_OBJECT_0 { + break; + } + crate::show_main_window(&app); + } + }) + .map(|_| ()) +} + +fn signal_existing_instance() { + let Ok(event) = (unsafe { + CreateEventW( + None, + false, + false, + w!("Local\\WindowAutoLayout.ShowMainWindow"), + ) + }) else { + return; + }; + unsafe { + let _ = SetEvent(event); + let _ = CloseHandle(event); + } +} + +impl Drop for SingleInstanceGuard { + fn drop(&mut self) { + unsafe { + if let Some(event) = self.activation_event { + let _ = CloseHandle(event); + } + let _ = CloseHandle(self.mutex); + } + } +} diff --git a/src-tauri/src/tray_ui.rs b/src-tauri/src/tray_ui.rs new file mode 100644 index 0000000..09a7684 --- /dev/null +++ b/src-tauri/src/tray_ui.rs @@ -0,0 +1,118 @@ +use std::sync::{ + atomic::{AtomicBool, Ordering}, + OnceLock, +}; + +use tauri::{ + menu::{CheckMenuItem, MenuItem}, + AppHandle, Emitter, Manager, Wry, +}; + +use crate::{ + errors::{AppError, AppResult}, + models::RuntimeStatus, + startup, + state::AppState, +}; + +pub const TRAY_ID: &str = "windowautolayout-tray"; +pub const STATUS_EVENT: &str = "runtime-status-changed"; + +static RESTORING: AtomicBool = AtomicBool::new(false); +static TRAY_UI: OnceLock = OnceLock::new(); + +struct TrayUi { + app: AppHandle, + restore_item: MenuItem, + automatic_item: CheckMenuItem, +} + +pub fn register( + app: AppHandle, + restore_item: MenuItem, + automatic_item: CheckMenuItem, +) -> AppResult<()> { + TRAY_UI + .set(TrayUi { + app: app.clone(), + restore_item, + automatic_item, + }) + .map_err(|_| AppError::Config("Tray controls were already initialized".to_string()))?; + let _ = sync(&app); + Ok(()) +} + +pub fn runtime_status(app: &AppHandle) -> AppResult { + let state = app.state::(); + let lock = state + .layout_lock + .lock() + .map_err(|_| AppError::Config("Layout lock state was poisoned".to_string()))? + .clone(); + let profile_name = state + .config + .lock() + .map_err(|_| AppError::Config("Config lock was poisoned".to_string()))? + .profiles + .iter() + .find(|profile| Some(&profile.id) == lock.profile_id.as_ref()) + .map(|profile| profile.name.clone()); + + Ok(RuntimeStatus { + automatic_restore_enabled: lock.enabled, + automatic_restore_profile_id: lock.profile_id, + automatic_restore_profile_name: profile_name, + restoring: RESTORING.load(Ordering::Acquire), + startup_registered: startup::startup_enabled(), + }) +} + +pub fn sync(app: &AppHandle) -> AppResult { + let status = runtime_status(app)?; + if let Some(ui) = TRAY_UI.get() { + let automatic_label = if status.automatic_restore_enabled { + "Automatic restore: On" + } else { + "Automatic restore: Off" + }; + let restore_label = if status.restoring { + "Restoring windows..." + } else { + "Restore windows now" + }; + let tooltip = if status.restoring { + "WindowAutoLayout - Restoring windows" + } else if status.automatic_restore_enabled { + "WindowAutoLayout - Automatic restore on" + } else { + "WindowAutoLayout - Automatic restore off" + }; + + let _ = ui + .automatic_item + .set_checked(status.automatic_restore_enabled); + let _ = ui.automatic_item.set_text(automatic_label); + let _ = ui.automatic_item.set_enabled(!status.restoring); + let _ = ui.restore_item.set_text(restore_label); + let _ = ui.restore_item.set_enabled(!status.restoring); + if let Some(tray) = app.tray_by_id(TRAY_ID) { + let _ = tray.set_tooltip(Some(tooltip)); + } + } + let _ = app.emit(STATUS_EVENT, &status); + Ok(status) +} + +pub fn set_restoring(restoring: bool) { + let changed = RESTORING.swap(restoring, Ordering::AcqRel) != restoring; + if changed { + if let Some(ui) = TRAY_UI.get() { + let _ = sync(&ui.app); + } + } +} + +pub fn restoring() -> bool { + RESTORING.load(Ordering::Acquire) +} diff --git a/src-tauri/src/window_actions.rs b/src-tauri/src/window_actions.rs index e0a74b6..41d9f92 100644 --- a/src-tauri/src/window_actions.rs +++ b/src-tauri/src/window_actions.rs @@ -1,17 +1,26 @@ -use std::{thread, time::Duration}; +use std::{ + ffi::c_void, + thread, + time::{Duration, Instant}, +}; use windows::{ core::BOOL, Win32::{ Foundation::{GetLastError, HWND, LPARAM, RECT, WPARAM}, - Graphics::Gdi::{ - RedrawWindow, UpdateWindow, RDW_ALLCHILDREN, RDW_FRAME, RDW_INVALIDATE, RDW_UPDATENOW, + Graphics::{ + Dwm::{DwmGetWindowAttribute, DWMWA_EXTENDED_FRAME_BOUNDS}, + Gdi::{ + GetDC, GetPixel, InvalidateRect, RedrawWindow, ReleaseDC, CLR_INVALID, + RDW_ALLCHILDREN, RDW_FRAME, RDW_INTERNALPAINT, RDW_INVALIDATE, RDW_UPDATENOW, + }, }, UI::WindowsAndMessaging::{ - BringWindowToTop, EnumWindows, GetClassNameW, GetWindowTextW, GetWindowThreadProcessId, - IsIconic, PostMessageW, SetForegroundWindow, SetWindowPos, ShowWindow, HWND_TOP, - SWP_NOACTIVATE, SWP_NOZORDER, SWP_SHOWWINDOW, SW_MAXIMIZE, SW_MINIMIZE, SW_RESTORE, - SW_SHOW, WM_APP, + BringWindowToTop, EnumWindows, GetClassNameW, GetClientRect, GetForegroundWindow, + GetWindowRect, GetWindowTextW, GetWindowThreadProcessId, IsIconic, IsWindow, + IsWindowVisible, IsZoomed, PostMessageW, SetForegroundWindow, SetWindowPos, + ShowWindowAsync, HWND_TOP, SC_MAXIMIZE, SWP_NOACTIVATE, SWP_NOZORDER, SWP_SHOWWINDOW, + SW_RESTORE, SW_SHOWMINNOACTIVE, SW_SHOWNOACTIVATE, WM_APP, WM_SYSCOMMAND, }, }, }; @@ -19,6 +28,7 @@ use windows::{ use crate::{ errors::{AppError, AppResult}, models::{LayoutRect, MonitorInfo, WindowStatePreference}, + performance, }; #[allow(clippy::too_many_arguments)] @@ -34,25 +44,30 @@ pub fn apply_layout( ) -> AppResult<()> { let should_show = pull_hidden_window || (restore_if_minimized && unsafe { IsIconic(hwnd).as_bool() }); + let should_unmaximize = + !matches!(state, WindowStatePreference::Minimized) && unsafe { IsZoomed(hwnd).as_bool() }; if should_show { show_window_for_restore(hwnd); + } else if should_unmaximize { + unsafe { + let _ = ShowWindowAsync(hwnd, SW_SHOWNOACTIVATE); + } + thread::sleep(Duration::from_millis(120)); } - let rect = absolute_rect(monitor, layout); + let frame_rect = absolute_rect(monitor, layout); + let window_rect = rect_for_set_window_pos(hwnd, frame_rect); let width = if force_resize { - rect.right - rect.left + window_rect.right - window_rect.left } else { 0 }; let height = if force_resize { - rect.bottom - rect.top + window_rect.bottom - window_rect.top } else { 0 }; - let mut flags = SWP_SHOWWINDOW; - if !activate_after_show { - flags |= SWP_NOZORDER | SWP_NOACTIVATE; - } + let mut flags = SWP_SHOWWINDOW | SWP_NOZORDER | SWP_NOACTIVATE; if !force_resize { flags |= windows::Win32::UI::WindowsAndMessaging::SWP_NOSIZE; } @@ -61,8 +76,8 @@ pub fn apply_layout( SetWindowPos( hwnd, Some(HWND_TOP), - rect.left, - rect.top, + window_rect.left, + window_rect.top, width, height, flags, @@ -78,20 +93,26 @@ pub fn apply_layout( unsafe { match state { - WindowStatePreference::Normal => { - let _ = ShowWindow(hwnd, SW_RESTORE); - } + WindowStatePreference::Normal => {} WindowStatePreference::Maximized => { - let _ = ShowWindow(hwnd, SW_MAXIMIZE); + let _ = PostMessageW( + Some(hwnd), + WM_SYSCOMMAND, + WPARAM(SC_MAXIMIZE as usize), + LPARAM(0), + ); } WindowStatePreference::Minimized => { - let _ = ShowWindow(hwnd, SW_MINIMIZE); + let _ = ShowWindowAsync(hwnd, SW_SHOWMINNOACTIVE); } }; } + if !matches!(state, WindowStatePreference::Minimized) { + refresh_window_surface(hwnd); + } if activate_after_show { - wake_painted_window(hwnd); + activate_window(hwnd); } Ok(()) @@ -99,11 +120,134 @@ pub fn apply_layout( pub fn show_window_for_restore(hwnd: HWND) { unsafe { - let _ = ShowWindow(hwnd, SW_RESTORE); - let _ = ShowWindow(hwnd, SW_SHOW); + let command = if IsIconic(hwnd).as_bool() { + SW_RESTORE + } else { + SW_SHOWNOACTIVATE + }; + let _ = ShowWindowAsync(hwnd, command); } - thread::sleep(Duration::from_millis(120)); - wake_painted_window(hwnd); + thread::sleep(Duration::from_millis(160)); + refresh_window_surface(hwnd); +} + +pub fn refresh_window_surface(hwnd: HWND) { + queue_full_repaint(hwnd, true); +} + +pub fn foreground_window() -> HWND { + unsafe { GetForegroundWindow() } +} + +pub fn wake_renderer_after_restore(hwnd: HWND, keep_active: bool, previous: HWND) -> bool { + if performance::foreground_is_latency_sensitive() { + return false; + } + + show_window_for_restore(hwnd); + if performance::foreground_is_latency_sensitive() { + return false; + } + activate_window(hwnd); + let mut surface_ready = wait_for_client_surface(hwnd, Duration::from_secs(2)); + if !surface_ready && !performance::foreground_is_latency_sensitive() { + refresh_window_surface(hwnd); + activate_window(hwnd); + surface_ready = wait_for_client_surface(hwnd, Duration::from_secs(2)); + } + + let current = unsafe { GetForegroundWindow() }; + if !keep_active + && previous != hwnd + && current == hwnd + && !previous.0.is_null() + && unsafe { IsWindow(Some(previous)).as_bool() } + { + activate_window(previous); + } + surface_ready +} + +fn wait_for_client_surface(hwnd: HWND, timeout: Duration) -> bool { + let deadline = Instant::now() + timeout; + loop { + if window_is_visible_and_restored(hwnd) { + match client_surface_is_blank(hwnd) { + Some(false) => return true, + None => { + thread::sleep(Duration::from_millis(250)); + return true; + } + Some(true) => {} + } + } + if Instant::now() >= deadline { + return false; + } + thread::sleep(Duration::from_millis(100)); + } +} + +fn window_is_visible_and_restored(hwnd: HWND) -> bool { + unsafe { + IsWindow(Some(hwnd)).as_bool() + && IsWindowVisible(hwnd).as_bool() + && !IsIconic(hwnd).as_bool() + } +} + +fn client_surface_is_blank(hwnd: HWND) -> Option { + let mut rect = RECT::default(); + if unsafe { GetClientRect(hwnd, &mut rect) }.is_err() { + return None; + } + let width = rect.right.saturating_sub(rect.left); + let height = rect.bottom.saturating_sub(rect.top); + if width < 80 || height < 80 { + return None; + } + + let dc = unsafe { GetDC(Some(hwnd)) }; + if dc.0.is_null() { + return None; + } + let points = [ + (width / 4, 8), + (width / 2, 8), + (width * 3 / 4, 8), + (width / 6, height - 12), + (width / 2, height - 12), + (width * 5 / 6, height - 12), + (12, height / 2), + (width - 12, height / 2), + ]; + let samples = points + .into_iter() + .map(|(x, y)| unsafe { GetPixel(dc, x, y) }) + .collect::>(); + unsafe { + ReleaseDC(Some(hwnd), dc); + } + surface_samples_are_blank(&samples) +} + +fn surface_samples_are_blank(samples: &[windows::Win32::Foundation::COLORREF]) -> Option { + let valid = samples + .iter() + .filter(|color| color.0 != CLR_INVALID) + .copied() + .collect::>(); + if valid.len() < 4 { + return None; + } + let white = valid + .iter() + .filter(|color| { + let value = color.0; + value & 0xff >= 245 && (value >> 8) & 0xff >= 245 && (value >> 16) & 0xff >= 245 + }) + .count(); + Some(white * 4 >= valid.len() * 3) } pub fn activate_qt_tray_icon_for_process(process_id: u32) -> bool { @@ -132,27 +276,25 @@ pub fn activate_qt_tray_icon_for_process(process_id: u32) -> bool { sent } -fn wake_painted_window(hwnd: HWND) { +fn activate_window(hwnd: HWND) { + if performance::foreground_is_latency_sensitive() { + return; + } unsafe { let _ = BringWindowToTop(hwnd); let _ = SetForegroundWindow(hwnd); - let _ = RedrawWindow( - Some(hwnd), - None, - None, - RDW_INVALIDATE | RDW_UPDATENOW | RDW_ALLCHILDREN | RDW_FRAME, - ); - let _ = UpdateWindow(hwnd); } - thread::sleep(Duration::from_millis(120)); + queue_full_repaint(hwnd, false); +} + +fn queue_full_repaint(hwnd: HWND, update_now: bool) { + let mut flags = RDW_INVALIDATE | RDW_ALLCHILDREN | RDW_FRAME | RDW_INTERNALPAINT; + if update_now { + flags |= RDW_UPDATENOW; + } unsafe { - let _ = RedrawWindow( - Some(hwnd), - None, - None, - RDW_INVALIDATE | RDW_UPDATENOW | RDW_ALLCHILDREN | RDW_FRAME, - ); - let _ = UpdateWindow(hwnd); + let _ = InvalidateRect(Some(hwnd), None, false); + let _ = RedrawWindow(Some(hwnd), None, None, flags); } } @@ -203,6 +345,76 @@ fn nin_select_message() -> u32 { 0x0400 } +fn rect_for_set_window_pos(hwnd: HWND, target_frame_rect: RECT) -> RECT { + window_frame_margins(hwnd) + .map(|margins| adjust_rect_for_frame_margins(target_frame_rect, margins)) + .unwrap_or(target_frame_rect) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct FrameMargins { + left: i32, + top: i32, + right: i32, + bottom: i32, +} + +fn window_frame_margins(hwnd: HWND) -> Option { + let mut outer = RECT::default(); + let outer_ok = unsafe { GetWindowRect(hwnd, &mut outer) }; + if outer_ok.is_err() { + return None; + } + + let mut frame = RECT::default(); + let frame_ok = unsafe { + DwmGetWindowAttribute( + hwnd, + DWMWA_EXTENDED_FRAME_BOUNDS, + &mut frame as *mut RECT as *mut c_void, + std::mem::size_of::() as u32, + ) + }; + if frame_ok.is_err() { + return None; + } + + if outer.right <= outer.left + || outer.bottom <= outer.top + || frame.right <= frame.left + || frame.bottom <= frame.top + { + return None; + } + + let margins = FrameMargins { + left: frame.left - outer.left, + top: frame.top - outer.top, + right: outer.right - frame.right, + bottom: outer.bottom - frame.bottom, + }; + let values = [margins.left, margins.top, margins.right, margins.bottom]; + if values + .iter() + .any(|value| *value < 0 || *value > MAX_FRAME_MARGIN_PX) + { + return None; + } + + Some(margins) +} + +const MAX_FRAME_MARGIN_PX: i32 = 128; + +fn adjust_rect_for_frame_margins(target_frame_rect: RECT, margins: FrameMargins) -> RECT { + RECT { + left: target_frame_rect.left - margins.left, + top: target_frame_rect.top - margins.top, + right: target_frame_rect.right + margins.right, + bottom: target_frame_rect.bottom + margins.bottom, + } +} + pub fn absolute_rect(monitor: &MonitorInfo, layout: &LayoutRect) -> RECT { RECT { left: monitor.x + layout.x, @@ -253,4 +465,51 @@ mod tests { assert_eq!(rect.left, -1820); assert_eq!(relative_rect(&monitor, rect), layout); } + + #[test] + fn adjusts_saved_frame_rect_to_outer_window_rect() { + let target = RECT { + left: -3061, + top: -244, + right: -1167, + bottom: 815, + }; + let adjusted = adjust_rect_for_frame_margins( + target, + FrameMargins { + left: 9, + top: 0, + right: 9, + bottom: 9, + }, + ); + + assert_eq!(adjusted.left, -3070); + assert_eq!(adjusted.top, -244); + assert_eq!(adjusted.right, -1158); + assert_eq!(adjusted.bottom, 824); + } + + #[test] + fn recognizes_uniform_white_client_samples() { + let samples = vec![windows::Win32::Foundation::COLORREF(0x00ff_ffff); 8]; + + assert_eq!(surface_samples_are_blank(&samples), Some(true)); + } + + #[test] + fn recognizes_a_painted_client_surface() { + let samples = vec![ + windows::Win32::Foundation::COLORREF(0x00ff_ffff), + windows::Win32::Foundation::COLORREF(0x0018_1818), + windows::Win32::Foundation::COLORREF(0x0024_2424), + windows::Win32::Foundation::COLORREF(0x0030_3030), + windows::Win32::Foundation::COLORREF(0x00ff_ffff), + windows::Win32::Foundation::COLORREF(0x0010_1010), + windows::Win32::Foundation::COLORREF(0x0020_2020), + windows::Win32::Foundation::COLORREF(0x0030_3030), + ]; + + assert_eq!(surface_samples_are_blank(&samples), Some(false)); + } } diff --git a/src-tauri/src/windows_enum.rs b/src-tauri/src/windows_enum.rs index 1b37878..d90c175 100644 --- a/src-tauri/src/windows_enum.rs +++ b/src-tauri/src/windows_enum.rs @@ -1,4 +1,4 @@ -use std::ffi::c_void; +use std::{collections::HashMap, ffi::c_void}; use windows::{ core::BOOL, @@ -7,7 +7,7 @@ use windows::{ Graphics::Dwm::{DwmGetWindowAttribute, DWMWA_EXTENDED_FRAME_BOUNDS}, UI::WindowsAndMessaging::{ EnumWindows, GetClassNameW, GetForegroundWindow, GetWindowRect, GetWindowTextLengthW, - GetWindowTextW, GetWindowThreadProcessId, IsIconic, IsWindowVisible, + GetWindowTextW, GetWindowThreadProcessId, IsIconic, IsWindowVisible, IsZoomed, }, }, }; @@ -15,15 +15,28 @@ use windows::{ use crate::{ errors::{AppError, AppResult}, models::WindowInfo, - monitors, - processes::{query_process_name, query_process_path}, + monitors, processes, }; pub fn list_windows_with_hidden(include_hidden: bool) -> AppResult> { + list_windows_with_options(include_hidden, true) +} + +fn list_windows_with_options( + include_hidden: bool, + include_executable_path: bool, +) -> AppResult> { let mut windows: Vec = Vec::new(); + let process_names = processes::list_processes() + .unwrap_or_default() + .into_iter() + .map(|process| (process.pid, process.name)) + .collect::>(); let mut context = EnumWindowsContext { windows: &mut windows, + process_names: &process_names, include_hidden, + include_executable_path, }; let lparam = LPARAM(&mut context as *mut EnumWindowsContext as isize); let ok = unsafe { EnumWindows(Some(enum_window_proc), lparam) }; @@ -41,21 +54,36 @@ pub fn list_windows_with_hidden(include_hidden: bool) -> AppResult Option { let hwnd = unsafe { GetForegroundWindow() }; - window_info_from_handle(hwnd) + window_info_from_handle_lightweight(hwnd) } struct EnumWindowsContext<'a> { windows: &'a mut Vec, + process_names: &'a HashMap, include_hidden: bool, + include_executable_path: bool, } pub fn window_info_from_handle(hwnd: HWND) -> Option { + window_info_from_handle_with_cache(hwnd, None, true) +} + +pub fn window_info_from_handle_lightweight(hwnd: HWND) -> Option { + window_info_from_handle_with_cache(hwnd, None, false) +} + +fn window_info_from_handle_with_cache( + hwnd: HWND, + process_names: Option<&HashMap>, + include_executable_path: bool, +) -> Option { if hwnd.0.is_null() { return None; } let visible = unsafe { IsWindowVisible(hwnd).as_bool() }; let minimized = unsafe { IsIconic(hwnd).as_bool() }; + let maximized = unsafe { IsZoomed(hwnd).as_bool() }; let rect = window_rect(hwnd)?; let width = rect.right - rect.left; let height = rect.bottom - rect.top; @@ -70,8 +98,19 @@ pub fn window_info_from_handle(hwnd: HWND) -> Option { let title = window_text(hwnd); let class_name = class_name(hwnd); - let process_name = query_process_name(pid); - let executable_path = query_process_path(pid); + let executable_path = if include_executable_path { + processes::query_process_path(pid) + } else { + None + }; + let process_name = process_names + .and_then(|names| names.get(&pid).cloned()) + .or_else(|| { + executable_path + .as_deref() + .and_then(processes::file_name_from_path) + }) + .unwrap_or_else(|| processes::query_process_name(pid)); let monitor_id = monitors::monitor_for_rect(rect).map(|monitor| monitor.id); Some(WindowInfo { @@ -88,6 +127,7 @@ pub fn window_info_from_handle(hwnd: HWND) -> Option { height, is_visible: visible, is_minimized: minimized, + is_maximized: maximized, }) } @@ -108,7 +148,11 @@ pub fn handle_to_string(hwnd: HWND) -> String { unsafe extern "system" fn enum_window_proc(hwnd: HWND, lparam: LPARAM) -> BOOL { let context = &mut *(lparam.0 as *mut EnumWindowsContext); - if let Some(info) = window_info_from_handle(hwnd) { + if let Some(info) = window_info_from_handle_with_cache( + hwnd, + Some(context.process_names), + context.include_executable_path, + ) { if (info.is_visible || context.include_hidden) && !is_shell_or_helper_window(&info) { context.windows.push(info); } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 259b340..2cd8dde 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "WindowAutoLayout", - "version": "0.1.18", + "version": "0.1.28", "identifier": "com.rique.windowautolayout", "build": { "beforeDevCommand": "npm run dev", @@ -12,7 +12,10 @@ "app": { "windows": [ { + "create": false, "title": "WindowAutoLayout", + "visible": false, + "incognito": true, "width": 1180, "height": 760, "minWidth": 980, @@ -20,7 +23,16 @@ } ], "security": { - "csp": null + "csp": { + "default-src": "'self' customprotocol: asset:", + "connect-src": "ipc: http://ipc.localhost", + "img-src": "'self' asset: http://asset.localhost data:", + "style-src": "'self' 'unsafe-inline'", + "object-src": "'none'", + "base-uri": "'self'", + "frame-ancestors": "'none'" + }, + "devCsp": null } }, "bundle": { diff --git a/src/App.tsx b/src/App.tsx index e773224..f6fbb7d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,23 +2,26 @@ import { AppWindow, Boxes, FileText, - LockKeyhole, LayoutDashboard, + LoaderCircle, PanelsTopLeft, RefreshCw, + RotateCcw, Save, Settings, + ShieldCheck, } from "lucide-react"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { register, unregisterAll } from "@tauri-apps/plugin-global-shortcut"; +import { listen } from "@tauri-apps/api/event"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { clsx } from "clsx"; import { api, isTauriRuntime } from "./lib/api"; -import { activeProfile, patchProfile, resolveProfileMonitor } from "./lib/helpers"; +import { activeProfile, patchProfile, resolveProfileMonitor, statusText } from "./lib/helpers"; import type { AppConfig, LogEntry, MonitorInfo, RestoreResult, + RuntimeStatus, WindowAutoLayoutConfig, WindowInfo, } from "./lib/types"; @@ -40,6 +43,14 @@ const navItems: Array<{ id: Page; label: string; icon: typeof LayoutDashboard }> { id: "settings", label: "Settings", icon: Settings }, ]; +const initialRuntimeStatus: RuntimeStatus = { + automaticRestoreEnabled: false, + automaticRestoreProfileId: null, + automaticRestoreProfileName: null, + restoring: false, + startupRegistered: false, +}; + export default function App() { const [page, setPage] = useState("dashboard"); const [config, setConfig] = useState(null); @@ -58,8 +69,9 @@ export default function App() { const [busy, setBusy] = useState(false); const [showGrid, setShowGrid] = useState(true); const [message, setMessage] = useState(null); - const [layoutLocked, setLayoutLocked] = useState(false); + const [runtimeStatus, setRuntimeStatus] = useState(initialRuntimeStatus); const [captureMonitorId, setCaptureMonitorId] = useState(null); + const workspaceContentRef = useRef(null); const profile = useMemo(() => (config ? activeProfile(config, selectedProfileId) : null), [config, selectedProfileId]); const effectiveCaptureMonitorId = useMemo(() => { @@ -69,9 +81,12 @@ export default function App() { } return resolveProfileMonitor(config, profile, monitors).monitor?.id ?? monitors[0]?.id ?? ""; }, [captureMonitorId, config, monitors, profile]); + const selectedProfileIsAutomatic = runtimeStatus.automaticRestoreEnabled + && runtimeStatus.automaticRestoreProfileId === profile?.id; + const operationBusy = busy || runtimeStatus.restoring; const refresh = useCallback(async () => { - const [nextConfig, nextMonitors, nextWindows, nextLogs, nextPresets, nextConfigPath, nextLogPath, nextValidation, nextLayoutLocked] = + const [nextConfig, nextMonitors, nextWindows, nextLogs, nextPresets, nextConfigPath, nextLogPath, nextValidation, nextRuntimeStatus] = await Promise.all([ api.getConfig(), api.monitors(), @@ -81,7 +96,7 @@ export default function App() { api.configPath(), api.logPath(), api.validateConfig(), - api.layoutLockStatus(), + api.runtimeStatus(), ]); setConfig(nextConfig); setMonitors(nextMonitors); @@ -91,9 +106,12 @@ export default function App() { setConfigPath(nextConfigPath); setLogPath(nextLogPath); setValidation(nextValidation); - setLayoutLocked(nextLayoutLocked); - setSelectedProfileId((current) => current ?? nextConfig.startup.defaultProfileId ?? nextConfig.profiles[0]?.id ?? null); - setSelectedAppId((current) => current ?? nextConfig.profiles[0]?.apps[0]?.id ?? null); + setRuntimeStatus(nextRuntimeStatus); + setSelectedProfileId((current) => + current && nextConfig.profiles.some((item) => item.id === current) + ? current + : nextConfig.startup.defaultProfileId ?? nextConfig.profiles[0]?.id ?? null, + ); setCaptureMonitorId((current) => { if (current && nextMonitors.some((monitor) => monitor.id === current)) return current; const nextProfile = activeProfile(nextConfig, nextConfig.startup.defaultProfileId ?? nextConfig.profiles[0]?.id ?? null); @@ -106,6 +124,46 @@ export default function App() { refresh().catch((error) => setMessage(String(error))); }, [refresh]); + useEffect(() => { + if (!isTauriRuntime) return undefined; + let cancelled = false; + let unlisten: (() => void) | undefined; + listen("runtime-status-changed", (event) => { + setRuntimeStatus(event.payload); + setConfig((current) => current ? { + ...current, + enforcement: { + ...current.enforcement, + enabled: event.payload.automaticRestoreEnabled, + profileId: event.payload.automaticRestoreProfileId ?? current.enforcement.profileId, + }, + } : current); + }).then((dispose) => { + if (cancelled) dispose(); + else unlisten = dispose; + }).catch((error) => setMessage(String(error))); + + return () => { + cancelled = true; + unlisten?.(); + }; + }, []); + + useEffect(() => { + if (!profile) return; + setSelectedProfileId((current) => (current === profile.id ? current : profile.id)); + setSelectedAppId((current) => + current && profile.apps.some((app) => app.id === current) + ? current + : profile.apps[0]?.id ?? null, + ); + }, [profile]); + + useEffect(() => { + workspaceContentRef.current?.scrollTo({ top: 0, left: 0 }); + window.scrollTo({ top: 0, left: 0 }); + }, [page]); + const restoreSelected = useCallback(async () => { if (!config || !profile) return; setBusy(true); @@ -113,10 +171,10 @@ export default function App() { const saved = dirty ? await api.saveConfig(config) : config; setConfig(saved); setDirty(false); - const result = await api.restoreProfile(profile.id, true); + const result = await api.restoreProfile(profile.id, saved.startup.launchMissingApps); setLastRestore(result); setLogs(await api.logs()); - setMessage(`Restore finished: ${result.status}`); + setMessage(`Restore finished: ${statusText(result.status)}`); } catch (error) { setMessage(String(error)); } finally { @@ -124,28 +182,6 @@ export default function App() { } }, [config, dirty, profile]); - useEffect(() => { - if (!config?.hotkey.enabled || !config.hotkey.accelerator.trim()) { - if (isTauriRuntime) { - unregisterAll().catch(() => undefined); - } - return; - } - - if (!isTauriRuntime) { - return; - } - - const accelerator = normalizeAccelerator(config.hotkey.accelerator); - unregisterAll() - .then(() => register(accelerator, () => restoreSelected())) - .catch((error) => setMessage(`Hotkey registration failed: ${String(error)}`)); - - return () => { - unregisterAll().catch(() => undefined); - }; - }, [config?.hotkey.enabled, config?.hotkey.accelerator, restoreSelected]); - function updateConfig(next: WindowAutoLayoutConfig) { setConfig(next); setDirty(true); @@ -189,11 +225,21 @@ export default function App() { const saved = dirty ? await api.saveConfig(config) : config; setConfig(saved); setDirty(false); - const enabled = await api.setLayoutLock(!layoutLocked, profile.id); + const enabling = !selectedProfileIsAutomatic; + const switchingProfile = runtimeStatus.automaticRestoreEnabled && enabling; + if (enabling) { + const result = await api.restoreProfile(profile.id, saved.startup.launchMissingApps); + setLastRestore(result); + if (["paused", "failed", "monitorMissing"].includes(result.status)) { + setMessage(`Automatic restore not enabled: ${statusText(result.status)}`); + return; + } + } + const enabled = await api.setLayoutLock(enabling, profile.id); setConfig({ ...saved, enforcement: { ...saved.enforcement, enabled, profileId: profile.id } }); - setLayoutLocked(enabled); + setRuntimeStatus(await api.runtimeStatus()); setLogs(await api.logs()); - setMessage(enabled ? "Layout lock on" : "Layout lock off"); + setMessage(enabled ? (switchingProfile ? `Automatic restore now uses ${profile.name}` : "Automatic restore on") : "Automatic restore off"); } catch (error) { setMessage(String(error)); } finally { @@ -250,11 +296,22 @@ export default function App() { setValidation(nextValidation); } + function refreshFromUi() { + if (dirty) { + setMessage("Save your changes before refreshing"); + return; + } + setBusy(true); + refresh() + .catch((error) => setMessage(String(error))) + .finally(() => setBusy(false)); + } + if (!config || !profile) { return (
-
- +
+ Loading WindowAutoLayout
@@ -263,97 +320,129 @@ export default function App() { return (
-
-
); } - -function normalizeAccelerator(accelerator: string) { - return accelerator - .replace(/\bCtrl\b/gi, "CommandOrControl") - .replace(/\bControl\b/gi, "CommandOrControl") - .replace(/\s+/g, ""); -} diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..6092aa5 --- /dev/null +++ b/src/components/ErrorBoundary.tsx @@ -0,0 +1,44 @@ +import { AlertTriangle, RotateCcw } from "lucide-react"; +import { Component, type ErrorInfo, type ReactNode } from "react"; + +interface ErrorBoundaryProps { + children: ReactNode; +} + +interface ErrorBoundaryState { + failed: boolean; +} + +export class ErrorBoundary extends Component { + state: ErrorBoundaryState = { failed: false }; + + static getDerivedStateFromError(): ErrorBoundaryState { + return { failed: true }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + console.error("WindowAutoLayout interface error", error, info.componentStack); + } + + render() { + if (!this.state.failed) return this.props.children; + + return ( +
+
+ +
+

Interface recovery

+

+ WindowAutoLayout is still running. Reload the interface to reconnect. +

+
+ +
+
+ ); + } +} diff --git a/src/components/Form.tsx b/src/components/Form.tsx index 975e723..b12c9cb 100644 --- a/src/components/Form.tsx +++ b/src/components/Form.tsx @@ -2,8 +2,8 @@ import type { InputHTMLAttributes, ReactNode, SelectHTMLAttributes, TextareaHTML export function Field({ label, children }: { label: string; children: ReactNode }) { return ( -