diff --git a/Cargo.lock b/Cargo.lock index 5370407..cbb0b9c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -709,12 +709,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - [[package]] name = "byteorder-lite" version = "0.1.0" @@ -1358,20 +1352,6 @@ dependencies = [ "objc2 0.6.4", ] -[[package]] -name = "display-info" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ba4b5ddb26d674c9cd40b7a747e42658ffe1289843615b838532f660e0e3dd0" -dependencies = [ - "anyhow", - "core-graphics", - "fxhash", - "widestring", - "windows 0.52.0", - "xcb", -] - [[package]] name = "displaydoc" version = "0.2.6" @@ -1914,15 +1894,6 @@ dependencies = [ "slab", ] -[[package]] -name = "fxhash" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" -dependencies = [ - "byteorder", -] - [[package]] name = "gdk" version = "0.18.2" @@ -4343,15 +4314,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" -[[package]] -name = "quick-xml" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eff6510e86862b57b210fd8cbe8ed3f0d7d600b9c2863cd4549a2e033c66e956" -dependencies = [ - "memchr", -] - [[package]] name = "quick-xml" version = "0.39.4" @@ -5316,7 +5278,6 @@ dependencies = [ "bytes", "libc", "mio", - "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", @@ -5919,7 +5880,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" dependencies = [ "proc-macro2", - "quick-xml 0.39.4", + "quick-xml", "quote", ] @@ -6156,9 +6117,7 @@ dependencies = [ "ddc", "ddc-winapi", "dirs 6.0.0", - "display-info", "env_logger", - "futures-util", "global-hotkey", "libcosmic", "log", @@ -6187,16 +6146,6 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "windows" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" -dependencies = [ - "windows-core 0.52.0", - "windows-targets 0.52.6", -] - [[package]] name = "windows" version = "0.61.3" @@ -6240,15 +6189,6 @@ dependencies = [ "windows-core 0.62.2", ] -[[package]] -name = "windows-core" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-core" version = "0.61.2" @@ -6943,17 +6883,6 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" -[[package]] -name = "xcb" -version = "1.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee4c580d8205abb0a5cf4eb7e927bd664e425b6c3263f9c5310583da96970cf6" -dependencies = [ - "bitflags 1.3.2", - "libc", - "quick-xml 0.30.0", -] - [[package]] name = "xcursor" version = "0.3.10" diff --git a/Cargo.toml b/Cargo.toml index 77d43d5..1b18a30 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,14 +19,12 @@ winres = "0.1" [dependencies] ddc = "0.2" ddc-winapi = "0.2" -display-info = "0.4" global-hotkey = "0.8" thiserror = "2.0" -tokio = { version = "1", features = ["full"] } +tokio = { version = "1", features = ["rt", "time", "sync", "macros"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" dirs = "6.0" -futures-util = "0.3" log = "0.4" env_logger = "0.11" tray-icon = "0.25" diff --git a/README.md b/README.md index a690064..63a7304 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Latest release](https://img.shields.io/github/v/release/cyb0rg56/WinDisplayManager)](https://github.com/cyb0rg56/WinDisplayManager/releases/latest) [![MIT license](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -DDC/CI monitor control for Windows β€” brightness, contrast, input switching, +DDC/CI monitor control for Windows: brightness, contrast, input switching, power mode, hotkeys, and display profiles, all from a native GUI. πŸ“– Full docs and download links: **https://cyb0rg56.github.io/WinDisplayManager/** @@ -12,26 +12,34 @@ power mode, hotkeys, and display profiles, all from a native GUI. ## Features - **Brightness & contrast control** over DDC/CI, per monitor. -- **Input source switching** (HDMI, DisplayPort, etc.) with a click or a hotkey. -- **Power mode control** to put a monitor to sleep or wake it from the app. -- **Global hotkeys** with configurable *action chains* β€” one shortcut can run +- **Input source switching** (HDMI, DisplayPort, etc.) from the monitor page + or a hotkey, using the inputs each monitor advertises. +- **Power mode control** to put monitors into standby or turn them off with a + hotkey, or sleep all displays from the tray. +- **Global hotkeys** with configurable *action chains*: one shortcut can run several actions at once (set or offset brightness/contrast, switch inputs, change power mode, write custom DDC/CI VCP codes, apply a profile, or turn displays off), each targeting all displays or specific monitors. Configurable entirely in-app. - **Display profiles** to save and restore whole monitor layouts (resolution, position, orientation) via Windows CCD, switchable instantly or by hotkey. -- **System tray integration** which lives quietly in the tray, always one click away. +- **System tray integration**: closing the window keeps the app in the tray, + with quick access to profiles and turning displays off. Optionally starts + with Windows, minimized to the tray. - **Native GUI** built with [libcosmic](https://github.com/pop-os/libcosmic)/[iced](https://github.com/iced-rs/iced). ## Installation Download the latest `windisplaymanager_rs-*.exe` from the [Releases page](https://github.com/cyb0rg56/WinDisplayManager/releases/latest) -and run it β€” it's a single portable executable, no installer needed. +and run it. It's a single portable executable, no installer needed. > The executable isn't code-signed, so Windows SmartScreen may warn you on > first run. Click **More info β†’ Run anyway** to proceed. +Monitors must have **DDC/CI** enabled in their on-screen menu. Settings are +stored in `%APPDATA%\windisplaymanager\config.json`; see +[Configuration & recovery](https://cyb0rg56.github.io/WinDisplayManager/docs/configuration/). + ## Building from source Requires a recent stable Rust toolchain (edition 2024) on Windows. @@ -46,8 +54,12 @@ To publish a GitHub Release, tag `v` plus the `Cargo.toml` version and push that ## Documentation +- [Monitors guide](https://cyb0rg56.github.io/WinDisplayManager/docs/monitors/) - [Hotkeys guide](https://cyb0rg56.github.io/WinDisplayManager/docs/hotkeys/) - [Profiles guide](https://cyb0rg56.github.io/WinDisplayManager/docs/profiles/) +- [Settings & startup](https://cyb0rg56.github.io/WinDisplayManager/docs/settings/) +- [Configuration & recovery](https://cyb0rg56.github.io/WinDisplayManager/docs/configuration/) +- [Troubleshooting & FAQ](https://cyb0rg56.github.io/WinDisplayManager/docs/troubleshooting/) - [Testing](https://cyb0rg56.github.io/WinDisplayManager/docs/testing/) - [Privacy policy](https://cyb0rg56.github.io/WinDisplayManager/privacy/) diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 9e97f4a..58144f4 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -42,7 +42,7 @@ export default defineConfig({ starlight({ title: 'WinDisplayManager', description: - 'DDC/CI monitor control for Windows β€” brightness, contrast, input switching, power mode, hotkeys, and display profiles.', + 'DDC/CI monitor control for Windows: brightness, contrast, input switching, power mode, hotkeys, and display profiles.', social: [ { icon: 'github', @@ -52,8 +52,12 @@ export default defineConfig({ ], sidebar: [ { label: 'Home', slug: 'index' }, + { label: 'Monitors', slug: 'docs/monitors' }, { label: 'Hotkeys', slug: 'docs/hotkeys' }, { label: 'Profiles', slug: 'docs/profiles' }, + { label: 'Settings & startup', slug: 'docs/settings' }, + { label: 'Configuration & recovery', slug: 'docs/configuration' }, + { label: 'Troubleshooting', slug: 'docs/troubleshooting' }, { label: 'Testing', slug: 'docs/testing' }, { label: 'Privacy', slug: 'privacy' }, ], diff --git a/docs/src/content/docs/docs/configuration.md b/docs/src/content/docs/docs/configuration.md new file mode 100644 index 0000000..a9d2940 --- /dev/null +++ b/docs/src/content/docs/docs/configuration.md @@ -0,0 +1,124 @@ +--- +title: Configuration & recovery +description: Where WinDisplayManager stores its settings, the file format, backups, and how to recover a damaged configuration. +--- + +All settings are stored per user under `%APPDATA%` (your roaming application +data folder). Nothing is written next to the executable. + +| What | Location | +|---|---| +| Settings and hotkeys | `%APPDATA%\windisplaymanager\config.json` | +| Previous settings | `%APPDATA%\windisplaymanager\config.json.bak` | +| Display profiles | `%APPDATA%\MonitorSwitcher\Profiles\.json` | +| Start with Windows | `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, value `WindowsDisplayManager` | + +The **About** page shows the full path of the configuration file, with a +**Copy** button. + +## What gets saved when + +- The **Save** icon in the header writes hotkeys, labels, actions and the + power-off method. It is highlighted while there are unsaved changes. +- **Start with Windows**, **Start minimized** and **Enable global hotkeys** are + saved as soon as you toggle them. Saving writes the whole configuration, so + any unsaved hotkey edits are written at the same time. +- Profiles are written when you save or replace them, independently of + `config.json`. + +If `config.json` doesn't exist yet, the app starts with defaults and creates +the file the first time something is saved. + +## File format + +`config.json` is plain, indented JSON: + +```json +{ + "schema_version": 1, + "hotkeys": { + "hotkeys": [ + { + "id": "hk-…", + "label": "Dim everything", + "binding": { "ctrl": true, "alt": true, "shift": false, "win": false, "key": "F1" }, + "actions": [ + { + "action_type": "Offset", + "target": "Brightness", + "all_monitors": true, + "monitors": [], + "value": -10, + "vcp_code": 16, + "input_source": "Hdmi1", + "monitor_inputs": [], + "power_mode": "On", + "profile_name": "" + } + ] + } + ], + "brightness_step": 10, + "contrast_step": 10 + }, + "hotkeys_enabled": true, + "turn_off_behavior": "None", + "start_with_windows": false, + "start_minimized": false +} +``` + +| Field | Default | Notes | +|---|---|---| +| `schema_version` | `1` | Files without it are treated as older versions and upgraded when loaded. | +| `hotkeys.hotkeys` | `[]` | See the [Hotkeys guide](/docs/hotkeys/). | +| `hotkeys_enabled` | `true` | *Enable global hotkeys* in Settings. | +| `turn_off_behavior` | `"None"` | `"None"`, `"Soft"`, `"Ddc"` or `"Both"`. | +| `start_with_windows` | `false` | Mirrors the registry `Run` value. | +| `start_minimized` | `false` | Kept even when startup is off. | + +Monitor selections in `monitors` and `monitor_inputs` are Windows device paths +(`windows-device-path:v1:\\?\display#…`). A plain number is an old, +[legacy selection](/docs/hotkeys/#migrating-monitor-selections) that needs +rebinding. You normally don't need to edit the file by hand; if you do, exit +the app first so your edits aren't overwritten. + +## Backups + +Every save first copies the current, valid `config.json` to +`config.json.bak`, then replaces the file in one step, so a crash or power +loss mid-save leaves either the old or the new file, never a half-written +one. Profiles work the same way: replacing a profile keeps the previous +version as `.json.bak`. + +The app never overwrites a file it can't read. If `config.json` is damaged, +ordinary saves are refused rather than replacing it. + +## Recovering a damaged configuration + +If `config.json` can't be read (invalid JSON, a newer `schema_version` from a +later release, or a permissions problem), the app shows +**Configuration recovery required** at the top of every page, with the file +path and the error. Until you resolve it: + +- Global hotkeys are disabled, and the Hotkeys page shows only the recovery + panel. +- Settings and hotkey edits are blocked, and nothing is written to + `config.json`. +- Your Start with Windows registration is left as it was. +- Monitor controls and profiles keep working. + +Choose one of: + +| Button | What it does | +|---|---| +| **Retry loading** | Reads `config.json` again. Use this after fixing the file by hand or restoring a copy. | +| **Recover backup** | Loads `config.json.bak` and writes it back as `config.json`. The backup is kept. | +| **Reset to defaults…** | After you click **Confirm reset**, replaces `config.json` with defaults. This discards your hotkeys; the backup is kept. | + +Before resetting, consider copying the damaged file somewhere safe: it may +still contain hotkeys you can re-create. + +A file with a newer `schema_version` usually means you ran a newer release and +then went back to an older one. Upgrade again instead of resetting, or use +**Recover backup** if the backup was written by this version. diff --git a/docs/src/content/docs/docs/hotkeys.md b/docs/src/content/docs/docs/hotkeys.md index eb5f4b5..9ba9305 100644 --- a/docs/src/content/docs/docs/hotkeys.md +++ b/docs/src/content/docs/docs/hotkeys.md @@ -5,7 +5,7 @@ description: How WinDisplayManager hotkey recording, action chains, and config w WinDisplayManager binds **global hotkeys** (they work even when the app isn't focused) to **actions**. Each hotkey is a key combination plus a *chain* of one -or more actions that all run when you press it β€” so a single shortcut can, for +or more actions that all run when you press it. A single shortcut can, for example, dim every display, switch each monitor to a different input, and apply a profile all at once. @@ -15,8 +15,13 @@ Hotkeys are managed on the **Hotkeys** page. Each hotkey card shows: +- Its **title**: the optional **Label** you type, or a numbered fallback such + as `Hotkey (1)` when the label is blank. - Its **key combination** (the accelerator), e.g. `Ctrl + Alt + F1`. -- Its registration status: **Active**, **Inactive**, or **Unbound**. +- Its registration status: **Active** (registered with Windows), + **Inactive** (global hotkeys are disabled, another application already owns + the combination, or it duplicates an earlier hotkey), or **Unbound** (no key + recorded yet). - One or more **actions**, which run in order when the hotkey is pressed. ### Action type @@ -33,7 +38,7 @@ Each hotkey card shows: |---|---| | **Brightness** | An absolute level (Set) or a signed delta (Offset), clamped to the monitor's range. | | **Contrast** | An absolute level (Set) or a signed delta (Offset), clamped to the monitor's range. | -| **Input Source** | Switch to HDMI, DisplayPort, USB-C, VGA or DVI. Can switch a **different input per monitor** (see below). | +| **Input Source** | Switch to HDMI, DisplayPort or USB-C. Can switch a **different input per monitor** (see below). | | **Power Mode** | On / Standby / Suspend / Off, via the DDC/CI power state. | | **Apply Profile** | Applies a saved [display profile](/docs/profiles/). | | **Custom VCP Code** | Write any raw DDC/CI VCP feature code (in hex). Set writes the value directly; Offset reads the monitor's current value first and adds the delta. | @@ -42,13 +47,18 @@ Each hotkey card shows: Actions are edited vertically in four labeled sections: -- **Actions** β€” choose **Set**, **Offset**, or **Turn Off**. -- **Commands** β€” choose Brightness, Contrast, Input Source, Power Mode, +- **Actions**: choose **Set**, **Offset**, or **Turn Off**. +- **Commands**: choose Brightness, Contrast, Input Source, Power Mode, Apply Profile, or Custom VCP Code. -- **Value** β€” type signed decimal values directly. Other commands show their - appropriate dropdown. Custom VCP actions also provide a separate hexadecimal - **VCP code** field. -- **Displays** β€” use switches for **All displays** and each detected monitor. +- **Value**: type signed decimal values directly, from `-200` to `200`. + Other commands show their appropriate dropdown. Custom VCP actions also + provide a separate hexadecimal **VCP code** field (`00`–`FF`, with or + without a `0x` prefix). +- **Displays**: use switches for **All displays** and each detected monitor. + +When an Input Source action targets several monitors, the dropdown only lists +inputs that all of them advertise. If they share none, the editor says +*Selected displays do not share this option*. Turn Off actions only show Actions and Displays because they do not need a command or value. @@ -114,23 +124,34 @@ Display-layout profile remapping is unchanged by this identity migration. ## Creating a hotkey -1. Open the **Hotkeys** page and click **Add Hotkey**. -2. Recording starts immediately β€” press the modifiers and key you want +1. Open the **Hotkeys** page and click **Add Hotkey**. The new hotkey has no + key combination yet and starts with one action: **Set Brightness** to `10` + on **All displays**. Change it to whatever you need. +2. Click **Record** and press the modifiers and key you want (`Ctrl`/`Alt`/`Shift`/`Win` + a key). The captured combination is shown back - to you. Use **Record** to re-record it later, or **Clear** to unbind it. -3. Configure the action (type, target, value, monitors). Click **Add Action** + to you. Use **Record** again to change it, **Cancel** to stop recording, or + **Clear** to unbind it. A combination already used by another hotkey is + refused with *That key combination is already assigned*. +3. Optionally type a **Label** so the card is easy to find. +4. Configure the action (type, target, value, monitors). Click **Add Action** to chain additional actions onto the same hotkey. -4. Click **Save Configuration** to persist everything and (re)register the - hotkeys with Windows. +5. Click the **Save** icon in the header to write everything to disk. The icon + is highlighted while there are unsaved changes. + +Recording, clearing, or deleting a binding updates the Windows registration +immediately, so a new combination works before you save. It is only kept +across restarts after you save. Action edits also take effect immediately for +already registered hotkeys. Remove a single action with **Delete Action**, or the entire hotkey with -**Delete**. All global hotkeys can be toggled on/off from the -**Settings** page without deleting your bindings. +**Delete** (you are asked to confirm). All global hotkeys can be toggled +on/off from [Settings](/docs/settings/) without deleting your bindings. ## Turn-off behavior -The **Turn Off** action type follows the **Power-off method** setting on the -**Settings** page: +The **Turn Off** action type follows the **Power-off method** in +[Settings](/docs/settings/). The default is **None**, so Turn Off actions do +nothing until you choose a method. | Mode | Effect | |---|---| @@ -149,7 +170,10 @@ Hotkeys are saved as JSON alongside the rest of the app configuration at: Each hotkey records its key combination and its list of actions (type, target, value, target monitors, input source(s), power mode, VCP code or profile name, -as applicable). You normally never need to touch this file β€” the Hotkeys page -manages it for you β€” but it's plain, human-readable JSON if you ever want to +as applicable). You normally never need to touch this file (the Hotkeys page +manages it for you), but it's plain, human-readable JSON if you ever want to back it up or inspect it. Older configuration shapes are read automatically; legacy numeric monitor selections require explicit rebinding as described above. +If you edit the file by hand and two hotkeys share a combination, only the +first one is registered. See [Configuration & recovery](/docs/configuration/) +for the full file layout and what happens if it cannot be read. diff --git a/docs/src/content/docs/docs/monitors.md b/docs/src/content/docs/docs/monitors.md new file mode 100644 index 0000000..38dd623 --- /dev/null +++ b/docs/src/content/docs/docs/monitors.md @@ -0,0 +1,96 @@ +--- +title: Monitors +description: Monitor pages, DDC/CI requirements, brightness, contrast, and input switching. +--- + +WinDisplayManager talks to your monitors over **DDC/CI**, the control channel +built into HDMI, DisplayPort and USB-C cables. Changes go straight to +the monitor's own settings, the same ones you'd change with its on-screen +menu buttons. + +## Before you start + +- **Enable DDC/CI on the monitor.** Most monitors have a *DDC/CI* option in + their on-screen menu (often under *System*, *Others*, or *Setup*). Some ship + with it off. +- **Direct connections work best.** Docks, KVM switches, HDMI splitters and + some USB-C adapters don't pass DDC/CI through. +- **Built-in laptop panels don't support DDC/CI.** They don't appear as + controllable monitors. + +If no monitor responds, the app reports *No monitors with DDC/CI support +found*. + +## Monitor pages + +Each detected monitor gets its own entry at the top of the navigation list, +named after the monitor with its resolution, e.g. `DELL U2723QE (3840x2160)`, +or `Monitor N` if Windows reports no name. The **Hotkeys**, **Profiles** and +**About** pages follow. + +A monitor page shows: + +- The monitor name, its resolution and desktop position (e.g. + `3840x2160 at (0, 0)`), and **[Primary]** for the primary display. +- **Input Source**: the current input and a dropdown to switch. +- **Brightness** and **Contrast**: the current value against the monitor's + own maximum (e.g. `70 / 100`) and a slider. + +Monitor numbers such as *Monitor 2* are labels for the current session only. +Hotkeys and profiles store the monitor's full Windows device path instead, so +reordering or reconnecting displays doesn't send a command to the wrong +monitor. + +### Brightness and contrast + +The slider range comes from the maximum each monitor reports, which is +usually 100 but not always. While you drag, the label shows +*(pending)*; the final value is sent once you stop moving the slider, so +rapid changes don't flood the monitor. + +### Input source + +The dropdown lists the inputs the monitor advertises in its DDC/CI +capabilities. If a monitor doesn't advertise a list, the app offers a +standard set instead: HDMI 1/2, DisplayPort 1/2, and USB-C 1/2. Not every +entry in that standard list will exist on your monitor. + +Inputs the app doesn't recognize are shown by their raw code, e.g. +`Custom (0x42)`. + +After switching to another input, the monitor may stop answering DDC/CI on +this PC's cable (it's now showing a different source). That's expected; use +a hotkey or the monitor's buttons to switch back. + +## Refresh and Retry + +- **Refresh** (the icon at the left of the header) detects monitors again and + re-reads every value. It cancels changes that are still waiting to be sent. + Use it after plugging in, unplugging, or rearranging displays. +- If a monitor can't be read at all, its page shows *Monitor N unavailable*, + the error, and a **Retry** button that re-reads just that monitor. +- If only one value fails, its section shows *Could not read …* with the + error and the label reads *Unknown*. Other controls keep working. The + brightness or contrast slider is replaced by *Refresh to adjust*, because + the app won't guess the monitor's range. + +## Power mode and custom VCP codes + +The monitor page doesn't include power controls. To put a monitor into +standby or turn it off, create a [hotkey](/docs/hotkeys/) with a **Power +Mode** or **Turn Off** action, or use **Turn Off Monitors** in the tray menu +(see [Settings & startup](/docs/settings/)). Hotkeys can also write any raw +VCP code with a **Custom VCP Code** action. + +## DDC/CI codes used + +| Feature | VCP code | +|---|---| +| Brightness | `0x10` | +| Contrast | `0x12` | +| Input source | `0x60` | +| Power mode | `0xD6` (On `1`, Standby `2`, Suspend `3`, Off `4`) | + +Standard input values: DisplayPort 1/2 `0x0F`/`0x10`, HDMI 1/2 +`0x11`/`0x12`, USB-C 1/2 `0x13`/`0x14`. Many monitors use their own values; +the app uses whatever the monitor advertises. diff --git a/docs/src/content/docs/docs/profiles.md b/docs/src/content/docs/docs/profiles.md index 240b607..fa8205b 100644 --- a/docs/src/content/docs/docs/profiles.md +++ b/docs/src/content/docs/docs/profiles.md @@ -3,8 +3,8 @@ title: Profiles description: How WinDisplayManager display profiles are stored and applied. --- -A **profile** is a saved snapshot of your monitor layout β€” which displays are -enabled, their resolution, position, and orientation β€” captured via the +A **profile** is a saved snapshot of your monitor layout (which displays are +enabled, their resolution, position, and orientation), captured via the Windows CCD (Connecting and Configuring Displays) API. Profiles let you flip between layouts (e.g. "Docked", "Laptop only", "Presentation") in one click or a single hotkey. @@ -15,19 +15,24 @@ a single hotkey. settings or your dock/monitor switch). 2. Open the **Profiles** page in WinDisplayManager, enter a name in the **New profile name** box, and click **Save Current Layout**. -3. The name becomes the filename, so avoid characters that aren't valid in - Windows filenames (`< > : " / \ | ? *`); the app will strip/reject invalid - names automatically. +3. The name becomes the filename. Characters that aren't valid in Windows + filenames (`< > : " / \ | ? *`) and control characters are removed, along + with leading/trailing spaces and dots. If nothing usable is left (for + example a name made only of dots), the profile is not saved. -If that profile already exists, the app asks you to confirm **Replace** before -capturing and overwriting it. +If a profile with the cleaned-up name already exists, the app asks you to +confirm **Replace** before capturing and overwriting it. + +You can also choose **Save Current Layout…** from the tray menu, which opens +the Profiles page. Delete a profile with **Delete** (you are asked to confirm). ## Applying a profile -Click **Apply** next to a saved profile to apply it immediately. To bind a -profile to a global hotkey, click **Hotkey** next to it β€” this adds a +Click **Apply** next to a saved profile to apply it immediately, or pick it +from **Load Profile** in the tray menu. To bind a +profile to a global hotkey, click **Hotkey** next to it. This adds a matching *Apply Profile* hotkey on the [Hotkeys page](/docs/hotkeys/) -and jumps there so you can record a key combination β€” letting you switch +and jumps there so you can record a key combination, letting you switch layouts without opening the app. ## Safe matching and limitations diff --git a/docs/src/content/docs/docs/settings.md b/docs/src/content/docs/docs/settings.md new file mode 100644 index 0000000..d36580c --- /dev/null +++ b/docs/src/content/docs/docs/settings.md @@ -0,0 +1,70 @@ +--- +title: Settings & startup +description: Startup options, global hotkeys, the power-off method, and the system tray. +--- + +Open **Settings** with the gear icon at the right of the header. The panel has +three sections: **Startup**, **Hotkeys**, and **Turn Off Displays**. + +## Startup + +| Setting | Default | Effect | +|---|---|---| +| **Start with Windows** | Off | Launch WinDisplayManager when you sign in. | +| **Start minimized** | Off | Only shown while *Start with Windows* is on. At sign-in the app starts in the system tray without opening its window. | + +Both toggles are saved as soon as you change them; you don't need to click +**Save**. They are stored per user; no administrator rights are needed. + +Behind the scenes the app writes one value to the registry: + +``` +HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run + WindowsDisplayManager = "C:\path\to\windisplaymanager_rs.exe" [--minimized] +``` + +- `--minimized` is added only when **Start minimized** is on. Launching the + app yourself always opens the window. +- Turning **Start with Windows** off removes the value. Your *Start minimized* + choice is remembered for next time. +- On every launch the value is rewritten to match your saved settings and the + executable's current location, so moving the `.exe` is picked up the next + time you start it manually. +- If the registry update succeeds but saving the configuration fails, the + previous registration is restored. + +## Hotkeys + +**Enable global hotkeys** (default: on) registers or unregisters every hotkey +with Windows. When off, your bindings are kept but pressing them does nothing, +and every hotkey card shows **Inactive**. This toggle is also saved +immediately. See the [Hotkeys guide](/docs/hotkeys/). + +## Turn Off Displays + +**Power-off method** decides what a hotkey **Turn Off** action does. The +default is **None**. Unlike the toggles above, this choice is saved when you +click **Save** in the header. + +| Method | Effect | +|---|---| +| **None** | Turn Off actions do nothing. | +| **Soft (Windows monitor sleep)** | Asks Windows to put the displays to sleep. This affects **all** displays, not just the selected ones; they wake on mouse or keyboard input. | +| **DDC/CI power off** | Sends a DDC/CI power-off command to each selected monitor. Many monitors then need their power button (or a DDC/CI *Power Mode β†’ On* command, if still supported) to wake. | +| **Both** | Does both of the above. | + +## System tray + +Closing the window (the close button or `Alt+F4`) hides WinDisplayManager to +the notification area; it keeps running so hotkeys keep working. Double-click +the tray icon to open the window again, or right-click it for the menu: + +| Menu item | What it does | +|---|---| +| **Show Window** | Opens or focuses the main window. | +| **Load Profile** | Submenu with every saved [profile](/docs/profiles/); pick one to apply it. Disabled when no profiles exist. | +| **Save Current Layout…** | Opens the Profiles page so you can name and save the current layout. | +| **Turn Off Monitors** | Puts all displays to sleep through Windows (the *Soft* method). It always uses Windows sleep, regardless of the Power-off method setting. | +| **Exit** | Quits the app and unregisters all hotkeys. | + +If the tray icon cannot be created, closing the window exits the app instead. diff --git a/docs/src/content/docs/docs/testing.md b/docs/src/content/docs/docs/testing.md index bbad6e0..6e14331 100644 --- a/docs/src/content/docs/docs/testing.md +++ b/docs/src/content/docs/docs/testing.md @@ -43,10 +43,12 @@ the version tag is pushed. ## Tagging a release -Publication happens only when an annotated tag is pushed. The tag must be -`v` plus the three-part `version` already committed in `Cargo.toml` on that -same commit. For the current package version that tag is `v0.1.4`. A tag such -as `v0.1.4.0` does not match and the release job fails. +Publication happens when a tag matching `v*.*.*` is pushed. Use an annotated +tag; the workflow does not enforce this, but it keeps the release author and +message with the tag. The tag must be `v` plus the three-part `version` already +committed in `Cargo.toml` on that same commit. For the current package version +that tag is `v0.1.5`. A tag such as `v0.1.5.0` does not match and the release +job fails. 1. On `main`, set `version` in `Cargo.toml` to `x.y.z`. Run a Cargo command such as `cargo check` so `Cargo.lock` records the same version, then commit @@ -58,11 +60,11 @@ as `v0.1.4.0` does not match and the release job fails. ```powershell git checkout main git pull -git tag -a v0.1.4 -m "WinDisplayManager 0.1.4" -git push origin v0.1.4 +git tag -a v0.1.5 -m "WinDisplayManager 0.1.5" +git push origin v0.1.5 ``` -Replace `0.1.4` with the version you committed. The tag has to point at the +Replace `0.1.5` with the version you committed. The tag has to point at the commit that contains that `Cargo.toml` version. Pushing the tag is the release gate. It runs the same formatting, Clippy, and @@ -72,8 +74,8 @@ unsigned MSIX. If that passes, the release job checks the tag against or sign them. The GitHub Release receives: -- `windisplaymanager_rs-v0.1.4.exe` -- unsigned `windisplaymanager_rs-0.1.4.0.msix` (the fourth part is always `.0`) +- `windisplaymanager_rs-v0.1.5.exe` +- unsigned `windisplaymanager_rs-0.1.5.0.msix` (the fourth part is always `.0`) - generated release notes The executable and MSIX are unsigned, so SmartScreen can warn on the @@ -90,8 +92,8 @@ with the panel, record the model, connection, operation, and raw reply. Unsupported or ambiguous configurations should fail with an explicit error rather than guess a target. -- With the AW2725QF explicitly on DisplayPort 1, compare **Retry reads** and **Refresh** with its on-screen input indication. -- With the DELL U2723QE explicitly on HDMI 1, verify **Retry reads** and **Refresh** show **HDMI 1**. +- With the AW2725QF explicitly on DisplayPort 1, compare **Refresh** (and **Retry**, when a monitor page shows it) with its on-screen input indication. +- With the DELL U2723QE explicitly on HDMI 1, verify **Refresh** (and **Retry**, when shown) reports **HDMI 1**. - Adjust brightness and contrast rapidly and switch between monitor pages. The final requested values must reach the intended displays. - Refresh while changes are queued. Cancellation must be safe and the UI must stay responsive. - Test a disconnected or DDC-unresponsive display. Errors must be visible, working features must stay independent, and retry must be able to recover. @@ -105,6 +107,9 @@ rather than guess a target. ## Known limitations +User-facing symptoms and workarounds for these limitations are in +[Troubleshooting](/docs/troubleshooting/). + - Windows device paths identify device instances. They are not immutable physical identities across every port, dock, or driver change. A path change requires recapture or rebind. - Ambiguous one-to-many physical associations are unsupported. Discovery fails for the whole snapshot instead of guessing from the model name. - Scalar MCCS input-source readback uses the low byte only. A decoded reply is the monitor's reported setting; it does not prove which physical source is displayed or that a switch finished. This is not an MCCS 3.0 table-form input control. diff --git a/docs/src/content/docs/docs/troubleshooting.md b/docs/src/content/docs/docs/troubleshooting.md new file mode 100644 index 0000000..6b4ecf6 --- /dev/null +++ b/docs/src/content/docs/docs/troubleshooting.md @@ -0,0 +1,155 @@ +--- +title: Troubleshooting +description: Fixes for common problems with monitor detection, hotkeys, profiles, and startup, plus a short FAQ. +--- + +Start with **Refresh** (the icon at the left of the header). It detects +monitors again and clears most temporary errors. The status line at the +bottom of the window shows the most recent message or error. + +## Monitors + +### "No monitors with DDC/CI support found" + +- Turn on **DDC/CI** in each monitor's on-screen menu. +- Connect the monitor directly to the PC. Docks, KVMs, splitters and some + USB-C adapters block DDC/CI. +- Try a different cable or port. DisplayPort and HDMI are usually more + reliable than adapters. +- Laptop built-in panels aren't controllable over DDC/CI. + +### A monitor page shows "Unknown" or "Could not read …" + +That value couldn't be read. Other controls on the page still work. Click +**Retry** if shown, or **Refresh**. Some monitors respond slowly right after +waking or switching inputs; wait a few seconds and refresh. If brightness or +contrast says *Refresh to adjust*, the app is waiting for a successful read +before offering a slider. + +### Switching input doesn't work, or the input list is wrong + +- The list comes from the monitor's own capabilities. If the monitor doesn't + advertise any, a standard HDMI/DisplayPort/USB-C list is shown, and some + entries may not exist on your model. +- Some monitors use non-standard input codes. Check what the monitor reports + after switching with its buttons (it may show as `Custom (0x..)`), then use + a hotkey with that **Input Source** or a **Custom VCP Code** action on code + `60`. +- Once a monitor shows another input, it may stop answering this PC. Switch + back with a hotkey or the monitor's buttons. +- The input shown is what the monitor *reports*. It doesn't prove that the + source is actually showing a picture. + +### "Ambiguous monitor association" or "cannot be identified safely" + +The app refuses to guess which physical monitor a command is for. This +happens with **cloned (duplicated) displays**, **tiled** or multi-stream +monitors, or when Windows reports incomplete identity information. Switch +Windows to *Extend these displays*, then click **Refresh**. + +### A hotkey or profile stopped targeting a monitor after I changed ports + +Monitors are identified by their Windows device path, which can change when +you move a monitor to another port or dock, reinstall a GPU driver, or +replace hardware. Open the hotkey's **Displays** section, then **Rebind** or +**Remove** the missing entry and save. For profiles, arrange the layout again +and re-save it with **Replace**. + +## Hotkeys + +### A hotkey shows "Inactive" + +- **Enable global hotkeys** is off in [Settings](/docs/settings/). +- Another application (or Windows itself) already uses that key combination. + Record a different combination. +- Two hotkeys in a hand-edited `config.json` share a combination; only the + first is registered. + +### A hotkey shows "Unbound" + +No key has been recorded yet. Click **Record** and press the combination. + +### "That key combination is already assigned" + +Another hotkey in the app already uses it. Change or clear that hotkey first. + +### "Legacy monitor N (rebind required)" + +The hotkey was created by an older version that stored monitor numbers. See +[Migrating monitor selections](/docs/hotkeys/#migrating-monitor-selections). + +### A Turn Off action does nothing + +The **Power-off method** in Settings defaults to **None**. Choose *Soft*, +*DDC/CI power off*, or *Both*, then click **Save**. + +### A hotkey worked until I restarted the app + +Recording a combination takes effect immediately, but it's only kept after +you click **Save** in the header. + +## Profiles + +See [If applying fails](/docs/profiles/#safe-matching-and-limitations) in the +Profiles guide. In short: reconnect every monitor in the profile, switch +cloned displays to extended, and re-save the profile after changing ports, +docks, drivers or GPUs. + +## Startup + +### The app doesn't start at sign-in + +- Check **Start with Windows** in Settings. +- Check that *WindowsDisplayManager* is enabled in **Task Manager β†’ Startup + apps** (or **Settings β†’ Apps β†’ Startup**). Windows can disable it there + independently of the app. +- If you moved the `.exe`, start it once manually so the startup entry picks + up the new location. + +### The window doesn't appear at sign-in + +**Start minimized** is on. The app is in the notification area; double-click +its icon, or turn *Start minimized* off. + +## Configuration + +### "Configuration recovery required" + +`config.json` couldn't be read, so hotkeys are paused and nothing is +overwritten. See [Recovering a damaged configuration](/docs/configuration/#recovering-a-damaged-configuration). + +## FAQ + +### Windows SmartScreen warns about the download + +Releases aren't code-signed yet. Click **More info β†’ Run anyway**. You can +also [build from source](https://github.com/cyb0rg56/WinDisplayManager#building-from-source). + +### Does it need administrator rights? + +No. Settings, profiles, and the startup entry are all per user. + +### Does it work with laptop screens? + +Built-in laptop panels don't support DDC/CI, so brightness, contrast and input +controls don't apply to them. Display profiles still include them. + +### Can I copy profiles to another PC? + +Not reliably. Profiles identify monitors by Windows device path, which is +specific to each PC and connection. Recreate the layout on the other PC and +save a new profile. + +### Can I turn off just one monitor? + +Yes, with a **Power Mode β†’ Off** action, or a **Turn Off** action with the +*DDC/CI power off* method, targeting only that monitor. The *Soft* method +and the tray's **Turn Off Monitors** always sleep every display. + +### Where are my settings stored? + +See [Configuration & recovery](/docs/configuration/). + +### Does the app send any data anywhere? + +No. See the [privacy policy](/privacy/). diff --git a/docs/src/content/docs/index.mdx b/docs/src/content/docs/index.mdx index 8ee157c..9863caa 100644 --- a/docs/src/content/docs/index.mdx +++ b/docs/src/content/docs/index.mdx @@ -1,10 +1,10 @@ --- title: WinDisplayManager -description: DDC/CI monitor control for Windows β€” brightness, contrast, input switching, power mode, hotkeys, and display profiles. +description: "DDC/CI monitor control for Windows: brightness, contrast, input switching, power mode, hotkeys, and display profiles." template: splash hero: title: WinDisplayManager - tagline: DDC/CI monitor control for Windows β€” brightness, contrast, input switching, power mode, hotkeys, and display profiles, all from a native GUI. + tagline: "DDC/CI monitor control for Windows: brightness, contrast, input switching, power mode, hotkeys, and display profiles, all from a native GUI." image: alt: WinDisplayManager logo file: ../../assets/logo.svg @@ -25,11 +25,11 @@ hero: ## Features - **Brightness & contrast control** over DDC/CI, per monitor. -- **Input source switching** with a click or a hotkey. -- **Power mode control** to put a monitor to sleep or wake it from the app. -- **Global hotkeys** with configurable *action chains* β€” one shortcut can run several actions at once (set or offset brightness/contrast, switch inputs, change power mode, write custom DDC/CI VCP codes, apply a profile, or turn displays off), each targeting all displays or specific monitors. Configurable entirely in-app. +- **Input source switching** from the monitor page or a hotkey, using the inputs each monitor advertises. +- **Power mode control** to put monitors into standby or turn them off with a hotkey, or sleep all displays from the tray. +- **Global hotkeys** with configurable *action chains*: one shortcut can run several actions at once (set or offset brightness/contrast, switch inputs, change power mode, write custom DDC/CI VCP codes, apply a profile, or turn displays off), each targeting all displays or specific monitors. Configurable entirely in-app. - **Display profiles** to save and restore whole monitor layouts (resolution, position, orientation) via Windows CCD, and switch between them instantly or with a hotkey. -- **System tray integration** that lives quietly in the tray, always one click away. +- **System tray integration**: closing the window keeps the app in the tray, with quick access to profiles and turning displays off. Optionally starts with Windows. - **Native GUI** built with [libcosmic](https://github.com/pop-os/libcosmic)/[iced](https://github.com/iced-rs/iced). ## Screenshots @@ -39,13 +39,18 @@ Screenshots coming soon. ## Getting started 1. **Download** the latest `windisplaymanager_rs-*.exe` from the [Releases page](https://github.com/cyb0rg56/WinDisplayManager/releases/latest). -2. **Run it** β€” it's a single portable executable, no installer needed. +2. **Run it.** It's a single portable executable, no installer needed. > Since the executable isn't code-signed, Windows SmartScreen may warn you on first run. Click **More info β†’ Run anyway** to proceed. -3. Your monitors are **detected automatically**. Adjust brightness, contrast, input, and power mode right from the Monitor page. -4. Head to **Hotkeys** to create global hotkeys, or **Profiles** to save your current display layout for quick recall later. +3. Your monitors are **detected automatically**, one page per monitor. Adjust brightness, contrast, and input source from each [monitor page](/docs/monitors/). If a monitor doesn't respond, make sure DDC/CI is enabled in its on-screen menu. +4. Head to **Hotkeys** to create global hotkeys (including power mode actions), or **Profiles** to save your current display layout for quick recall later. +5. Open **Settings** (gear icon in the header) to start with Windows or choose how displays are turned off. ## Documentation -- [Hotkeys guide](/docs/hotkeys/) β€” how hotkey recording and config work. -- [Profiles guide](/docs/profiles/) β€” how display profiles are stored and applied. -- [Testing](/docs/testing/) β€” automated gates, the manual hardware checklist, and how to tag a release. +- [Monitors guide](/docs/monitors/): monitor pages, DDC/CI, and input sources. +- [Hotkeys guide](/docs/hotkeys/): how hotkey recording and config work. +- [Profiles guide](/docs/profiles/): how display profiles are stored and applied. +- [Settings & startup](/docs/settings/): startup, global hotkeys, power-off method, and the tray menu. +- [Configuration & recovery](/docs/configuration/): where settings live and how to recover a damaged file. +- [Troubleshooting](/docs/troubleshooting/): common problems and FAQ. +- [Testing](/docs/testing/): automated gates, the manual hardware checklist, and how to tag a release. diff --git a/docs/src/content/docs/privacy.md b/docs/src/content/docs/privacy.md index 24ff257..3d5dae7 100644 --- a/docs/src/content/docs/privacy.md +++ b/docs/src/content/docs/privacy.md @@ -3,22 +3,26 @@ title: Privacy Policy description: WinDisplayManager collects nothing and sends nothing over the network. --- -_Last updated: 2026-07-22_ +_Last updated: 2026-09-29_ ## The short version **WinDisplayManager collects nothing and sends nothing over the network.** -There is no telemetry, no analytics, no crash reporting, and no network code -in the application at all β€” this has been verified by reviewing the source -and its dependencies. +There is no telemetry, no analytics, and no crash reporting. The application's +own code makes no network requests. ## What the app does locally Everything WinDisplayManager reads or writes stays on your own machine: - **Hotkey configuration**: Your global hotkey bindings and settings are - saved as JSON at `%APPDATA%\windisplaymanager\config.json`. See the - [Hotkeys guide](/docs/hotkeys/). + saved as JSON at `%APPDATA%\windisplaymanager\config.json`, with a + `config.json.bak` backup of the previous version. See + [Configuration & recovery](/docs/configuration/). +- **Start with Windows**: If enabled, the app writes a single + `WindowsDisplayManager` value under + `HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run`. See + [Settings & startup](/docs/settings/). - **Display profiles**: Saved monitor layouts are stored as JSON files under `%APPDATA%\MonitorSwitcher\Profiles\`. See the [Profiles guide](/docs/profiles/). @@ -40,8 +44,8 @@ advertising in the application itself. This site is a static page hosted on GitHub Pages. WinDisplayManager (the project) does not add its own analytics or tracking scripts to this site. GitHub's own hosting infrastructure may collect standard web server logs as -described in [GitHub's Privacy Statement](https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement) β€” -that collection is GitHub's, not ours. +described in [GitHub's Privacy Statement](https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement). +That collection is GitHub's, not ours. ## Changes to this policy diff --git a/src/app.rs b/src/app.rs index a7b594a..f6f3492 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,4 +1,5 @@ mod actions; +mod config_ops; mod coordination; mod debounce; mod hardware_queue; @@ -14,7 +15,7 @@ mod views; use self::actions::HardwareJob; use self::coordination::{HardwareCoordinator, HardwareResult}; -use self::debounce::{DebounceToken, SliderDebounce}; +use self::debounce::{DebounceToken, SliderDebounce, SliderFeature}; use crate::config::{ ActionTarget, ActionType, AppConfig, ConfigStore, HotkeyActionSpec, MonitorTarget, @@ -68,8 +69,6 @@ pub enum Message { // Monitor controls RefreshMonitors, RetryMonitor(u32), - SetBrightness(u32, u16), - SetContrast(u32, u16), SelectInputSource(u32, InputSource), HardwareJobFinished(u64, Result), // Debounced slider changes @@ -115,9 +114,10 @@ pub enum Message { RequestResetConfig, CancelResetConfig, ConfirmResetConfig, + ConfigOpFinished(config_ops::ConfigOp, config_ops::ConfigOpDone), // Profiles RefreshProfiles, - ProfilesListed(Vec), + ProfilesListed(u64, Result, String>), ProfileNameInput(String), SaveCurrentProfile(String), ConfirmReplaceProfile, @@ -136,8 +136,6 @@ pub enum Message { /// Discard edits to the read-only configuration path field. ConfigPathInput, CopyConfigPath, - // Errors - Error(String), } // --------------------------------------------------------------------------- @@ -163,6 +161,8 @@ pub struct AppModel { monitor_load_errors: HashMap, config: AppConfig, config_store: ConfigStore, + config_ops: std::collections::VecDeque, + config_op_active: bool, pending_config_reset: bool, hotkey_manager: Option, hotkey_action_map: Arc>>, @@ -179,6 +179,7 @@ pub struct AppModel { about: widget::about::About, slider_debounce: SliderDebounce, profiles: Vec, + profiles_request: u64, profile_name_input: String, pending_profile_delete: Option, pending_profile_replace: Option, @@ -209,7 +210,10 @@ impl cosmic::Application for AppModel { fn init(core: Core, _flags: Self::Flags) -> (Self, cosmic::app::Task) { // Load persistent config - let (config_store, outcome) = ConfigStore::open(AppConfig::config_path()); + let (config_store, outcome) = match AppConfig::config_path() { + Ok(path) => ConfigStore::open(path), + Err(error) => ConfigStore::unavailable(error), + }; let config = match outcome { LoadOutcome::Missing => AppConfig::default(), LoadOutcome::Loaded(config) => config, @@ -284,6 +288,8 @@ impl cosmic::Application for AppModel { monitor_load_errors: HashMap::new(), config, config_store, + config_ops: Default::default(), + config_op_active: false, pending_config_reset: false, hotkey_manager, hotkey_action_map, @@ -300,6 +306,7 @@ impl cosmic::Application for AppModel { about, slider_debounce: SliderDebounce::default(), profiles: Vec::new(), + profiles_request: 0, profile_name_input: String::new(), pending_profile_delete: None, pending_profile_replace: None, @@ -475,23 +482,19 @@ impl cosmic::Application for AppModel { // -- Brightness ------------------------------------------------- Message::BrightnessSliderChanged(monitor_id, value) => { - return self.brightness_slider_changed(monitor_id, value); + return self.slider_changed(monitor_id, SliderFeature::Brightness, value); } Message::ApplyBrightnessDebounced(monitor_id, token) => { - return self.apply_brightness_debounced(monitor_id, token); - } - Message::SetBrightness(monitor_id, value) => { - return self.set_brightness(monitor_id, value); + return self.apply_slider_debounced(monitor_id, SliderFeature::Brightness, token); } // -- Contrast --------------------------------------------------- Message::ContrastSliderChanged(monitor_id, value) => { - return self.contrast_slider_changed(monitor_id, value); + return self.slider_changed(monitor_id, SliderFeature::Contrast, value); } Message::ApplyContrastDebounced(monitor_id, token) => { - return self.apply_contrast_debounced(monitor_id, token); + return self.apply_slider_debounced(monitor_id, SliderFeature::Contrast, token); } - Message::SetContrast(monitor_id, value) => return self.set_contrast(monitor_id, value), // -- Input source ----------------------------------------------- Message::SelectInputSource(monitor_id, source) => { @@ -565,17 +568,18 @@ impl cosmic::Application for AppModel { } Message::CloseSettings => self.set_show_context(false), Message::ToggleHotkeys(enabled) => return self.toggle_hotkeys(enabled), - Message::RetryConfig => self.retry_config(), - Message::RecoverConfigBackup => self.recover_config_backup(), + Message::RetryConfig => return self.retry_config(), + Message::RecoverConfigBackup => return self.recover_config_backup(), Message::RequestResetConfig => { self.pending_config_reset = self.config_store.recovery_error().is_some(); } Message::CancelResetConfig => self.pending_config_reset = false, - Message::ConfirmResetConfig => self.confirm_reset_config(), + Message::ConfirmResetConfig => return self.confirm_reset_config(), + Message::ConfigOpFinished(op, done) => return self.config_op_finished(op, done), // -- Profiles --------------------------------------------------- Message::RefreshProfiles => return self.refresh_profiles(), - Message::ProfilesListed(profiles) => self.profiles_listed(profiles), + Message::ProfilesListed(request, listed) => self.profiles_listed(request, listed), Message::ProfileNameInput(value) => self.profile_name_input(value), Message::SaveCurrentProfile(name) => return self.save_current_profile(name), Message::ConfirmReplaceProfile => return self.confirm_replace_profile(), @@ -653,12 +657,6 @@ impl cosmic::Application for AppModel { self.status_message = format!("Failed to open URL: {error}"); } } - - // -- Errors / misc ---------------------------------------------- - Message::Error(msg) => { - log::error!("{msg}"); - self.status_message = msg; - } } cosmic::app::Task::none() diff --git a/src/app/actions.rs b/src/app/actions.rs index ca55cb6..c81cd1b 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -51,6 +51,9 @@ pub enum HardwareJob { name: String, replace: bool, }, + DeleteProfile { + name: String, + }, SoftTurnOff { monitor_ids: Vec, }, @@ -165,6 +168,13 @@ pub enum HardwareOutcome { ProfileSaved { name: String, }, + /// A create-new save found an existing profile; the user must confirm replacement. + ProfileExists { + name: String, + }, + ProfileDeleted { + name: String, + }, MonitorsPoweredOff, } @@ -184,9 +194,17 @@ impl PreparedJob { return Ok(HardwareOutcome::ProfileApplied { name }); } if let HardwareJob::SaveProfile { name, replace } = self.job { - profiles::save_current(&name, replace) - .map_err(|e| format!("Save profile error: {e}"))?; - return Ok(HardwareOutcome::ProfileSaved { name }); + return match profiles::save_current(&name, replace) { + Ok(()) => Ok(HardwareOutcome::ProfileSaved { name }), + Err(profiles::ProfileError::AlreadyExists(_)) if !replace => { + Ok(HardwareOutcome::ProfileExists { name }) + } + Err(e) => Err(format!("Save profile error: {e}")), + }; + } + if let HardwareJob::DeleteProfile { name } = self.job { + profiles::delete_profile(&name).map_err(|e| format!("Delete profile error: {e}"))?; + return Ok(HardwareOutcome::ProfileDeleted { name }); } self.execute_monitor_job(ddc::Session::open, ccd::turn_off_monitors) } diff --git a/src/app/config_ops.rs b/src/app/config_ops.rs new file mode 100644 index 0000000..986ce5c --- /dev/null +++ b/src/app/config_ops.rs @@ -0,0 +1,171 @@ +use super::{AppModel, Message}; +use crate::config::{AppConfig, ConfigStore}; +use crate::persistence::LoadOutcome; +use crate::startup; + +/// Config and registry writes run one at a time off the UI thread. Each +/// operation reads `AppModel::config` when it starts, not when requested. +#[derive(Clone, Debug)] +pub enum ConfigOp { + Save, + SetHotkeysEnabled(bool), + SetStartup { enabled: bool, minimized: bool }, + Retry, + RecoverBackup, + Reset, +} + +#[derive(Clone, Debug)] +pub struct ConfigOpDone { + /// `None` only if the blocking task itself failed. + store: Option, + /// Loaded or recovered config for recovery operations; `None` otherwise. + outcome: Result, String>, +} + +fn run(op: &ConfigOp, mut store: ConfigStore, mut config: AppConfig) -> ConfigOpDone { + let outcome = match *op { + ConfigOp::Save => store + .save(&config) + .map(|()| None) + .map_err(|e| format!("Failed to save config: {e}")), + ConfigOp::SetHotkeysEnabled(enabled) => store + .set_hotkeys_enabled(&mut config, enabled) + .map(|()| None) + .map_err(|e| format!("Failed to save config: {e}")), + ConfigOp::SetStartup { enabled, minimized } => { + set_startup(&mut store, config, enabled, minimized).map(|()| None) + } + ConfigOp::Retry => match store.retry() { + LoadOutcome::Loaded(config) => Ok(Some(config)), + LoadOutcome::Missing => Ok(None), + LoadOutcome::Failed(e) => Err(format!("Configuration still needs recovery: {e}")), + }, + ConfigOp::RecoverBackup => store + .recover_backup() + .map(Some) + .map_err(|e| format!("Could not recover backup: {e}")), + ConfigOp::Reset => store + .reset_confirmed() + .map(Some) + .map_err(|e| format!("Could not reset configuration: {e}")), + }; + ConfigOpDone { + store: Some(store), + outcome, + } +} + +/// The registry is updated first. If saving config fails, the previous +/// registration is written back. +fn set_startup( + store: &mut ConfigStore, + mut config: AppConfig, + enabled: bool, + minimized: bool, +) -> Result<(), String> { + let previous = (config.start_with_windows, config.start_minimized); + startup::apply(enabled, minimized) + .map_err(|e| format!("Failed to update Windows startup: {e}"))?; + config.start_with_windows = enabled; + config.start_minimized = minimized; + store.save(&config).map_err(|error| { + if let Err(revert_error) = startup::apply(previous.0, previous.1) { + log::error!("Failed to restore Windows startup registration: {revert_error}"); + } + format!("Failed to save config: {error}") + }) +} + +impl AppModel { + pub(super) fn enqueue_config_op(&mut self, op: ConfigOp) -> cosmic::app::Task { + self.config_ops.push_back(op); + self.start_next_config_op() + } + + fn start_next_config_op(&mut self) -> cosmic::app::Task { + if self.config_op_active { + return cosmic::app::Task::none(); + } + let Some(op) = self.config_ops.pop_front() else { + return cosmic::app::Task::none(); + }; + self.config_op_active = true; + if matches!(op, ConfigOp::Save) { + // Edits made while the save is in flight mark the config dirty again. + self.config_dirty = false; + } + let store = self.config_store.clone(); + let config = self.config.clone(); + let task_op = op.clone(); + cosmic::app::Task::perform( + async move { tokio::task::spawn_blocking(move || run(&task_op, store, config)).await }, + move |result| { + let done = result.unwrap_or_else(|error| ConfigOpDone { + store: None, + outcome: Err(format!("Task join error: {error}")), + }); + cosmic::Action::App(Message::ConfigOpFinished(op.clone(), done)) + }, + ) + } + + pub(super) fn config_op_finished( + &mut self, + op: ConfigOp, + done: ConfigOpDone, + ) -> cosmic::app::Task { + self.config_op_active = false; + if let Some(store) = done.store { + self.config_store = store; + } + match (op, done.outcome) { + (ConfigOp::Save, Err(error)) => { + self.config_dirty = true; + self.status_message = error; + } + (_, Err(error)) => self.status_message = error, + (ConfigOp::Save, Ok(_)) => { + self.status_message = "Configuration saved and hotkeys activated.".into(); + self.refresh_hotkey_registration(); + } + (ConfigOp::SetHotkeysEnabled(enabled), Ok(_)) => { + self.config.hotkeys_enabled = enabled; + self.status_message = if enabled { + "Hotkeys enabled" + } else { + "Hotkeys disabled" + } + .into(); + self.refresh_hotkey_registration(); + } + (ConfigOp::SetStartup { enabled, minimized }, Ok(_)) => { + self.config.start_with_windows = enabled; + self.config.start_minimized = minimized; + self.status_message = if !enabled { + "Will not start with Windows".into() + } else if minimized { + "Will start with Windows, minimized to the tray".into() + } else { + "Will start with Windows".into() + }; + } + (ConfigOp::Retry, Ok(Some(config))) => { + self.install_recovered_config(config, "Configuration reloaded.") + } + (ConfigOp::Retry, Ok(None)) => self.install_recovered_config( + AppConfig::default(), + "No configuration file found; using defaults.", + ), + (ConfigOp::RecoverBackup, Ok(Some(config))) => { + self.install_recovered_config(config, "Configuration restored from backup.") + } + (ConfigOp::Reset, Ok(Some(config))) => self.install_recovered_config( + config, + "Configuration reset to defaults. Existing backup retained.", + ), + (ConfigOp::RecoverBackup | ConfigOp::Reset, Ok(None)) => {} + } + self.start_next_config_op() + } +} diff --git a/src/app/coordination.rs b/src/app/coordination.rs index 16dd869..eb2177a 100644 --- a/src/app/coordination.rs +++ b/src/app/coordination.rs @@ -124,12 +124,9 @@ impl HardwareCoordinator { if !self.accepts_monitor(id) { return false; } - let info = self - .available - .iter() - .find(|info| info.id == id) - .unwrap() - .clone(); + let Some(info) = self.available.iter().find(|info| info.id == id).cloned() else { + return false; + }; self.executor.extend([Queued::Read(self.generation, info)]); true } @@ -213,7 +210,9 @@ impl HardwareCoordinator { if job.generation == self.generation || matches!( job.job, - HardwareJob::ApplyProfile { .. } | HardwareJob::SaveProfile { .. } + HardwareJob::ApplyProfile { .. } + | HardwareJob::SaveProfile { .. } + | HardwareJob::DeleteProfile { .. } ) => { if matches!(job.job, HardwareJob::ApplyProfile { .. }) { diff --git a/src/app/hardware_queue.rs b/src/app/hardware_queue.rs index 47326ad..0c54bb5 100644 --- a/src/app/hardware_queue.rs +++ b/src/app/hardware_queue.rs @@ -157,6 +157,15 @@ impl AppModel { self.profile_name_input.clear(); self.refresh_profiles_after_jobs = true; } + Ok(HardwareOutcome::ProfileExists { name }) => { + self.status_message = + format!("Profile '{name}' already exists. Confirm replacement."); + self.pending_profile_replace = Some(name); + } + Ok(HardwareOutcome::ProfileDeleted { name }) => { + self.status_message = format!("Deleted profile '{name}'."); + self.refresh_profiles_after_jobs = true; + } Ok(HardwareOutcome::MonitorsPoweredOff) => { self.status_message = "Monitors turned off.".into(); } diff --git a/src/app/hotkey_editor.rs b/src/app/hotkey_editor.rs index f30364c..f725de0 100644 --- a/src/app/hotkey_editor.rs +++ b/src/app/hotkey_editor.rs @@ -1,4 +1,5 @@ use super::AppModel; +use super::config_ops::ConfigOp; use super::{Message, Page, RecordingState}; use crate::config::{ ActionTarget, ActionType, AppConfig, Hotkey, HotkeyActionSpec, HotkeyBinding, MonitorInput, @@ -6,7 +7,6 @@ use crate::config::{ }; use crate::ddc::{InputSource, MonitorKey, PowerMode}; use crate::hotkeys::HotkeyManager; -use crate::persistence::LoadOutcome; use cosmic::iced::keyboard::{Key, Modifiers}; use std::collections::HashMap; use std::sync::Arc; @@ -229,6 +229,10 @@ impl AppModel { pub(super) fn validate_action_drafts(&self) -> Option { for hotkey in &self.config.hotkeys.hotkeys { for (idx, action) in hotkey.actions.iter().enumerate() { + // Off actions hide their value fields, so stale drafts cannot be corrected. + if action.action_type == ActionType::Off { + continue; + } if matches!( action.target, ActionTarget::Brightness | ActionTarget::Contrast | ActionTarget::CustomVcp @@ -630,78 +634,35 @@ impl AppModel { self.status_message = error; return cosmic::app::Task::none(); } - if let Err(e) = self.config_store.save(&self.config) { - self.status_message = format!("Failed to save config: {e}"); - } else { - self.status_message = "Configuration saved and hotkeys activated.".into(); - self.config_dirty = false; - self.refresh_hotkey_registration(); - } - cosmic::app::Task::none() + self.enqueue_config_op(ConfigOp::Save) } pub(super) fn toggle_hotkeys(&mut self, enabled: bool) -> cosmic::app::Task { - match self - .config_store - .set_hotkeys_enabled(&mut self.config, enabled) - { - Ok(()) => { - self.status_message = if enabled { - "Hotkeys enabled" - } else { - "Hotkeys disabled" - } - .into(); - self.refresh_hotkey_registration(); - } - Err(error) => self.status_message = format!("Failed to save config: {error}"), - } - cosmic::app::Task::none() + self.enqueue_config_op(ConfigOp::SetHotkeysEnabled(enabled)) } - pub(super) fn retry_config(&mut self) { + pub(super) fn retry_config(&mut self) -> cosmic::app::Task { self.pending_config_reset = false; - match self.config_store.retry() { - LoadOutcome::Loaded(config) => { - self.install_recovered_config(config, "Configuration reloaded.") - } - LoadOutcome::Missing => self.install_recovered_config( - AppConfig::default(), - "No configuration file found; using defaults.", - ), - LoadOutcome::Failed(error) => { - self.status_message = format!("Configuration still needs recovery: {error}") - } - } + self.enqueue_config_op(ConfigOp::Retry) } - pub(super) fn recover_config_backup(&mut self) { + pub(super) fn recover_config_backup(&mut self) -> cosmic::app::Task { self.pending_config_reset = false; - match self.config_store.recover_backup() { - Ok(config) => { - self.install_recovered_config(config, "Configuration restored from backup.") - } - Err(error) => self.status_message = format!("Could not recover backup: {error}"), - } + self.enqueue_config_op(ConfigOp::RecoverBackup) } - pub(super) fn confirm_reset_config(&mut self) { + pub(super) fn confirm_reset_config(&mut self) -> cosmic::app::Task { if !std::mem::take(&mut self.pending_config_reset) || self.config_store.recovery_error().is_none() { - return; - } - match self.config_store.reset_confirmed() { - Ok(config) => self.install_recovered_config( - config, - "Configuration reset to defaults. Existing backup retained.", - ), - Err(error) => self.status_message = format!("Could not reset configuration: {error}"), + return cosmic::app::Task::none(); } + self.enqueue_config_op(ConfigOp::Reset) } - fn install_recovered_config(&mut self, config: AppConfig, status: &str) { + pub(super) fn install_recovered_config(&mut self, config: AppConfig, status: &str) { self.config = config; + self.config_path = self.config_store.path().display().to_string(); self.config_dirty = false; self.recording_state = RecordingState::NotRecording; self.expanded_hotkey = None; diff --git a/src/app/monitors.rs b/src/app/monitors.rs index b58e66e..673daaf 100644 --- a/src/app/monitors.rs +++ b/src/app/monitors.rs @@ -121,44 +121,51 @@ impl AppModel { cosmic::app::Task::none() } - pub(super) fn brightness_slider_changed( + pub(super) fn slider_changed( &mut self, monitor_id: u32, + feature: SliderFeature, value: u16, ) -> cosmic::app::Task { if !self.action_executor.accepts_monitor(monitor_id) { return cosmic::app::Task::none(); } - let token = self.slider_debounce.record( - self.monitor_generation, - monitor_id, - SliderFeature::Brightness, - value, - ); + let token = + self.slider_debounce + .record(self.monitor_generation, monitor_id, feature, value); cosmic::app::Task::perform( async move { tokio::time::sleep(tokio::time::Duration::from_millis(150)).await; token }, - move |token| cosmic::Action::App(Message::ApplyBrightnessDebounced(monitor_id, token)), + move |token| { + cosmic::Action::App(match feature { + SliderFeature::Brightness => { + Message::ApplyBrightnessDebounced(monitor_id, token) + } + SliderFeature::Contrast => Message::ApplyContrastDebounced(monitor_id, token), + }) + }, ) } - pub(super) fn apply_brightness_debounced( + pub(super) fn apply_slider_debounced( &mut self, monitor_id: u32, + feature: SliderFeature, token: DebounceToken, ) -> cosmic::app::Task { - if let Some(value) = self.slider_debounce.take_current( - self.monitor_generation, - monitor_id, - SliderFeature::Brightness, - token, - ) { - return self.update(Message::SetBrightness(monitor_id, value)); + let Some(value) = + self.slider_debounce + .take_current(self.monitor_generation, monitor_id, feature, token) + else { + return cosmic::app::Task::none(); + }; + match feature { + SliderFeature::Brightness => self.set_brightness(monitor_id, value), + SliderFeature::Contrast => self.set_contrast(monitor_id, value), } - cosmic::app::Task::none() } pub(super) fn set_brightness( @@ -169,46 +176,6 @@ impl AppModel { self.enqueue_hardware_jobs([super::HardwareJob::SetBrightness { monitor_id, value }]) } - pub(super) fn contrast_slider_changed( - &mut self, - monitor_id: u32, - value: u16, - ) -> cosmic::app::Task { - if !self.action_executor.accepts_monitor(monitor_id) { - return cosmic::app::Task::none(); - } - let token = self.slider_debounce.record( - self.monitor_generation, - monitor_id, - SliderFeature::Contrast, - value, - ); - - cosmic::app::Task::perform( - async move { - tokio::time::sleep(tokio::time::Duration::from_millis(150)).await; - token - }, - move |token| cosmic::Action::App(Message::ApplyContrastDebounced(monitor_id, token)), - ) - } - - pub(super) fn apply_contrast_debounced( - &mut self, - monitor_id: u32, - token: DebounceToken, - ) -> cosmic::app::Task { - if let Some(value) = self.slider_debounce.take_current( - self.monitor_generation, - monitor_id, - SliderFeature::Contrast, - token, - ) { - return self.update(Message::SetContrast(monitor_id, value)); - } - cosmic::app::Task::none() - } - pub(super) fn set_contrast( &mut self, monitor_id: u32, diff --git a/src/app/profile_handlers.rs b/src/app/profile_handlers.rs index c88f4bc..7a09c65 100644 --- a/src/app/profile_handlers.rs +++ b/src/app/profile_handlers.rs @@ -3,21 +3,36 @@ use crate::profiles; impl AppModel { pub(super) fn refresh_profiles(&mut self) -> cosmic::app::Task { + self.profiles_request = self.profiles_request.wrapping_add(1); + let request = self.profiles_request; cosmic::app::Task::perform( async { tokio::task::spawn_blocking(profiles::list_profiles).await }, - |result| match result { - Ok(list) => cosmic::Action::App(Message::ProfilesListed(list)), - Err(error) => { - cosmic::Action::App(Message::Error(format!("Task join error: {error}"))) - } + move |result| { + let listed = match result { + Ok(Ok(list)) => Ok(list), + Ok(Err(error)) => Err(format!("List profiles error: {error}")), + Err(error) => Err(format!("Task join error: {error}")), + }; + cosmic::Action::App(Message::ProfilesListed(request, listed)) }, ) } - pub(super) fn profiles_listed(&mut self, profiles: Vec) { - self.profiles = profiles; - if let Some((tray, _)) = &self.tray { - tray.update_menu(&self.profiles); + pub(super) fn profiles_listed(&mut self, request: u64, listed: Result, String>) { + if request != self.profiles_request { + return; + } + match listed { + Ok(profiles) => { + self.profiles = profiles; + if let Some((tray, _)) = &self.tray { + tray.update_menu(&self.profiles); + } + } + Err(error) => { + log::error!("{error}"); + self.status_message = error; + } } } @@ -31,25 +46,13 @@ impl AppModel { self.status_message = "Enter a profile name first.".into(); return cosmic::app::Task::none(); } - match profiles::profile_exists(&name) { - Ok(true) => { - self.status_message = - format!("Profile '{name}' already exists. Confirm replacement."); - self.pending_profile_replace = Some(name); - cosmic::app::Task::none() - } - Ok(false) => { - self.status_message = format!("Saving profile '{name}'..."); - self.enqueue_hardware_jobs([super::HardwareJob::SaveProfile { - name, - replace: false, - }]) - } - Err(error) => { - self.status_message = format!("Invalid profile name: {error}"); - cosmic::app::Task::none() - } - } + // The worker reports an existing profile, which prompts for replacement. + self.pending_profile_replace = None; + self.status_message = format!("Saving profile '{name}'..."); + self.enqueue_hardware_jobs([super::HardwareJob::SaveProfile { + name, + replace: false, + }]) } pub(super) fn confirm_replace_profile(&mut self) -> cosmic::app::Task { @@ -83,17 +86,7 @@ impl AppModel { pub(super) fn delete_profile(&mut self, name: String) -> cosmic::app::Task { self.pending_profile_delete = None; - cosmic::app::Task::perform( - async move { tokio::task::spawn_blocking(move || profiles::delete_profile(&name)).await }, - |result| match result { - Ok(Ok(())) => cosmic::Action::App(Message::RefreshProfiles), - Ok(Err(error)) => { - cosmic::Action::App(Message::Error(format!("Delete profile error: {error}"))) - } - Err(error) => { - cosmic::Action::App(Message::Error(format!("Task join error: {error}"))) - } - }, - ) + self.status_message = format!("Deleting profile '{name}'..."); + self.enqueue_hardware_jobs([super::HardwareJob::DeleteProfile { name }]) } } diff --git a/src/app/settings_views.rs b/src/app/settings_views.rs index 2826a7b..8eb6874 100644 --- a/src/app/settings_views.rs +++ b/src/app/settings_views.rs @@ -1,47 +1,18 @@ +use super::config_ops::ConfigOp; use super::{AppModel, Message}; use crate::config::TurnOffBehavior; -use crate::startup; use cosmic::Element; use cosmic::iced::Length; use cosmic::widget; impl AppModel { /// Persist startup settings and keep the per-user Run key in step. - /// - /// The registry is updated first. If saving config fails, the previous - /// registration is written back and the in-memory config is left unchanged. pub(super) fn set_windows_startup( &mut self, enabled: bool, minimized: bool, ) -> cosmic::app::Task { - let previous_enabled = self.config.start_with_windows; - let previous_minimized = self.config.start_minimized; - if let Err(error) = startup::apply(enabled, minimized) { - self.status_message = format!("Failed to update Windows startup: {error}"); - return cosmic::app::Task::none(); - } - - let mut updated = self.config.clone(); - updated.start_with_windows = enabled; - updated.start_minimized = minimized; - if let Err(error) = self.config_store.save(&updated) { - self.status_message = format!("Failed to save config: {error}"); - if let Err(revert_error) = startup::apply(previous_enabled, previous_minimized) { - log::error!("Failed to restore Windows startup registration: {revert_error}"); - } - return cosmic::app::Task::none(); - } - - self.config = updated; - self.status_message = if !enabled { - "Will not start with Windows".into() - } else if minimized { - "Will start with Windows, minimized to the tray".into() - } else { - "Will start with Windows".into() - }; - cosmic::app::Task::none() + self.enqueue_config_op(ConfigOp::SetStartup { enabled, minimized }) } /// Settings shown in the header context drawer. diff --git a/src/ccd.rs b/src/ccd.rs index b1da782..ee9790b 100644 --- a/src/ccd.rs +++ b/src/ccd.rs @@ -231,12 +231,18 @@ fn apply_config_with( pub fn turn_off_monitors() { std::thread::sleep(std::time::Duration::from_millis(500)); // SAFETY: PostMessageW with a broadcast handle and plain integer parameters. - unsafe { - let _ = PostMessageW( + let posted = unsafe { + PostMessageW( HWND_BROADCAST, WM_SYSCOMMAND, SC_MONITORPOWER as usize, 2isize, + ) + }; + if posted == 0 { + log::warn!( + "Monitor power-off broadcast failed: {}", + std::io::Error::last_os_error() ); } } diff --git a/src/config.rs b/src/config.rs index e7b6724..6b25239 100644 --- a/src/config.rs +++ b/src/config.rs @@ -646,8 +646,6 @@ pub struct AppConfig { #[serde(default)] schema_version: u32, pub hotkeys: HotkeyConfig, - /// Refresh interval in seconds for polling monitor state (0 = disabled). - pub refresh_interval_secs: u64, /// Whether global hotkeys are enabled. #[serde(default = "default_hotkeys_enabled")] pub hotkeys_enabled: bool, @@ -672,7 +670,6 @@ impl Default for AppConfig { Self { schema_version: SCHEMA_VERSION, hotkeys: HotkeyConfig::default(), - refresh_interval_secs: 0, hotkeys_enabled: true, turn_off_behavior: TurnOffBehavior::default(), start_with_windows: false, @@ -687,9 +684,10 @@ impl Default for AppConfig { impl AppConfig { /// Path to the JSON configuration file. - pub fn config_path() -> PathBuf { - let base = dirs::config_dir().unwrap_or_else(|| PathBuf::from(".")); - base.join("windisplaymanager").join("config.json") + pub fn config_path() -> io::Result { + Ok(persistence::config_base_dir()? + .join("windisplaymanager") + .join("config.json")) } pub fn load_from(path: &Path) -> LoadOutcome { @@ -714,7 +712,7 @@ impl AppConfig { /// The write gate survives failed loads, including subsequent removal of the /// bad file. Only an explicit retry, backup recovery, or confirmed reset clears it. -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct ConfigStore { path: PathBuf, recovery_error: Option, @@ -730,6 +728,15 @@ impl ConfigStore { (store, outcome) } + /// A store with no usable path stays in recovery, so nothing is written. + pub fn unavailable(error: io::Error) -> (Self, LoadOutcome) { + let store = Self { + path: PathBuf::new(), + recovery_error: Some(error.to_string()), + }; + (store, LoadOutcome::Failed(error)) + } + pub fn path(&self) -> &Path { &self.path } @@ -739,6 +746,15 @@ impl ConfigStore { } pub fn retry(&mut self) -> LoadOutcome { + if self.path.as_os_str().is_empty() { + match AppConfig::config_path() { + Ok(path) => self.path = path, + Err(error) => { + self.recovery_error = Some(error.to_string()); + return LoadOutcome::Failed(error); + } + } + } let outcome = AppConfig::load_from(&self.path); self.recovery_error = match &outcome { LoadOutcome::Failed(error) => Some(error.to_string()), @@ -781,7 +797,7 @@ impl ConfigStore { } pub fn recover_backup(&mut self) -> io::Result { - let backup = persistence::backup_path(&self.path); + let backup = persistence::backup_path(self.resolved_path()?); let config = match AppConfig::load_from(&backup) { LoadOutcome::Loaded(config) => config, LoadOutcome::Missing => { @@ -805,10 +821,20 @@ impl ConfigStore { fn replace_for_recovery(&mut self, config: &AppConfig) -> io::Result<()> { let bytes = serde_json::to_vec_pretty(config).map_err(persistence::invalid_data)?; - persistence::atomic_write(&self.path, &bytes, WriteMode::Replace)?; + persistence::atomic_write(self.resolved_path()?, &bytes, WriteMode::Replace)?; self.recovery_error = None; Ok(()) } + + fn resolved_path(&self) -> io::Result<&Path> { + if self.path.as_os_str().is_empty() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + "Configuration path is unavailable; retry first", + )); + } + Ok(&self.path) + } } // --------------------------------------------------------------------------- @@ -816,11 +842,21 @@ impl ConfigStore { // --------------------------------------------------------------------------- /// Build a mapping from global-hotkey OS id β†’ the hotkey's action chain. +/// A hand-edited config may repeat a binding; the first occurrence wins. pub fn build_hotkey_map(config: &HotkeyConfig) -> HashMap)> { let mut map = HashMap::new(); for hotkey in &config.hotkeys { if let Some(hk) = hotkey.binding.to_hotkey() { - map.insert(hk.id(), (hk, hotkey.actions.clone())); + match map.entry(hk.id()) { + std::collections::hash_map::Entry::Occupied(_) => log::warn!( + "Hotkey '{}' duplicates binding {}; ignoring it", + hotkey.id, + hotkey.binding + ), + std::collections::hash_map::Entry::Vacant(slot) => { + slot.insert((hk, hotkey.actions.clone())); + } + } } } map @@ -935,6 +971,27 @@ mod tests { use crate::persistence::{backup_path, tests::TestDir}; use std::fs; + #[test] + fn duplicate_bindings_keep_the_first_action_chain() { + let binding = HotkeyBinding { + ctrl: true, + key: "F1".into(), + ..HotkeyBinding::unbound() + }; + let mut first = Hotkey::new_for_profile("first".into()); + first.binding = binding.clone(); + let mut second = Hotkey::new_for_profile("second".into()); + second.binding = binding.clone(); + let config = HotkeyConfig { + hotkeys: vec![first, second], + ..HotkeyConfig::default() + }; + let map = build_hotkey_map(&config); + assert_eq!(map.len(), 1); + let (_, actions) = &map[&binding.to_hotkey().unwrap().id()]; + assert_eq!(actions[0].profile_name, "first"); + } + #[test] fn startup_flags_default_off_when_missing_from_saved_config() { let defaults = AppConfig::default(); diff --git a/src/hotkeys.rs b/src/hotkeys.rs index 40c4ed6..2c82985 100644 --- a/src/hotkeys.rs +++ b/src/hotkeys.rs @@ -80,13 +80,14 @@ impl HotkeyManager { self.action_map = Arc::new(action_map); // Recompute per-config-hotkey active status by re-deriving the OS id - // from each binding and checking whether it registered successfully. + // from each binding. Only the first owner of a duplicated binding is active. let mut status = HashMap::new(); + let mut claimed = HashSet::new(); for cfg_hotkey in &config.hotkeys.hotkeys { let active = cfg_hotkey .binding .to_hotkey() - .is_some_and(|hk| registered_ids.contains(&hk.id())); + .is_some_and(|hk| registered_ids.contains(&hk.id()) && claimed.insert(hk.id())); status.insert(cfg_hotkey.id.clone(), active); } self.status = status; diff --git a/src/persistence.rs b/src/persistence.rs index c5f54e9..81917f5 100644 --- a/src/persistence.rs +++ b/src/persistence.rs @@ -9,6 +9,16 @@ use std::sync::atomic::{AtomicU64, Ordering}; pub const SCHEMA_VERSION: u32 = 1; +/// Per-user roaming app data. Never falls back to the working directory. +pub fn config_base_dir() -> io::Result { + dirs::config_dir().ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "Could not resolve the user application data directory", + ) + }) +} + #[derive(Debug)] pub enum LoadOutcome { Missing, diff --git a/src/profiles.rs b/src/profiles.rs index 35a0ace..3c35c70 100644 --- a/src/profiles.rs +++ b/src/profiles.rs @@ -58,9 +58,10 @@ pub struct DisplayProfile { // --------------------------------------------------------------------------- /// Directory holding profile JSON files. -pub fn profiles_dir() -> PathBuf { - let base = dirs::config_dir().unwrap_or_else(|| PathBuf::from(".")); - base.join("MonitorSwitcher").join("Profiles") +pub fn profiles_dir() -> io::Result { + Ok(persistence::config_base_dir()? + .join("MonitorSwitcher") + .join("Profiles")) } /// Strip characters invalid in Windows filenames and reject traversal/empty @@ -81,7 +82,7 @@ fn sanitize_name(name: &str) -> Option { fn profile_path(name: &str) -> Result { let safe = sanitize_name(name).ok_or(ProfileError::InvalidName)?; - Ok(profiles_dir().join(format!("{safe}.json"))) + Ok(profiles_dir()?.join(format!("{safe}.json"))) } // --------------------------------------------------------------------------- @@ -89,29 +90,32 @@ fn profile_path(name: &str) -> Result { // --------------------------------------------------------------------------- /// List profile names (file stems of `*.json`), sorted. -pub fn list_profiles() -> Vec { +pub fn list_profiles() -> io::Result> { + list_profiles_in(&profiles_dir()?) +} + +fn list_profiles_in(dir: &Path) -> io::Result> { + let entries = match fs::read_dir(dir) { + Ok(entries) => entries, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(error), + }; let mut names = Vec::new(); - if let Ok(entries) = fs::read_dir(profiles_dir()) { - for entry in entries.flatten() { - let path = entry.path(); - if path.extension().and_then(|e| e.to_str()) == Some("json") - && let Some(stem) = path.file_stem().and_then(|s| s.to_str()) - { - names.push(stem.to_string()); - } + for entry in entries { + let path = entry?.path(); + if path.extension().and_then(|e| e.to_str()) == Some("json") + && let Some(stem) = path.file_stem().and_then(|s| s.to_str()) + { + names.push(stem.to_string()); } } names.sort_unstable(); - names + Ok(names) } /// Save a captured configuration as a named profile. -pub fn profile_exists(name: &str) -> Result { - Ok(profile_path(name)?.exists()) -} - pub fn save_profile(name: &str, config: &DisplayConfig, replace: bool) -> Result<()> { - save_profile_in(&profiles_dir(), name, config, replace) + save_profile_in(&profiles_dir()?, name, config, replace) } fn save_profile_in(dir: &Path, name: &str, config: &DisplayConfig, replace: bool) -> Result<()> { @@ -159,13 +163,16 @@ pub fn load_profile(name: &str) -> Result { } } -/// Delete a profile by name. +/// Delete a profile by name. Deleting a missing profile succeeds. pub fn delete_profile(name: &str) -> Result<()> { - let path = profile_path(name)?; - if path.exists() { - fs::remove_file(&path)?; + remove_if_present(&profile_path(name)?) +} + +fn remove_if_present(path: &Path) -> Result<()> { + match fs::remove_file(path) { + Err(error) if error.kind() != io::ErrorKind::NotFound => Err(error.into()), + _ => Ok(()), } - Ok(()) } /// Load and apply a profile by name. @@ -295,6 +302,30 @@ mod tests { assert_eq!(sanitize_name("ac:d").unwrap(), "abcd"); } + #[test] + fn listing_treats_missing_dir_as_empty_and_filters_non_json() { + let dir = TestDir::new(); + assert!(list_profiles_in(&dir.0.join("missing")).unwrap().is_empty()); + fs::write(dir.0.join("b.json"), b"{}").unwrap(); + fs::write(dir.0.join("a.json"), b"{}").unwrap(); + fs::write(dir.0.join("notes.txt"), b"").unwrap(); + assert_eq!(list_profiles_in(&dir.0).unwrap(), ["a", "b"]); + let file = dir.0.join("a.json"); + assert!(list_profiles_in(&file).is_err()); + } + + #[test] + fn removal_is_idempotent_but_propagates_other_errors() { + let dir = TestDir::new(); + let path = dir.0.join("Home.json"); + fs::write(&path, b"{}").unwrap(); + remove_if_present(&path).unwrap(); + assert!(!path.exists()); + remove_if_present(&path).unwrap(); + // Removing a directory as a file fails with an error other than NotFound. + assert!(remove_if_present(&dir.0).is_err()); + } + #[test] fn iso8601_epoch_is_correct() { assert_eq!(civil_from_days(0), (1970, 1, 1)); diff --git a/src/startup.rs b/src/startup.rs index cc5a147..758b958 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -4,6 +4,7 @@ //! `--minimized` is added only when both startup toggles are on, so a normal launch //! always opens the window. +use std::ffi::OsString; use std::io; use std::path::Path; @@ -17,13 +18,16 @@ pub fn launched_minimized() -> bool { } /// Quoted executable path, plus `--minimized` when requested. -pub fn startup_command(exe: &Path, minimized: bool) -> String { - let quoted = quote_path(exe); +/// Windows paths cannot contain `"`, so wrapping in quotes is sufficient. +pub fn startup_command(exe: &Path, minimized: bool) -> OsString { + let mut command = OsString::from("\""); + command.push(exe.as_os_str()); + command.push("\""); if minimized { - format!("{quoted} {MINIMIZED_ARG}") - } else { - quoted + command.push(" "); + command.push(MINIMIZED_ARG); } + command } /// Write or remove the Run value so it matches the saved startup settings. @@ -35,11 +39,6 @@ pub fn apply(enabled: bool, minimized: bool) -> io::Result<()> { set_run_value(&exe, enabled, minimized) } -fn quote_path(exe: &Path) -> String { - let path = exe.to_string_lossy().replace('"', "\\\""); - format!("\"{path}\"") -} - fn set_run_value(exe: &Path, enabled: bool, minimized: bool) -> io::Result<()> { let hkcu = winreg::RegKey::predef(winreg::enums::HKEY_CURRENT_USER); let (run, _) = hkcu.create_subkey(RUN_KEY)?; diff --git a/src/tray.rs b/src/tray.rs index 65a239c..6a7f6e2 100644 --- a/src/tray.rs +++ b/src/tray.rs @@ -198,29 +198,11 @@ impl Hash for TrayId { } } -/// Create the tray icon from the embedded ICO file. +/// Load the tray icon from the executable's icon resource (ID 1, embedded by build.rs). fn create_tray_icon() -> Result { - // Embed the ICO file at compile time - static ICON_DATA: &[u8] = include_bytes!("../icon.ico"); - - log::debug!( - "Creating tray icon, embedded data size: {} bytes", - ICON_DATA.len() - ); - - // Write to temp file since tray_icon can load ICO from path - let temp_path = std::env::temp_dir().join("windisplaymanager_icon.ico"); - std::fs::write(&temp_path, ICON_DATA).map_err(|e| { - log::error!("Failed to write temp icon file: {e}"); - tray_icon::BadIcon::OsError(e) - })?; - - log::debug!("Wrote temp icon to: {}", temp_path.display()); - - let result = tray_icon::Icon::from_path(&temp_path, None); - match &result { - Ok(_) => log::debug!("Tray icon loaded successfully"), - Err(e) => log::error!("Failed to load tray icon: {e:?}"), + let result = tray_icon::Icon::from_resource(1, None); + if let Err(e) = &result { + log::error!("Failed to load tray icon: {e:?}"); } result }