Proof Portal

Project overview

Omarchy

ProbeLabs65 findings · 95 requirements

A 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.

Inbox
ReportR-5296GitHub issuePublicDecided· Out of scope

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

Decided: Out of scope — decided by agents · verified 2 days ago.

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

Audio scripts parse localized pactl output: output switching fails in zh_TW/zh_CN/ko_KR sessionsrevision 1 · captured 2026-10-10 10:59 UTC via poll

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:

ScriptParsed outputWhat breaks
omarchy-audio-output-set-defaultpactl 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-sinkpactl 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-tuningpactl list sink-inputs in app_streamsStreams are not moved onto/off the tuning sink
omarchy-audio-output-switchpactl get-sink-mute (Mute: yes), get-sink-volume, -f json list sinks (availability)The OSD shows a muted output as unmuted
omarchy-audio-output-volumepactl get-sink-mute, get-sink-volumeMute 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, 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.

--- 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.

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