Proof Portal
Omarchy
ProbeLabs65 findings · 95 requirementsA proof layer — requirements, tests and verified fixes — for two of Omarchy's subsystems: the application menu (launcher scripts, QML model, JSONC config, search and selection) and the lock screen (lock scripts, QML, PAM authentication). Scope is deliberately limited to those components of omacom/omarchy; the rest of the distribution is not covered.
Audio scripts parse localized pactl output: output switching fails in zh_TW/zh_CN/ko_KR sessions
Reported by rofrol · filed Oct 10, 2026 · open at source
At a glance
- 1 · What is claimed?
- Only the report text below — no validator has restated it as checkable claims yet.
- 2 · What does it affect?
- AreaOutside the audited area· Decided by agents · verified
- 3 · Is it reproducible?
- No validation has been run yet.
- 4 · What remains unclear?
- Not validated yet: no claims, no reproducer.
- 5 · What happens next?
- Decided by agents and verified by a second, independent agent — see the decision.
Original report
What happens
Several audio scripts parse pactl's human-readable output with awk or
string matches. pactl is translated (gettext), so in a session whose locale
is, for example, zh_TW.UTF-8, zh_CN.UTF-8 or ko_KR.UTF-8, the headers
and values the scripts look for are not there:
| Script | Parsed output | What breaks |
|---|---|---|
omarchy-audio-output-set-default | pactl list sink-inputs (Sink Input #) | Its stream-moving loop finds no streams (streams that follow the default sink still move through WirePlumber; streams with their own target stay) |
omarchy-audio-output-sink | pactl list sink-inputs (Sink Input #, Sink:) | A DSP/tuning sink is not resolved to the physical sink, so volume goes to the wrong sink |
omarchy-audio-tuning | pactl list sink-inputs in app_streams | Streams are not moved onto/off the tuning sink |
omarchy-audio-output-switch | pactl get-sink-mute (Mute: yes), get-sink-volume, -f json list sinks (availability) | The OSD shows a muted output as unmuted |
omarchy-audio-output-volume | pactl get-sink-mute, get-sink-volume | Mute state and OSD level are wrong |
To reproduce on any Omarchy install with an output device:
LC_ALL=zh_TW.UTF-8 pactl list sink-inputs | head -3 # translated headers
pactl set-sink-mute @DEFAULT_SINK@ 1
LC_ALL=ko_KR.UTF-8 pactl get-sink-mute @DEFAULT_SINK@ # not "Mute: yes"(The locale must be generated; on Arch, uncomment it in /etc/locale.gen
and run locale-gen.)
Fix
Against quattro at a15636b5 (2026-10-10).
Run only the parsed pactl calls in the C locale, asomarchy-audio-sink-availability already does since #14431 (e1b0e5e9) and
as Omarchy does for LC_ALL=C pacman -Qi. The rest of each script, including the OSD text
and device descriptions, keeps the session's language.
--- a/bin/omarchy-audio-output-set-default
+++ b/bin/omarchy-audio-output-set-default
@@ -19,7 +19,8 @@
# sink input but carries no application.name, and moving it would rewire the
# processing itself -- onto headphones, or into its own virtual sink, which is a
# cycle. EasyEffects' output stream must stay put for the same reason.
-timeout 2 pactl list sink-inputs 2>/dev/null | awk '
+# pactl translates its headers; parse them in the C locale.
+LC_ALL=C timeout 2 pactl list sink-inputs 2>/dev/null | awk '
/^Sink Input #/ {id = substr($3, 2)}
/application\.name = / {
app = $0
--- a/bin/omarchy-audio-output-sink
+++ b/bin/omarchy-audio-output-sink
@@ -27,7 +27,8 @@
# A DSP sink feeds its physical output through a stream of its own; follow that
# stream down to the sink underneath.
-downstream="$(pactl list sink-inputs 2>/dev/null |
+# pactl translates its headers; parse them in the C locale.
+downstream="$(LC_ALL=C pactl list sink-inputs 2>/dev/null |
awk -v virt="$sink" '
BEGIN {
output = virt
--- a/bin/omarchy-audio-output-switch
+++ b/bin/omarchy-audio-output-switch
@@ -6,7 +6,8 @@
# silently bypass the tuning rather than pick a different output.
fronted=$(omarchy-audio-tuning fronted-sink 2>/dev/null || true)
-sinks=$(timeout 2 pactl -f json list sinks |
+# pactl translates availability, headers and yes/no; parse them in the C locale.
+sinks=$(LC_ALL=C timeout 2 pactl -f json list sinks |
jq --arg fronted "$fronted" '[.[]
| select((.ports | length == 0) or ([.ports[]? | .availability != "not available"] | any))
| select($fronted == "" or .name != $fronted)]')
@@ -33,10 +34,10 @@
# physical sink beneath it, so read the level from whichever sink actually carries
# it or the OSD contradicts the volume keys.
next_sink_effective=$(omarchy-audio-output-sink "$next_sink_name")
-next_sink_volume=$(timeout 2 pactl get-sink-volume "$next_sink_effective" 2>/dev/null |
+next_sink_volume=$(LC_ALL=C timeout 2 pactl get-sink-volume "$next_sink_effective" 2>/dev/null |
awk 'NR == 1 {for (i = 1; i <= NF; i++) if ($i ~ /%$/) {sub("%", "", $i); print $i; exit}}')
[[ -n $next_sink_volume ]] || next_sink_volume=$(jq -r '.volume | to_entries[0].value.value_percent | sub("%"; "") | tonumber' <<<"$next_sink")
-if [[ $(timeout 2 pactl get-sink-mute "$next_sink_effective" 2>/dev/null) == *yes ]]; then
+if [[ $(LC_ALL=C timeout 2 pactl get-sink-mute "$next_sink_effective" 2>/dev/null) == *yes ]]; then
next_sink_is_muted=true
else
next_sink_is_muted=false
--- a/bin/omarchy-audio-output-volume
+++ b/bin/omarchy-audio-output-volume
@@ -22,8 +22,9 @@
# pactl reports the same percentage scale wpctl does (both are the raw volume
# over PA_VOLUME_NORM), so the OSD reads identically either way.
+# pactl translates its labels and yes/no; parse them in the C locale.
volume_percent() {
- pactl get-sink-volume "$sink" 2>/dev/null |
+ LC_ALL=C pactl get-sink-volume "$sink" 2>/dev/null |
awk 'NR == 1 {
for (i = 1; i <= NF; i++)
if ($i ~ /%$/) {sub("%", "", $i); print $i; exit}
@@ -31,7 +32,7 @@
}
volume_muted() {
- [[ $(pactl get-sink-mute "$sink" 2>/dev/null) == *yes ]]
+ [[ $(LC_ALL=C pactl get-sink-mute "$sink" 2>/dev/null) == *yes ]]
}
case "$action" in
--- a/bin/omarchy-audio-tuning
+++ b/bin/omarchy-audio-tuning
@@ -127,7 +127,8 @@
# a sink input but carries no application.name, and moving it would rewire the
# tuning itself.
app_streams() {
- pactl list sink-inputs 2>/dev/null | awk '
+ # pactl translates its headers; parse them in the C locale.
+ LC_ALL=C pactl list sink-inputs 2>/dev/null | awk '
/^Sink Input #/ {id = substr($3, 2)}
/application\.name = / {
app = $0omarchy-audio-input-set-default parses pactl list short source-outputs,
which is not translated, so it needs no change. pactl list ... short,get-default-sink and the move-*/set-* commands print no translated
text that the scripts read.
Verified
On Omarchy 4.0.4 (c668141e) with pipewire-pulse, in a VM whose session iszh_TW.UTF-8, then ko_KR.UTF-8, with four output sinks:
omarchy-audio-sink-availability(4.0.4): no output at all; with
LC_ALL=C: all four sinks, each available. (The same fix as #14431.)
- omarchy-audio-output-switch (4.0.4): the default sink stays the same
(the JSON filter drops every sink); patched: the default moves to the
next sink and the playing stream follows it.
- pactl get-sink-mute on a muted sink prints Mute: 是 / Mute: 예,
so the 4.0.4 == *yes test reports it unmuted; with LC_ALL=C it
reports it muted.
View raw text
### What happens
Several audio scripts parse `pactl`'s human-readable output with awk or
string matches. pactl is translated (gettext), so in a session whose locale
is, for example, `zh_TW.UTF-8`, `zh_CN.UTF-8` or `ko_KR.UTF-8`, the headers
and values the scripts look for are not there:
| Script | Parsed output | What breaks |
| --- | --- | --- |
| `omarchy-audio-output-set-default` | `pactl list sink-inputs` (`Sink Input #`) | Its stream-moving loop finds no streams (streams that follow the default sink still move through WirePlumber; streams with their own target stay) |
| `omarchy-audio-output-sink` | `pactl list sink-inputs` (`Sink Input #`, `Sink:`) | A DSP/tuning sink is not resolved to the physical sink, so volume goes to the wrong sink |
| `omarchy-audio-tuning` | `pactl list sink-inputs` in `app_streams` | Streams are not moved onto/off the tuning sink |
| `omarchy-audio-output-switch` | `pactl get-sink-mute` (`Mute: yes`), `get-sink-volume`, `-f json list sinks` (`availability`) | The OSD shows a muted output as unmuted |
| `omarchy-audio-output-volume` | `pactl get-sink-mute`, `get-sink-volume` | Mute state and OSD level are wrong |
To reproduce on any Omarchy install with an output device:
```sh
LC_ALL=zh_TW.UTF-8 pactl list sink-inputs | head -3 # translated headers
pactl set-sink-mute @DEFAULT_SINK@ 1
LC_ALL=ko_KR.UTF-8 pactl get-sink-mute @DEFAULT_SINK@ # not "Mute: yes"
```
(The locale must be generated; on Arch, uncomment it in `/etc/locale.gen`
and run `locale-gen`.)
### Fix
Against `quattro` at a15636b5 (2026-10-10).
Run only the parsed `pactl` calls in the C locale, as
`omarchy-audio-sink-availability` already does since #14431 (e1b0e5e9) and
as Omarchy does for `LC_ALL=C pacman -Qi`. The rest of each script, including the OSD text
and device descriptions, keeps the session's language.
```diff
--- a/bin/omarchy-audio-output-set-default
+++ b/bin/omarchy-audio-output-set-default
@@ -19,7 +19,8 @@
# sink input but carries no application.name, and moving it would rewire the
# processing itself -- onto headphones, or into its own virtual sink, which is a
# cycle. EasyEffects' output stream must stay put for the same reason.
-timeout 2 pactl list sink-inputs 2>/dev/null | awk '
+# pactl translates its headers; parse them in the C locale.
+LC_ALL=C timeout 2 pactl list sink-inputs 2>/dev/null | awk '
/^Sink Input #/ {id = substr($3, 2)}
/application\.name = / {
app = $0
--- a/bin/omarchy-audio-output-sink
+++ b/bin/omarchy-audio-output-sink
@@ -27,7 +27,8 @@
# A DSP sink feeds its physical output through a stream of its own; follow that
# stream down to the sink underneath.
-downstream="$(pactl list sink-inputs 2>/dev/null |
+# pactl translates its headers; parse them in the C locale.
+downstream="$(LC_ALL=C pactl list sink-inputs 2>/dev/null |
awk -v virt="$sink" '
BEGIN {
output = virt
--- a/bin/omarchy-audio-output-switch
+++ b/bin/omarchy-audio-output-switch
@@ -6,7 +6,8 @@
# silently bypass the tuning rather than pick a different output.
fronted=$(omarchy-audio-tuning fronted-sink 2>/dev/null || true)
-sinks=$(timeout 2 pactl -f json list sinks |
+# pactl translates availability, headers and yes/no; parse them in the C locale.
+sinks=$(LC_ALL=C timeout 2 pactl -f json list sinks |
jq --arg fronted "$fronted" '[.[]
| select((.ports | length == 0) or ([.ports[]? | .availability != "not available"] | any))
| select($fronted == "" or .name != $fronted)]')
@@ -33,10 +34,10 @@
# physical sink beneath it, so read the level from whichever sink actually carries
# it or the OSD contradicts the volume keys.
next_sink_effective=$(omarchy-audio-output-sink "$next_sink_name")
-next_sink_volume=$(timeout 2 pactl get-sink-volume "$next_sink_effective" 2>/dev/null |
+next_sink_volume=$(LC_ALL=C timeout 2 pactl get-sink-volume "$next_sink_effective" 2>/dev/null |
awk 'NR == 1 {for (i = 1; i <= NF; i++) if ($i ~ /%$/) {sub("%", "", $i); print $i; exit}}')
[[ -n $next_sink_volume ]] || next_sink_volume=$(jq -r '.volume | to_entries[0].value.value_percent | sub("%"; "") | tonumber' <<<"$next_sink")
-if [[ $(timeout 2 pactl get-sink-mute "$next_sink_effective" 2>/dev/null) == *yes ]]; then
+if [[ $(LC_ALL=C timeout 2 pactl get-sink-mute "$next_sink_effective" 2>/dev/null) == *yes ]]; then
next_sink_is_muted=true
else
next_sink_is_muted=false
--- a/bin/omarchy-audio-output-volume
+++ b/bin/omarchy-audio-output-volume
@@ -22,8 +22,9 @@
# pactl reports the same percentage scale wpctl does (both are the raw volume
# over PA_VOLUME_NORM), so the OSD reads identically either way.
+# pactl translates its labels and yes/no; parse them in the C locale.
volume_percent() {
- pactl get-sink-volume "$sink" 2>/dev/null |
+ LC_ALL=C pactl get-sink-volume "$sink" 2>/dev/null |
awk 'NR == 1 {
for (i = 1; i <= NF; i++)
if ($i ~ /%$/) {sub("%", "", $i); print $i; exit}
@@ -31,7 +32,7 @@
}
volume_muted() {
- [[ $(pactl get-sink-mute "$sink" 2>/dev/null) == *yes ]]
+ [[ $(LC_ALL=C pactl get-sink-mute "$sink" 2>/dev/null) == *yes ]]
}
case "$action" in
--- a/bin/omarchy-audio-tuning
+++ b/bin/omarchy-audio-tuning
@@ -127,7 +127,8 @@
# a sink input but carries no application.name, and moving it would rewire the
# tuning itself.
app_streams() {
- pactl list sink-inputs 2>/dev/null | awk '
+ # pactl translates its headers; parse them in the C locale.
+ LC_ALL=C pactl list sink-inputs 2>/dev/null | awk '
/^Sink Input #/ {id = substr($3, 2)}
/application\.name = / {
app = $0
```
`omarchy-audio-input-set-default` parses `pactl list short source-outputs`,
which is not translated, so it needs no change. `pactl list ... short`,
`get-default-sink` and the `move-*`/`set-*` commands print no translated
text that the scripts read.
### Verified
On Omarchy 4.0.4 (c668141e) with pipewire-pulse, in a VM whose session is
`zh_TW.UTF-8`, then `ko_KR.UTF-8`, with four output sinks:
- `omarchy-audio-sink-availability` (4.0.4): no output at all; with
`LC_ALL=C`: all four sinks, each available. (The same fix as #14431.)
- `omarchy-audio-output-switch` (4.0.4): the default sink stays the same
(the JSON filter drops every sink); patched: the default moves to the
next sink and the playing stream follows it.
- `pactl get-sink-mute` on a muted sink prints `Mute: 是` / `Mute: 예`,
so the 4.0.4 `== *yes` test reports it unmuted; with `LC_ALL=C` it
reports it muted.
Validation
Not checked yet. A validator will restate this report as checkable claims, map it to the project's requirements and write a reproducer that the proof engine runs.
Linked
Not linked to any known issue, other report or change yet.
Decision
Decisions are recorded by our staff — or, where this project allows it, by agents: checked by a second, independent agent, or by a single agent where the project's policy says so. Any decision reached so far is shown below.
- Out of scopeDecided by agents · verified · 2026-10-10 13:10 UTC