Linux holds a logind inhibitor fd; Windows has SetThreadExecutionState, and
nothing was calling it.
The plan asked for the call to sit in `set_playback`. It cannot: that API is
PER-THREAD -- Windows drops a thread's requests when it exits -- and this
module's own contract says outbound calls are "free from any thread"
(media_controls_qt.rs:34). In practice they arrive on the mpris-server thread,
a tokio worker, or the GUI thread, whichever pushed. A request made from a pool
worker would evaporate with it, silently. So the state is owned by one thread
that lives as long as the process and receives a bool.
ES_SYSTEM_REQUIRED without ES_DISPLAY_REQUIRED: an audio player keeps the
MACHINE awake, not the screen. No ES_AWAYMODE_REQUIRED either -- that is for
media-centre appliances and it suppresses the sleep the user asked for.
Exposed as a free function rather than a `PlatformHandle` method, and called
BEFORE the `handle()` early return on both publishers. Hanging it off the
handle tied it to SMTC: a failed souvlaki init, or an HWND that never arrived,
would have left playback working and the machine falling asleep mid-track --
two unrelated features failing together for no reason.
`held` is updated only after the API succeeds, so a failed release cannot leave
Windows inhibited while later `false` messages are skipped as no-ops.
cc
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
When the Direct arm moved from a concrete AlsaDirectStream to
Arc<dyn DirectSink>, the set_hardware_volume call kept its
cfg(target_os = "linux") gate, so on every other platform the setting
silently did nothing.
Added as a second arm rather than by removing the Linux one, per the
protected-code rule. Linux keeps its verbatim block and its behaviour;
a Windows sink can now answer for itself, and one with no hardware volume
returns Err and says so instead of the call never happening. macOS and the
remaining targets keep their previous silence, since CoreAudio has its own
path through set_coreaudio_hardware_volume.
Latent rather than live: the row is visible only on ALSA.
cc
machine_id_stable_source had no Windows arm. It read /etc/machine-id, then
HOSTNAME, then USER -- two UNIX names Windows itself never sets but an
MSYS environment may.
Measured on the box: Git-Bash sets HOSTNAME as a shell variable it does not
export, so a child process reads None, and leaves USER empty. The chain
returned None and the persisted random fallback kept the key stable. Stable by
accident: any environment that exports either name would silently derive a
different key, and a key that moves is a credentials file that no longer
decrypts.
COMPUTERNAME is set by Windows for every process, however the app was
launched, so the value is now stable by construction. Approved with the
one-time re-authentication that moving the key costs; verified by smoke that
no re-authentication was in fact needed, because the master key lives in the
Credential Manager and this derivation is the fallback path.
cc
install_id spawns reg query for MachineGuid. The shipped image is a
GUI-subsystem binary and owns no console, so Windows allocated one and a black
box flashed at every startup. CREATE_NO_WINDOW suppresses it, through
std::os::windows::process::CommandExt, with no new dependency.
The command, its arguments, its stdout and its parse are untouched. That
matters: this value is a KDF input, and .output() captures the same bytes --
only the console attachment changes.
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
detect_profile fell back to MemoryProfile::from_total_kb(u64::MAX) -- the top
tier -- on any machine without /proc/meminfo, and read_memory_pressure
returned None forever. The prefetch and buffer tiers were therefore pinned at
the top on Windows and the memory watchdog was blind: it continues on None, so
evict_l1_audio_cache and the prefetch halt could never fire at any pressure.
Both halves had to land together. With the old u64::MAX total, a working
pressure probe would have computed roughly 0 per cent available and reported
critical permanently.
ullAvailPhys is the analogue of MemAvailable: what an allocation can take
without going to disk. The Normal-fallback contract is preserved, so a probe
that fails still never throttles the machine. windows-sys 0.61 is already in
the workspace lock via qbz-local-catalog and qbzd, so nothing new is pulled.
Measured on a 32 GB box: 57.9 per cent available, class Normal, which is the
same class the old fallback produced -- the change is the true total and a
pressure figure that exists at all.
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
WASAPI Exclusive played nothing at all. The stream opened correctly every
time (Initialized EXCLUSIVE @ 44100 Hz, S24In32, DirectHardware); the render
thread was simply gone by the time audio arrived:
Stop requested
Writer thread started (gapless-capable)
stream closed with 2 underrun(s)
ERROR Write failed: render thread is gone
The player calls stop() on EVERY track change and then reuses the same stream
object for the next track. ALSA states that contract in its own comment --
"Stop PCM immediately (prepare for next playback)": DROP to halt and discard,
then prepare, and the object survives. Ctl::Stop here did break, so the stream
was single-use and the first track never reached the device.
Ctl::Stop now halts (stop_stream + reset_stream), discards the carry and the
queued PCM, answers any pending drain, and parks waiting for the next track's
first chunk, which it pre-rolls before restarting. The IAudioClient is never
re-Initialized, so the exclusive lock is held across the track change.
Three consequences, each found before shipping:
- Ctl::Shutdown is new, and only Drop sends it. Drop's comment asserted "the
thread's own loop exits on Stop"; once that stopped being true, join()
blocked forever at process exit. Measured as a 10-minute hang.
- stop() now blocks on an acknowledgement. Fire-and-forget raced the player,
which queues the next track's PCM immediately after stop() returns, so the
purge could eat audio belonging to the track that was starting.
- The halt and pre-roll results are no longer discarded, a Drain arriving
while stopped completes at once instead of blocking for the deadline,
drain() reports Err when a stop threw the audio away rather than claiming
it played, and Resume no longer Starts an unprimed Reset stream.
The example only tested drain-then-write, which is why a stream that could not
play a second track passed it. It now plays three audible one-second beeps --
after playback, after the drain, after the stop -- and judges underruns on
continuous playback only, since the gaps between beeps are underruns the test
creates itself.
Measured against the DAC at 44100 and 192000: three beeps, stop acknowledged
in 109 ms, write after stop OK, 0 underruns, 0 event timeouts, exit 0.
Verified by smoke in the app: one exclusive open at 96000 Hz across two track
changes, zero "render thread is gone", and gapless confirmed audibly.
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
PlaybackEngine::AlsaDirect and StreamType::AlsaDirect become Direct, carrying
Arc<dyn DirectSink> instead of Arc<AlsaDirectStream>. Eleven match arms and
seven sites keep working untouched because they only ever called trait
methods; duplicating them per platform would have been the alternative.
CORRECTION to e61cd7bd8's message: it claimed the ALSA path still
monomorphises to static dispatch. That was true when written and is NOT true
now - the payload is a trait object, so those calls are virtual. Measured
where it lands: write_f32 runs once per 8192-frame chunk, i.e. 5.4 indirect
calls/second at 44.1 kHz and 23.4 at 192 kHz, each immediately entering
blocking device I/O. channels() once at construction, sample_rate/stop/drain
at boundaries. Nothing in the sample path.
Two things Windows CANNOT compile-check, found by reading rather than by the
compiler, and both would have broken the Linux build:
- Four cfg(linux) arms passed a matched Arc<dyn DirectSink> to
new_alsa_direct, which takes the concrete type. Arc unsizes one way only.
- set_hardware_volume is called inside a cfg(linux) block and was not on the
trait. It is now, with a default that REFUSES rather than silently
succeeding, overridden by ALSA on Linux only - a sink with no mixer must
not pretend it moved the volume.
StreamType::Direct is also no longer cfg(linux). Gating it was what stopped
this compiler from checking any arm that handles it, which is the exact blind
spot this port has to work around; the variant now compiles everywhere and
Windows will refuse anything I get wrong in it.
The log prefix moved onto the sink (DirectSink::log_label) rather than being
passed in: four call sites hard-coding "ALSA Direct Engine" is four chances to
mislabel a WASAPI stream. play/pause/stop/set_volume use it too - previously
only the writer thread did, so a WASAPI stream would have logged ALSA
lifecycle lines.
Sol reviewed as the Linux compiler before this commit and found no Linux type
error; the stale references and hard-coded labels above are its findings.
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
ALSA and WASAPI need byte-identical behaviour in the writer thread - same
chunking, same gapless hand-off, same drain - and two copies of 112 lines
drift. `qbz_audio::backend::DirectSink` names the five methods the engine
already called on AlsaDirectStream, both streams forward to their inherent
methods with no wrapping, and `alsa_writer_thread` becomes
`direct_writer_thread<S: DirectSink>` plus a log label.
Linux is unchanged: the ALSA call site passes its old label verbatim, so
every emitted string is identical, and `S` infers to AlsaDirectStream, so the
call monomorphises to the same static dispatch it had - no trait object, no
vtable in the audio path.
Sol reviewed this before the commit, per the rule added today, and found the
defect that mattered:
WasapiDirectStream::drain only SENT Ctl::Drain and returned immediately,
while the trait - and the ALSA drain it now shares a thread with - promises
to play the queue out first. Worse, the render thread EXITED after draining,
so a later append would have written into a dead thread. Gapless on Windows
would have been broken in a way no unit test reaches.
drain now blocks on a reply from the render thread, and the render thread
answers and KEEPS RUNNING. Proven with the example: drain returns Ok in 28 ms
rather than 0, and a write after it succeeds. Starvation is also counted as an
EVENT now rather than per period - an idle stream otherwise reported hundreds
of underruns for one real one.
Sol also caught two stale doc references to the old function name and a
"draining ALSA buffer" line that a WASAPI stream would have printed.
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 render thread and the handle. COM objects are !Send, so one thread owns
every one of them for the life of the stream and this struct is a bounded
channel plus atomics - the same back-pressure snd_pcm_writei gives the ALSA
path for free.
Opens by ENDPOINT ID, never by friendly name: Windows gives several
endpoints the same name, which is what hid this DAC from the picker until
249adc77b. Walks the format ladder, pre-aligns the period to 128 bytes,
keeps the documented NOT_ALIGNED recovery for the devices that still need
it, joins MMCSS Pro Audio, and refuses S_FALSE loudly - a "closest match" is
by definition not the format we asked for.
Verified against the owner's Cambridge Audio USB Audio 2.0 with the new
example, and it matches the spike exactly:
rate 96000, S24In32 (24/32), period 30000 hns, buffer 288 frames,
realigned false, timing Events, mode DirectHardware,
960000 frames, 0 underruns, 0 event timeouts
Three faults were found on the way, and all three were in the MEASUREMENT
rather than the audio - worth recording because each looked like a hardware
problem:
- 339 "underruns" were event timeouts counted in the same counter. Split
them; they are different faults and were hiding each other.
- 337 event timeouts came from a 2x-period deadline. At a 3 ms period that
expired on 10% of wakeups because Windows scheduling is not that
punctual. The deadline exists to stop the thread hanging on a dead
device, not to detect glitches, and the spike waited 1000 ms and saw
none in ten minutes. It is generous now.
- The last 2 were a drain's trailing silence. A drain emitting silence is
the drain working.
The stub for every other platform mirrors alsa_direct's: constructors refuse,
teardown succeeds, so a caller written against this compiles and unwinds
everywhere. `cargo tree` shows exactly one windows v0.62.2 - no second
windows tree entered the graph.
cc
Format ladder, PCM packing and period arithmetic for phase B, with the tests
that make them provable. No COM yet: this half compiles and is tested on
every host, which matters because it is where the platform-specific mistakes
live.
The one that bites: Windows 24-in-32 is LEFT-aligned, the 24 valid bits in
the HIGH bytes, where ALSA's S24_LE is right-aligned. Porting the ALSA
packing verbatim shifts every sample down 8 bits and plays 48 dB QUIET, which
sounds like a volume bug and survives a listening test. There is a test whose
only job is to fail if that ever happens.
Scaling matches alsa_direct exactly (8_388_607 and 32767, not the power of
two), so the two backends cannot disagree about full scale. The plan's test
sketch asserted i16::MIN and -8_388_608; those follow from the OTHER scaling
choice, so the tests assert the behaviour this file actually has.
aligned_period_hns rounds the period so frames * block_align is a whole
number of 128-byte blocks, which is how AUDCLNT_E_BUFFER_SIZE_NOT_ALIGNED
stops firing rather than being recovered from. Verified against the two
periods measured on the owner's DAC: 3 ms at 192k 24-in-32 is already
aligned at 576 frames and must not move.
LADDER order comes from research/01; the spike found that this DAC accepts
only S24In32, so the ladder is one rung deep there. It stays a ladder because
other devices are not.
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
Windows endpoint friendly names are not unique. Measured on this box, FOUR
active render endpoints resolved to three display strings, and two were both
called "Altavoces": the onboard Realtek and a Cambridge Audio USB DAC.
device_filter dedups on the display, so the DAC was folded away and could
not be selected at all - and a DAC you cannot select cannot be sent a
bit-perfect stream, so this also blocked phase B.
device_filter.rs:20-23 states the opposite in a comment - "macOS/Windows
device names carry unique displays, so they pass through untouched". That is
the third comment in this tree asserting a mechanism that does not hold.
Nothing new is queried: cpal 0.17.3 already fills the fields this backend
ignored. name() is the ENDPOINT and repeats; driver() is the ADAPTER and is
what tells them apart; id() is the stable WASAPI endpoint GUID. On Windows
the id becomes the GUID, the display becomes "endpoint (adapter)", and the
adapter lands in the description field that was hard-coded None. The default
device is matched by id there too, since three endpoints shared a name.
Additive cfg arms only, per the protected-core rule: every other platform
takes the same (name, name, None, name == default_name) tuple it took
before, byte for byte. The open path keeps the name comparison as a fallback
so a device_id saved before this change still resolves.
Verified against the real hardware, before and after, with a standalone cpal
probe: the DAC goes from DROPPED to listed, labelled and flagged default.
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
Adversarial review of the previous commit found four defects, three of them
mine and one of them fatal to the Local Library on Windows.
fs::canonicalize RETURNS extended-length syntax on Windows and
qbz-library/src/scan.rs stores that string for every scanned file, so the
helper saw \?\C:\Music\cover.jpg, read it as a UNC path whose server was
"?", and emitted file://%3F/C:/Music/... - every local cover, gone. Both
verbatim spellings are now undone, \?\UNC\ included.
local_path dropped the // that a UNC authority stands for, so the round trip
returned nas/music/x, a path that names nothing. It now returns
//nas/music/x, which is also what QUrl::toLocalFile gives back.
file_url pushed a third slash for any path that did not start with one, so a
relative artwork_path changed from file://covers/x to file:///covers/x on
Linux. Neither resolves - a relative path is not a valid file:// URL - but
Linux behaviour does not move in a Windows port. The extra slash is now
emitted only for a drive path.
is_local_abs_path now requires a separator after the colon, so C:cover.jpg
is drive-RELATIVE and no longer claimed as absolute on either platform.
The two residual ambiguities - a Unix path whose first component is a single
letter plus a colon - are documented at the top of the module rather than
papered over: no string algorithm separates them, and deciding by host would
put the failure back on the machine that ships.
Four regression tests added, one per defect.
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
lock.rs imported std::os::unix and called libc::flock unconditionally, so
a workspace test run could not compile on Windows.
The Windows arm locks ONE byte at offset 1 GiB rather than the whole file.
Byte-range locks are mandatory there, not advisory like flock: measured on
this box, a whole-file lock from offset 0 makes the losing instance's
read_to_string fail with ERROR_LOCK_VIOLATION, so AlreadyRunning would
always carry None - the pid is the only reason that variant exists. With
the lock byte past any content the pid still reads back, the same handle
conflict is still detected, and the lock still releases when the File
drops. Reserving a region past EOF is what SQLite's Windows VFS does.
Only ERROR_LOCK_VIOLATION means contention; a filesystem that does not
implement byte-range locks answers ERROR_INVALID_FUNCTION and must surface
as Io, not as a daemon that is not running. The unix arm keeps its
existing any-failure-means-busy behaviour unchanged.
cc
The not(unix) arm returned Err and both preflight() and prepare()
propagate it, so the catalog never bootstrapped on Windows and the Local
Library died at preflight.
GetDiskFreeSpaceExW on the nearest existing ancestor, since the catalog
root may not exist on first run; LockFileEx with FAIL_IMMEDIATELY, mapping
ERROR_LOCK_VIOLATION to BootstrapBusy like the flock arm and any other
error to Io. Nothing reads this lock file's contents, so the whole-file
range is safe here.
cc
qbz-credentials still asked for the Linux features on every target with
default_features on. keyring 3 has no default backend: without the
per-platform feature it exports the in-memory mock, whose writes succeed
and whose contents die with the process, so the offline-cache master key
was regenerated every launch and every wrapped blob failed AES-GCM on the
next start, with the logs reporting a working keyring. It survived only
because Cargo unified features with qbz-secrets, which is the fragility
the per-target block exists to remove.
Also refuse a non-disk-backed backend at runtime and take the KDF path.
Measured in keyring-3.6.3, not assumed: windows.rs, macos.rs and
secret_service.rs all inherit the trait default UntilDelete
(credential.rs:171); mock.rs:228 overrides it to EntryOnly. Testing for
ProcessOnly, as first drafted, would never have fired.
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
Preserve Afonso’s restart regression test while selecting native stores only on Linux, macOS and Windows. Unsupported targets take the persisted KDF path instead of keyring’s successful in-memory mock; the stale workflow change is deliberately excluded.
cx
Preserve Afonso’s same-directory temp-file replacement on top of the configurable cache root from #708. Cover the complete explicit-root round trip and document the exact process-crash and concurrent-writer guarantees.
cx
build-aarch64-linux.sh builds -p qbz-qt natively with the distro's Qt
(gated at >= 6.8, the tree's floor; Debian 13 / Ubuntu 25.04 / Fedora
41 qualify), installs rustup when absent, forces one rustc at a time on
<= 4 GB boxes, and runs the same offscreen smoke as CI. Its CROSS mode
is retired honestly: cxx-qt runs the target Qt's qmake during the
build, which cross-rs's Ubuntu 20.04 images cannot do. The qbzd script
and Cross.toml keep cross-compiling the daemon, with the Qt-free dep
set.
cc