Skip to content

Commit 0816a7c

Browse files
synleclaude
andauthored
fix(windows): explicit DDC/HMONITOR pairing + About v-prefix (v7.0.11) (#2)
Three independent fixes bundled per the user's request. 1) About page: Latest version no longer rendered with leading "v" - The Latest row pulled the GitHub release tag verbatim ("v7.0.10") while Version came from `tauri.conf.json` without prefix ("7.0.10"). They now both render unprefixed; semver comparison still uses the raw tag, so update detection is unchanged. New regression test in `AboutPanel.test.tsx`. Engine label updated from the stale "Tauri + Rust Sidecar" to "Tauri + Rust (in-process)" — v7.0.0 dropped the sidecar; the label was lying. 2) Verified no server / sidecar is spun up - `grep -r 'SERVER_PORT|externalBin|tauri-plugin-shell|find_available_port'` across `src-tauri/` finds zero matches. Auto-update doesn't need a local server — `AboutPanel.tsx` queries `api.github.com/repos/synle/display-dj/releases/latest` directly. Also refreshed `sidecar_cache.rs` docstring (now an in-process cache, name retained from v6.x). 3) Windows external brightness: explicit DDC ↔ HMONITOR pairing - Previous code called `ddc_winapi::Monitor::enumerate()` and `enum_hmonitors()` independently, then `zip`'d the two Vecs by index. `EnumDisplayMonitors` is not documented to return the same callback order across two separate invocations — when the orders drift, `SetVCPFeature` targets the wrong physical monitor and the gamma write goes to the correct HMONITOR (i.e. opposite displays), giving a visible no-op on the panel the user is actually adjusting. - Fix: enumerate HMONITORs once, then per HMONITOR call `ddc_winapi::get_physical_monitors_from_hmonitor(hm)` and wrap each `PHYSICAL_MONITOR` via `Monitor::new`. Pairing is now explicit and per-HMONITOR — orderings can no longer drift. - Same strategy applied to `WinPlatform::debug_info()`; each DDC entry now carries `hmonitor_index` so future diagnostic dumps anchor DDC results to specific physical displays. - Added `hmonitor_to_winapi()` helper for the `windows` → `winapi` HMONITOR pointer cast. - Documented the local patch in `VENDORING.md` so a future vendor refresh re-applies (or upstreams) it. Tests - `cargo test --lib`: 236 passed - `npm test`: 116 passed (added one regression test for the v-prefix strip in `AboutPanel.test.tsx`). Version - 7.0.10 → 7.0.11 (`tauri.conf.json`, single source of truth) Co-authored-by: Claude Opus 4 (1M context) <noreply@anthropic.com>
1 parent 48ec894 commit 0816a7c

7 files changed

Lines changed: 167 additions & 68 deletions

File tree

VENDORING.md

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,16 +24,16 @@ The "Last-synced SHA" column records the upstream `synle/display-dj-cli`
2424
commit on `main` that each vendored snapshot corresponds to. Run
2525
`./scripts/check-vendor-drift.sh` to verify these are still accurate.
2626

27-
| Vendored path | Upstream path | Last-synced SHA | Notes |
28-
| --------------------------------- | ---------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
29-
| `src-tauri/src/core/mod.rs` | `src/main.rs` | `3b9306d` | Extracted shared types (`DisplayInfo`, `DisplayControl`, `Platform`, VCP consts, `matches_display`) + added `PlatformImpl` cfg-alias. |
30-
| `src-tauri/src/core/macos.rs` | `src/macos.rs` | `3b9306d` | Copied 1:1; only adjustment is `crate::``super::`. |
31-
| `src-tauri/src/core/windows.rs` | `src/windows.rs` | `3b9306d` | Copied 1:1; only adjustment is `crate::``super::`. |
32-
| `src-tauri/src/core/linux.rs` | `src/linux.rs` | `3b9306d` | Copied 1:1; only adjustment is `crate::``super::`. |
33-
| `src-tauri/src/core/theme.rs` | `src/main.rs` | `3b9306d` | Extracted `get_dark_mode` / `set_dark_mode` per-OS impls (macOS AppleScript, Windows registry, Linux gsettings/dconf). |
34-
| `src-tauri/src/core/volume.rs` | `src/main.rs` | `3b9306d` | Extracted `get_volume` / `set_volume` / `set_mute` per-OS impls + `VolumeInfo`. |
35-
| `src-tauri/src/core/wallpaper.rs` | `src/main.rs` | `3b9306d` | Extracted `set_wallpaper` / `set_wallpaper_one` per-OS impls + `SlideshowState` and `slideshow_*` helpers. |
36-
| `src-tauri/src/core/display.rs` | `src/main.rs` | `3b9306d` | Distilled high-level fan-out helpers (`list_all`, `set_all_brightness`, `set_one_brightness`, contrast variants) from CLI's `cmd_get` / `cmd_set_all` / `cmd_set_one`. |
27+
| Vendored path | Upstream path | Last-synced SHA | Notes |
28+
| --------------------------------- | ---------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
29+
| `src-tauri/src/core/mod.rs` | `src/main.rs` | `3b9306d` | Extracted shared types (`DisplayInfo`, `DisplayControl`, `Platform`, VCP consts, `matches_display`) + added `PlatformImpl` cfg-alias. |
30+
| `src-tauri/src/core/macos.rs` | `src/macos.rs` | `3b9306d` | Copied 1:1; only adjustment is `crate::``super::`. |
31+
| `src-tauri/src/core/windows.rs` | `src/windows.rs` | `3b9306d` | Copied 1:1; only adjustment is `crate::``super::`. |
32+
| `src-tauri/src/core/linux.rs` | `src/linux.rs` | `3b9306d` | Copied 1:1; only adjustment is `crate::``super::`. |
33+
| `src-tauri/src/core/theme.rs` | `src/main.rs` | `3b9306d` | Extracted `get_dark_mode` / `set_dark_mode` per-OS impls (macOS AppleScript, Windows registry, Linux gsettings/dconf). |
34+
| `src-tauri/src/core/volume.rs` | `src/main.rs` | `3b9306d` | Extracted `get_volume` / `set_volume` / `set_mute` per-OS impls + `VolumeInfo`. |
35+
| `src-tauri/src/core/wallpaper.rs` | `src/main.rs` | `3b9306d` | Extracted `set_wallpaper` / `set_wallpaper_one` per-OS impls + `SlideshowState` and `slideshow_*` helpers. |
36+
| `src-tauri/src/core/display.rs` | `src/main.rs` | `3b9306d` | Distilled high-level fan-out helpers (`list_all`, `set_all_brightness`, `set_one_brightness`, contrast variants) from CLI's `cmd_get` / `cmd_set_all` / `cmd_set_one`. |
3737
| `src-tauri/src/core/win_cmd.rs` | _(local)_ | _n/a_ | display-dj-only helper. Wraps `Command::new(...)` with the Win32 `CREATE_NO_WINDOW` (`0x08000000`) creation flag so `powershell` / `reg` spawns don't flash a console window on every brightness/volume/theme/wallpaper change. Has no upstream counterpart — the CLI is itself a console app, so it never needed it. |
3838

3939
In addition to the table above, the following vendored files carry a
@@ -46,6 +46,18 @@ binary does not pop a console window for each child spawn. When
4646
re-syncing from upstream, re-apply these substitutions or run a
4747
follow-up `s/std::process::Command::new("\(powershell\|reg\)")/hidden_command("\1")/g` pass.
4848

49+
`src-tauri/src/core/windows.rs` also carries a display-dj-local fix to
50+
the DDC enumeration path (v7.0.11): instead of calling
51+
`ddc_winapi::Monitor::enumerate()` and zipping the resulting Vec with
52+
`enum_hmonitors()` by index, the code now iterates HMONITORs once and
53+
calls `ddc_winapi::get_physical_monitors_from_hmonitor(hm)` per
54+
HMONITOR — explicit pairing, no implicit index alignment between two
55+
independent `EnumDisplayMonitors` calls. Both `WinPlatform::enumerate`
56+
and `WinPlatform::debug_info` use this strategy; `debug_info` also
57+
emits `hmonitor_index` on each DDC entry so the diagnostic dump
58+
anchors DDC results to specific physical displays. On the next vendor
59+
refresh, re-apply this change (or push it upstream).
60+
4961
The recorded SHAs above reflect the latest upstream commit at which
5062
the vendored snapshots are byte-identical (modulo the
5163
`crate::``super::` namespace tweak on the platform files). The

src-tauri/src/config.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -800,7 +800,8 @@ pub fn get_about_info() -> std::collections::HashMap<String, String> {
800800
};
801801
let mut info = std::collections::HashMap::new();
802802
info.insert("version".into(), env!("APP_VERSION").to_string());
803-
info.insert("engine".into(), "Tauri + Rust Sidecar".to_string());
803+
// v7.0.0+ has no sidecar — platform code is vendored in-process under src/core/.
804+
info.insert("engine".into(), "Tauri + Rust (in-process)".to_string());
804805
info.insert("arch".into(), arch.to_string());
805806
info.insert("os".into(), os.to_string());
806807
info.insert("buildDate".into(), env!("BUILD_DATE").to_string());

src-tauri/src/core/windows.rs

Lines changed: 103 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,15 @@ use windows::Win32::Foundation::*;
1010
use windows::Win32::Graphics::Gdi::*;
1111
use winapi::um::wingdi::SetDeviceGammaRamp;
1212

13+
/// Cast a `windows::Win32::Graphics::Gdi::HMONITOR` (raw pointer wrapped in a
14+
/// tuple struct) to the `winapi::shared::windef::HMONITOR` type expected by
15+
/// `ddc-winapi` v0.2. Both are opaque-pointer aliases over the same Win32
16+
/// handle — only the surrounding Rust wrapper type differs. Pulled out as a
17+
/// helper so the unsafe-looking pointer cast is named and documented.
18+
fn hmonitor_to_winapi(hm: HMONITOR) -> winapi::shared::windef::HMONITOR {
19+
hm.0 as winapi::shared::windef::HMONITOR
20+
}
21+
1322
// =========================================================================
1423
// Built-in display — WMI (Windows Management Instrumentation) via PowerShell.
1524
// Laptops expose brightness through the WmiMonitorBrightness WMI class.
@@ -300,38 +309,70 @@ impl Platform for WinPlatform {
300309

301310
// --- External displays ---
302311
// We need both DDC handles (for brightness) and HMONITOR handles (for gamma).
303-
// These come from different APIs so we zip them together by index:
304-
// - ddc_winapi::Monitor::enumerate() → DDC physical monitor handles (Dxva2)
305-
// - enum_hmonitors() → GDI HMONITOR handles (for gamma ramp)
306-
// Both use EnumDisplayMonitors internally, so indices align.
312+
// Previously, we called `ddc_winapi::Monitor::enumerate()` and then `zip`'d
313+
// the resulting Vec with `enum_hmonitors()` by index — both internally call
314+
// `EnumDisplayMonitors`, and the assumption was "callback order is the same".
315+
// That assumption was fragile: `EnumDisplayMonitors` is not documented to
316+
// return a deterministic order, and a single mismatched index assigns the
317+
// wrong DDC handle to a monitor (e.g. SetVCPFeature targets the laptop panel
318+
// when the user pulls the slider on the external one, while the GDI gamma
319+
// call writes to the correct external HMONITOR — yielding a visible no-op
320+
// because the panel that DDC actually accepted the write for is a different
321+
// physical screen entirely).
322+
//
323+
// Fix: enumerate HMONITORs once via `enum_hmonitors()`, then for each
324+
// HMONITOR call `ddc_winapi::get_physical_monitors_from_hmonitor(hm)` to
325+
// get the physical monitor(s) attached to that specific HMONITOR. Wrap
326+
// each `PHYSICAL_MONITOR` in `Monitor::new(pm)`. The (DDC handle, HMONITOR)
327+
// pairing is now explicit and per-HMONITOR — order can't drift.
307328
//
308-
// DEDUP: On laptops, the built-in panel appears in both WMI and DDC enumeration.
309-
// We track has_builtin to skip the primary HMONITOR from DDC when WMI already
310-
// covered it. See "Windows display dedup" in CLAUDE.md for full explanation.
329+
// DEDUP: On laptops, the built-in panel appears in both WMI and DDC
330+
// enumeration. We track has_builtin to skip the primary HMONITOR from DDC
331+
// when WMI already covered it. See "Windows display dedup" in CLAUDE.md.
311332
let has_builtin = !result.is_empty();
312333
let hmonitors = enum_hmonitors();
313-
// Pre-compute details for each HMONITOR (PnP device ID + primary flag)
314334
let hmonitor_details: Vec<(String, bool)> = hmonitors.iter()
315335
.map(|&hm| get_hmonitor_details(hm))
316336
.collect();
317337

318-
if let Ok(monitors) = ddc_winapi::Monitor::enumerate() {
319-
let mut ext_id = 1usize;
320-
for (idx, mut mon) in monitors.into_iter().enumerate() {
321-
let hmonitor = hmonitors.get(idx).copied().unwrap_or(HMONITOR::default());
322-
let (device_id, is_primary) = hmonitor_details.get(idx)
323-
.cloned()
324-
.unwrap_or((String::new(), false));
325-
326-
// DEDUP: Skip the primary (built-in) monitor if we already added it via WMI.
327-
// On laptops, the built-in panel appears in both WMI and DDC enumeration.
328-
// Without this check, you'd get a duplicate: the WMI "Built-in Display"
329-
// plus a DDC "Generic PnP Monitor" with null brightness (laptop panels
330-
// don't respond to DDC commands). The primary HMONITOR flag reliably
331-
// identifies the built-in panel across all Windows laptop configurations.
332-
if has_builtin && is_primary {
338+
let mut ext_id = 1usize;
339+
for (idx, &hmonitor) in hmonitors.iter().enumerate() {
340+
let (device_id, is_primary) = hmonitor_details.get(idx)
341+
.cloned()
342+
.unwrap_or((String::new(), false));
343+
344+
// DEDUP: Skip the primary (built-in) monitor if we already added it via WMI.
345+
// On laptops, the built-in panel appears in both WMI and DDC enumeration.
346+
// Without this check, you'd get a duplicate: the WMI "Built-in Display"
347+
// plus a DDC "Generic PnP Monitor" with null brightness (laptop panels
348+
// don't respond to DDC commands). The primary HMONITOR flag reliably
349+
// identifies the built-in panel across all Windows laptop configurations.
350+
if has_builtin && is_primary {
351+
continue;
352+
}
353+
354+
// Resolve physical monitors for *this* HMONITOR — explicit pairing,
355+
// no implicit index alignment with a separate enumeration call.
356+
let physical_monitors = match ddc_winapi::get_physical_monitors_from_hmonitor(
357+
hmonitor_to_winapi(hmonitor),
358+
) {
359+
Ok(v) => v,
360+
Err(e) => {
361+
log::warn!(
362+
"GetPhysicalMonitorsFromHMONITOR failed for hmonitor[{}] device_id={:?}: {}",
363+
idx, device_id, e,
364+
);
333365
continue;
334366
}
367+
};
368+
369+
for pm in physical_monitors {
370+
// SAFETY: `pm` came from GetPhysicalMonitorsFromHMONITOR; ddc-winapi
371+
// takes ownership of the handle and calls DestroyPhysicalMonitor on
372+
// drop. The constructor is unsafe only because the crate cannot
373+
// verify the handle is valid; we obtained it from the OS API one
374+
// statement ago.
375+
let mut mon = unsafe { ddc_winapi::Monitor::new(pm) };
335376

336377
let brightness = mon.get_vcp_feature(VCP_BRIGHTNESS).ok().map(|val| {
337378
let max = val.maximum() as f64;
@@ -395,31 +436,49 @@ impl Platform for WinPlatform {
395436
}
396437

397438
// --- DDC monitors with raw VCP data ---
439+
// Enumerate per-HMONITOR (same pairing strategy as `enumerate()`) so the
440+
// `hmonitor_index` field anchors each DDC entry to a specific physical
441+
// display in the `hmonitors` array above. Previous code called
442+
// `Monitor::enumerate()` and reported a flat index, which made it
443+
// impossible to tell which HMONITOR a DDC entry belonged to when
444+
// ordering between the two calls drifted.
398445
let mut ddc_list = Vec::new();
399-
let ddc_error: Option<String>;
400-
match ddc_winapi::Monitor::enumerate() {
401-
Ok(monitors) => {
402-
ddc_error = None;
403-
for (idx, mut mon) in monitors.into_iter().enumerate() {
404-
let desc = mon.description();
405-
let brightness_raw = mon.get_vcp_feature(VCP_BRIGHTNESS).ok().map(|v| {
406-
serde_json::json!({"current": v.value(), "max": v.maximum()})
407-
});
408-
let contrast_raw = mon.get_vcp_feature(VCP_CONTRAST).ok().map(|v| {
409-
serde_json::json!({"current": v.value(), "max": v.maximum()})
410-
});
411-
ddc_list.push(serde_json::json!({
412-
"index": idx,
413-
"description": desc,
414-
"vcp_brightness": brightness_raw,
415-
"vcp_contrast": contrast_raw,
416-
}));
446+
let mut ddc_errors: Vec<String> = Vec::new();
447+
let mut ddc_seq = 0usize;
448+
for (hm_idx, &hmonitor) in hmonitors.iter().enumerate() {
449+
match ddc_winapi::get_physical_monitors_from_hmonitor(hmonitor_to_winapi(hmonitor)) {
450+
Ok(physicals) => {
451+
for pm in physicals {
452+
// SAFETY: handle obtained from the OS one line above; ddc-winapi
453+
// takes ownership and calls DestroyPhysicalMonitor on drop.
454+
let mut mon = unsafe { ddc_winapi::Monitor::new(pm) };
455+
let desc = mon.description();
456+
let brightness_raw = mon.get_vcp_feature(VCP_BRIGHTNESS).ok().map(|v| {
457+
serde_json::json!({"current": v.value(), "max": v.maximum()})
458+
});
459+
let contrast_raw = mon.get_vcp_feature(VCP_CONTRAST).ok().map(|v| {
460+
serde_json::json!({"current": v.value(), "max": v.maximum()})
461+
});
462+
ddc_list.push(serde_json::json!({
463+
"index": ddc_seq,
464+
"hmonitor_index": hm_idx,
465+
"description": desc,
466+
"vcp_brightness": brightness_raw,
467+
"vcp_contrast": contrast_raw,
468+
}));
469+
ddc_seq += 1;
470+
}
471+
}
472+
Err(e) => {
473+
ddc_errors.push(format!("hmonitor[{}]: {}", hm_idx, e));
417474
}
418-
}
419-
Err(e) => {
420-
ddc_error = Some(format!("{}", e));
421475
}
422476
}
477+
let ddc_error: Option<String> = if ddc_errors.is_empty() {
478+
None
479+
} else {
480+
Some(ddc_errors.join("; "))
481+
};
423482

424483
serde_json::json!({
425484
"wmi_brightness": wmi_brightness,

0 commit comments

Comments
 (0)