Two halves of the same identity.
`SetCurrentProcessExplicitAppUserModelID` runs before `QGuiApplication::new()`,
which is the documented window: Windows caches the identity with the first
window and a later call fails. Without it the taskbar treats each launch as an
anonymous window and does not group it with the Start-menu shortcut. The
HRESULT is logged on failure -- when this breaks, taskbar grouping and toast
delivery both break silently, and the only symptom would otherwise be
"notifications do not work" with nothing to point at.
The notification arm follows the macOS one, with `app_id` matching the AUMID.
notify-rust's Windows backend forwards summary/body/app_id/image and SILENTLY
DROPS `actions`, so no buttons are attempted: they would look implemented and
do nothing.
The artwork cache is shared rather than duplicated -- its four `cfg` gates now
include Windows, which also retires an unused-import warning there. Its
`file://` resolver gained the two Windows shapes: the empty authority's slash
in front of a drive letter (`/C:/x`, which PathBuf keeps and then never
resolves), and a UNC authority (`file://nas/share/x`), which was being treated
as a path relative to the working directory.
`system_notifications_supported` deliberately stays false on Windows. An
unpackaged desktop app only receives toasts once Windows can RESOLVE its
AppUserModelID, which needs a Start-menu shortcut carrying
System.AppUserModel.ID -- written by the MSI, which does not exist yet. Setting
the process identity is necessary and not sufficient. Advertising the row now
would let the setting be enabled in exactly the builds where nothing can ever
appear. It flips with the MSI.
cc
Windows media keys never worked because SMTC never started. souvlaki's Windows
backend does `config.hwnd.expect(..)`, which PANICS rather than degrading, and
`MediaControls::new` runs inside a tokio task, so a missing handle aborted the
task instead of returning the Err the caller was written for. Phase A bowed out
before reaching it and logged "SMTC disabled: no window handle wired yet".
`spawn` now takes a `NativeWindow`, and `cxx/win_shell.cpp` supplies the
top-level HWND through `QGuiApplication::topLevelWindows()`. The guard stays:
a missing integration is a missing feature, a panic in a tokio task is a dead
session.
Windows reads the handle from a bounded retry on the GUI thread. Both things it
waits for -- the tray bridge's Qt thread and a shown window -- are "not yet",
never "not ever": `tray_bridge::ui` is a no-op until `QbzTray::boot` registers
the thread, and `QbzSession.boot()` runs first (Main.qml:254 vs :380).
NON-WINDOWS IS UNCHANGED, deliberately. An earlier draft routed every platform
through that hop and it was a Linux/macOS regression three ways: initialization
could be dropped outright before boot, MPRIS would start only when the Qt queue
drained (losing the first metadata push), and `main_window_hwnd` calls
`QWindow::winId()`, which CREATES the platform window as a side effect on
platforms with no reason to.
Cover art needed its own fix. souvlaki 0.8.3 branches on
`url.starts_with("file://")`, then trims exactly that prefix and hands the rest
to `GetFileFromPathAsync`. So `file:///C:/x.jpg` arrives as `/C:/x.jpg` -- a
slash before a drive letter, which never opens -- while a bare `C:\x.jpg` no
longer matches the prefix and takes the remote-URI branch instead. The value
that works is the hybrid `file://C:\x.jpg`. UNC is handled too: `file://nas/m/a`
is a real Windows path and was being passed through as the relative `nas/m/a`.
The percent-decode is full and applied once: `art_url` arrives from
`url::Url::from_file_path` (space as %20) and from `fs_url::file_url` (only
% # ? escaped), and one full decode reads both -- `%2520` gives back a literal
`%20` rather than a space. `fs_url::local_path` is deliberately not used here;
its three-escape decode would leave the other producer's %20 intact.
Eight tests cover the matrix. Reviewed adversarially before commit, which is
where the inverted cover conversion and the non-Windows regression were caught;
the souvlaki branch was then read at the source to confirm.
cc
A spawn of an image that does not exist fails before a process is created, so
pactl, pw-dump and systemctl were harmless on Windows. sh is not: it exists
wherever Git for Windows is installed, so the five sh -c probes in
audio_stack_health really ran and flashed five console windows past a
GUI-subsystem binary that owns no console.
Scoped to windows / not(windows), NOT to linux / not(linux). macOS and the
BSDs can have a real pactl or pw-dump from Homebrew or ports, and answering
false for them would take away an answer they can give.
qbz-theme needed nothing: its dispatch is by XDG_CURRENT_DESKTOP, so Windows
resolves DesktopEnvironment::Unknown, whose arm returns Err without calling
any desktop probe.
cc
Three settings and menu entries offered Windows something no code behind them
could do.
DSD playback was opened to WASAPI in fc0d35e29, written before the phase B
spike. The spike then measured this DAC refusing 176400, the DSD64 DoP
carrier, so DoP over exclusive mode lost its target; and both non-convert
modes are cfg(target_os = "linux") in the audio thread and answer "DoP
playback is Linux-only" with a stream error. The row could only offer two ways
to fail. Back to ALSA-only. DSD still plays on Windows, converted to PCM.
Exclusive mode became visible the moment phase B published backendIsWasapi,
but the selection arm keys on backend_type == WasapiExclusive and never reads
exclusive_mode. A live toggle that changes nothing, directly under the
BIT-PERFECT header and directly above the thing being measured.
Open audio CD is dropped on Windows: the CDDA backend is mod unsupported there
and answers NoDrive to everything. Open SACD image stays on every platform --
it opens a file, not a drive, and hiding it would have been a bug.
cc
AudioBackendType::WasapiExclusive, offered on Windows, plus the selection arm
that opens the exclusive stream before the shared one - the same shape ALSA
Direct has on Linux.
No separate enumeration was needed: 249adc77b already made AudioDevice.id the
WASAPI ENDPOINT ID, which is exactly what WasapiDirectStream::new wants. That
fix turns out to have been the load-bearing one; by friendly name this could
not have addressed a device at all.
Two deliberate fallbacks, both of which prefer a resampled track to silence:
- No device chosen: there is nothing to open exclusively, so it says so and
uses the shared stream. Grabbing the default DAC would be deciding for the
user.
- The endpoint refuses the rate: warn and fall through. A DAC that will not
take 192/24 in exclusive still plays it shared.
The mode LED lights EXCL only when a device IS chosen, because with none the
player falls through to shared - a lit EXCL there would be the LED lying,
which is the one thing output_labels must never do.
backendIsWasapi joins the settings document, which is what unhides the
Exclusive-mode and DSD rows that fc0d35e29 gated on it.
229 + 71 + 110 tests pass; five QML audits pass.
cc
Window.topPadding..rightPadding and the SafeArea attached type are Qt
6.9; on the Linux pin (6.8 LTS) declaring them on the ApplicationWindow
refuses the whole component at Main.qml:173 ("Cannot assign to
non-existent property"), measured on the CI VM 2026-08-25 and on a Pi 4
with Debian 13's Qt 6.8.2. The paddings are now set imperatively from a
Loader active only on macOS, and HeaderBar reads SafeArea.margins.left
through a macOS-only Loader into a plain property, so no binding names
the type on other platforms. Behaviour on macOS is unchanged.
cc
The parenthetical added in 1a01abde6 says `$null` removes the variable. It
does not, for the call the comment is about: PowerShell coerces `$null` to
String.Empty when binding a `string` parameter, so
`[Environment]::SetEnvironmentVariable('X', $null, 'Process')` leaves the name
PRESENT and empty, and a child process inherits it that way. That is the call
that produced the original observation.
`$env:X = $null` IS the one that removes it, so both spellings belong in the
comment rather than one standing for the other.
Measured on Windows 11, PowerShell 7.6.5, with a child that enumerates its own
environment rather than using cmd's `if defined` - which collapses "empty" and
"absent" and is what made an earlier reading of this look the other way:
[Environment]::SetEnvironmentVariable('X', $null, 'Process')
python -c "import os; print('X' in os.environ, repr(os.environ.get('X')))"
-> True ''
never set at all
-> False
The POSIX half of the comment is unchanged and was already correct: `VAR= cmd`
delivers `Some("")`, verified the same way.
Comment only; no behaviour change.
cc
The mode LED is derived from SETTINGS (output_labels.rs reads
audio.exclusive_mode and the ALSA plugin), so it reports what was ASKED FOR.
Nothing reported what the opened stream actually negotiated, which means
"exclusive worked" and "we asked for exclusive" were indistinguishable - and
that distinction is what phase B's acceptance turns on.
PlaybackEvent already carries bit_perfect_mode, so this is a publish, not new
plumbing: the poll tick maps it to direct | plugin | disabled | unknown and
set_effective_stream dedupes on it alongside the delivered params, so a
steady stream still costs no Qt-thread hop. The quality tooltip appends
"stream: <mode>" on both of its paths; unknown and empty print nothing.
Four technical tokens, so no new translatable string.
cc
Qt's default rasteriser on Windows renders text thin and jagged. The owner's
words for the lyrics panel were "como fuente de bloc de notas con extra
alias", and the supplied screenshot shows exactly that: broken strokes and
visible stair-stepping on every line.
It is the RASTERISER, not the font and not the lyrics view. Verified on the
box by launching the same build with
QT_QPA_PLATFORM=windows:fontengine=freetype and comparing the same panel:
identical font, identical sizes, clean glyphs. So the fix belongs at the
application level, where it also cleans up the rest of the UI.
Applied only when the variable carries no real value. `offscreen` is what
the release smoke gate passes here and overwriting it would make the gate
test a platform nobody ships. Empty counts as absent for the same reason the
renderer preference now does: a cleared variable is present-with-no-value,
which var_os().is_some() reports as set.
cc
apply_renderer_preference gated on var_os("QSG_RHI_BACKEND").is_some(), which
answers YES for a variable that is present with no value. Clearing a variable
rather than unsetting it is the common way to end up there - `QSG_RHI_BACKEND=
./qbz` in a shell, and on Windows PowerShell's SetEnvironmentVariable(name,
$null), which leaves the name present and empty. The whole preference then
stopped applying, the user's persisted Settings choice included, and the only
clue was one info line claiming an explicit backend was present.
Found on the Windows box, where a test harness cleared the variable that way
and the renderer arm silently never ran. It reproduces on Linux the same way;
this is not a platform fix.
QBZ_RENDERER, read a few lines below, has always filtered the empty string.
This applies the same rule to the two Qt variables. The predicate is split
from the lookup so the three new tests exercise it without mutating
process-global env, which would race the other tests in the binary.
cc
A regression from 8baf68db8, reported from the running app: the album and
artist header gradients stopped appearing on Windows.
Six sites undid a file:// URL by hand with strip_prefix/trim_start_matches.
Before the URL builder was fixed they worked BY ACCIDENT: file_url produced
the broken "file://C:\Users\x", and stripping seven characters off that left
a valid Windows path. With the URL correct at "file:///C:/Users/x" the same
strip yields "/C:/Users/x", which opens nothing - so the fix to the QML-
facing half broke the Rust-facing half everywhere the inverse was open-coded.
atmosphere_qt.rs is the one that showed: it decodes the cover to build the
128x128 field the header gradient IS, so a failed open means no gradient at
all, with nothing logged. album_qt.rs feeds the same pipeline plus the flat
tint, ambient_qt.rs extracts the colour triad, artwork_qt.rs uses the string
as BOTH a cache key and a path, myqbz_detail_qt.rs hands a bare path to the
artwork channel, and qbz-source returns an ArtRef::File.
local_path also undoes the three percent-escapes these sites never did, so a
cover under a path holding # ? or % now opens on every platform - previously
it failed on all of them. The two sites that hash the string will miss their
cache once and regenerate.
Three strips deliberately stay: notify.rs is cfg(linux, macos); the
playlist-import one takes a filename STEM, where a leading slash cannot
matter; and qbz-theme parses a COSMIC desktop config Windows never reads.
The plan's step-8 grep could not have caught these - it searches for
starts_with('/') and format!("file://, not for the inverse.
cc
Adds isWindows, traySupported, systemNotificationsSupported and
rendererSelectable to the settings document, so Settings QML keeps reading
ONE document the way it already does for isMacos. The two any(...) lists
flip to include Windows when plan A-2 T3/T5 land; they point at that line.
Then the Audio tab, measured on the box with the tab open:
HiFi Wizard was live on Windows. qbz-dac-wizard-core generates a
PipeWire/WirePlumber config and knows no other stack, so the wizard cannot
finish anywhere but Linux. Hidden, not disabled - a disabled row invites a
bug report. This is not the reference's backend-is-pipewire gate returning:
inside Linux every backend still sees it, per the 2026-08-11 ruling in that
file's own comment.
Reserve DAC is D-Bus org.freedesktop.ReserveDevice1, Linux only. On Windows
exclusive mode IS the reservation; on macOS it is Hog Mode.
Exclusive mode was visible with no backend able to honour it. It now follows
the backend: Linux unchanged (always visible, enabled only on ALSA), hidden
on macOS, and on Windows it appears when Phase B publishes backendIsWasapi.
QML reads an absent doc property as undefined and undefined === true is
false, so this is correct today and correct again the day the field lands.
The DSD row follows the same pair.
Main.qml no longer boots the wizard off Linux, where its row is hidden and
the audio-stack probe would reach nothing.
Linux sees no change: every gate is isLinux-true or an added || on a field
that is undefined there. Five QML audits pass.
cc
winresource, not winres: winres has been unmaintained since 2021 and this
is its maintained fork. Gated to cfg(windows) as a target build-dependency,
so no other platform resolves it at all.
The manifest declares longPathAware and NOTHING else. Qt 6 sets
PerMonitorV2 itself at startup and a manifest DPI declaration is immutable,
so the two together either log a permanent COM error 0x5 or degrade
scaling - Qt owns DPI awareness.
The .ico carries 256/128/64/48/32. The four small entries are BMP and only
256 is PNG: measured on this box, System.Drawing.Icon (GDI+) throws
ArgumentOutOfRangeException in ToBitmap() on a PNG-payload entry, while WIC
decodes it. 256 has to be PNG because the directory encodes its size as 0.
Pixels verified against the source PNG at four points including an
asymmetric one, which is what proves the BGRA order and the bottom-up flip.
cc
Three copies read HOSTNAME and then /etc/hostname. Windows has neither, so
every Windows install fell through to the same last-resort word and they
collided on Qobuz Connect. One implementation in qbz-app; HOSTNAME keeps
precedence so Linux behaviour does not move.
qbzd/src/qconnect/transport.rs was a fourth copy the plan did not list, and
the settings_qt.rs one it did list no longer exists on this branch. Both
callers keep their own last-resort word - "Desktop" here, and qbzd's own -
because they are user-visible and mean different things.
qbz-credentials/src/lib.rs:273 also reads HOSTNAME and is deliberately NOT
touched: it is a salt in the KDF fallback chain, not a device label, so
adding COMPUTERNAME there would change the derived key on Windows and make
every already-encrypted credential file undecryptable. That is W17.
cc
Windows was neither is_macos nor is_linux, so QML could not name it and
every two-way platform ternary handed it the Linux arm by default.
Compile-time constant like the other two.
This is infrastructure, not a fix: the audit below found NO site that
currently resolves wrong on Windows. It is the property the settings
per-platform work and the A-2 chrome slice branch on.
Every two-way platform branch, and what Windows gets today:
Main.qml:145 chrome flags -> Qt.Window. default_use_system_title_bar
is !cfg!(macos), so systemTitleBar is
true on Windows and the ternary never
reaches the macOS/Linux arms. With the
title bar off it takes FramelessWindowHint,
which is also the right arm on Windows -
ExpandedClientAreaHint paints nothing there
Main.qml:241 macOS diagnostic -> skipped
Main.qml:435 macChromeTimer -> not running
Main.qml:640 traffic-light inset -> skipped
ImmersiveHeader.qml:42 -> no macOS inset
HeaderBar.qml:89 drawn cluster -> gated on showWindowControls, which the
native title bar leaves off; only the
native buttons are drawn (confirmed on screen)
HeaderBar.qml:104 -> no macOS inset
AppearanceSettings.qml:493 -> hidden (useSystemTitleBar is true)
AppearanceSettings.qml:510,521 -> visible; both are meaningful on Windows
and 521 is the route to custom chrome
AppearanceSettings.qml:583,603 -> hidden
AppearanceSettings.qml:630-646 -> renderer group hidden
hotkeys_qt.rs:423,961 -> Ctrl glyphs
main.rs:3099-3102 -> has its own Windows arm
renderer_qt.rs:420 -> gpuSelectable false, GPU row hidden
settings_qt.rs:469 -> system title bar on by default
settings_qt.rs:2258 -> is_macos false
cc
Seven sites built format!("file://{p}") by hand or tested starts_with('/')
for "absolute path". Both are Unix-only: file://C:/... parses the drive
letter as the URL HOST, and C:\... does not start with '/'. On Windows
that silently cost every album cover, every runtime-tinted icon and the
local folder tree - a URL that resolves to nothing renders as an empty
rectangle, not as an error, so nothing complained.
qbz_models::fs_url now owns file_url / local_path / is_local_abs_path,
deciding by path SHAPE (POSIX, drive, UNC) rather than by host, so the
tests run and fail everywhere rather than only on Windows.
Linux behaviour is unchanged, deliberately: the backslash-to-slash rewrite
fires only for a Windows-shaped input, because a backslash is a legal
filename character on Unix and rewriting it would point /home/v/AC\DC at
a directory that does not exist. There is a test for exactly that.
Three of the seven sites were not in the plan's list and were found by its
step 8 grep: local_ephemeral.rs, local_playlist_qt.rs, playback_qt.rs.
Two starts_with('/') hits stay and are not local-path tests:
qbz-library/src/mount_info.rs is cfg(linux) mount-point arithmetic, and
qbz-theme's is parsing a COSMIC desktop config that Windows never reads.
cc
windows_subsystem was declared nowhere, so the app carried a console
window on Windows. The file logger is unaffected; only the inherited
stdio console goes.
The offscreen smoke gate has not run against this yet. If smoke.log comes
back empty the fix is AttachConsole(ATTACH_PARENT_PROCESS) behind
cfg(windows), not reverting this; there is a note at the attribute saying
so.
cc
The gl/gles/femtovg pref forced QSG_RHI_BACKEND=opengl on every non-macOS
host, dragging Windows off its healthy d3d11 default onto desktop GL. That
is a downgrade, not the GPU tier the pref means, so Windows takes the same
remap macOS already had.
And with no explicit style Qt loads Windows on Windows and Fusion on
Linux, never Basic, so the popups without an explicit background inherit
native chrome. Pinned before QGuiApplication, since it must run before the
first QtQuick.Controls import, and windeployqt then ships one style.
cc
souvlaki's Windows backend does config.hwnd.expect() at
platform/windows/mod.rs:57, so the None we passed was a panic inside a
tokio task on the first session, not the Err the match was written for.
The comment on that field claimed init would merely fail; it was read from
the comment, not the mechanism. No HWND is wired yet, so spawn() bows out
on Windows and the body moves to spawn_native, which is compiled out
there. Phase A ships without media keys and says so.
The offline-folder opener spawned xdg-open on every non-macOS host,
Windows included, where it does not exist. Route it through the open crate
like the other eleven sites in this crate; Linux and macOS reach the same
two binaries as before.
cc
Neither cc-rs nor cxx-qt-build passes them, and without both nothing that
includes a Qt header compiles on Windows.
/Zc:__cplusplus: MSVC reports __cplusplus as 199711L whatever /std: says,
and qcompilerdetection.h:1317 rejects that value outright on every
translation unit, ours and moc's alike.
/permissive-: measured on Qt 6.9.3, the headers do not parse in MSVC's
default mode - qtmochelpers.h:262 C2065 'result', qcomparehelpers.h:1348
C2968 recursive alias, and C2737 on every moc staticMetaObject.
Applied to both cc builders: this crate's own, which compiles the nine RHI
items, and cxx-qt's. Patching only the latter changed nothing, because the
failure came from the former. Gated on the target env, so the Linux and
macOS builds cannot see either flag.
cc
qt-build-utils writes the collected path verbatim as the qrc alias, and
read_dir joins with a backslash on Windows, so none of the 1469 aliases
resolved: 1106 icons, 9 fonts and 21 shaders would each fail with
"Cannot open". Normalise only when the build host is Windows, since a
backslash is a legal filename character on Unix.
Also probe qsb.exe and QT_ROOT_DIR/bin, because Path::is_file never
appends .exe, and fix the SpectrumBand hand-bake comment, whose command
lacked --hlsl 50 and the GLES variants that build.rs actually passes.
cc
Port the Tauri immediate and accumulated offline policies into the active Qt fire path, retain independent scrobbler sessions across Qobuz logout behind a default-on opt-out, and flush queued Last.fm/ListenBrainz plays only when the effective policy permits it.
cx