Merge branch 'quattro' into add-default-agent

# Conflicts:
#	migrations/1785633225.sh
This commit is contained in:
David Heinemeier Hansson
2026-08-04 05:33:49 -07:00
30 changed files with 759 additions and 194 deletions
+3 -1
View File
@@ -31,7 +31,9 @@ option_bool() {
local signature="$2" local signature="$2"
local option="$3" local option="$3"
hyprctl_instance "$runtime_dir" "$signature" -j getoption "$option" 2>/dev/null | jq -r '.bool' # A dead instance makes hyprctl print "Couldn't connect ..." on stdout, so
# silence jq too and let the failed pipeline skip the instance.
hyprctl_instance "$runtime_dir" "$signature" -j getoption "$option" 2>/dev/null | jq -r '.bool' 2>/dev/null
} }
instances() { instances() {
+16 -2
View File
@@ -2,11 +2,25 @@
# omarchy:summary=Generate a Wi-Fi QR matrix for the shell # omarchy:summary=Generate a Wi-Fi QR matrix for the shell
# omarchy:group=network # omarchy:group=network
# omarchy:args=<interface> # omarchy:args=[interface]
set -euo pipefail set -euo pipefail
interface=${1:?Usage: omarchy-network-qr <interface>} interface=${1:-}
if [[ -z $interface ]]; then
# Prefer the default-route device: it is the connection the panel and the
# menu's visibility gate describe. Fall back to the first connected Wi-Fi
# device. nmcli localizes state names, so pin the locale, and the prefix
# match accepts states like "connected (externally)".
route_device=$(ip route get 1.1.1.1 2>/dev/null | awk '{ for (i = 1; i <= NF; i++) if ($i == "dev") { print $(i + 1); exit } }')
if [[ -n $route_device && -d /sys/class/net/$route_device/wireless ]]; then
interface=$route_device
else
interface=$(LC_ALL=C nmcli -t -f DEVICE,TYPE,STATE device status 2>/dev/null |
awk -F: '$2 == "wifi" && $3 ~ /^connected/ { print $1; exit }')
fi
fi
[[ -n $interface ]] || { echo "No active Wi-Fi connection" >&2; exit 1; }
uuid=$(nmcli --get-values GENERAL.CON-UUID device show "$interface" | head -n 1) uuid=$(nmcli --get-values GENERAL.CON-UUID device show "$interface" | head -n 1)
[[ -n $uuid && $uuid != "--" ]] || { echo "No active Wi-Fi connection" >&2; exit 1; } [[ -n $uuid && $uuid != "--" ]] || { echo "No active Wi-Fi connection" >&2; exit 1; }
+6
View File
@@ -42,3 +42,9 @@ for file in "$HOME"/.local/state/omarchy/restart-*-required; do
omarchy-restart-"$service" omarchy-restart-"$service"
fi fi
done done
# Updates routinely replace the shell's QML, and a stale process can lazy-load
# new files into old code. A restart failure (locked session, ssh, TTY) only
# prints its reason: the next update or login gets a fresh shell anyway.
echo "Restarting shell"
omarchy-restart-shell || true
+1
View File
@@ -8,6 +8,7 @@ workers=0
[scrollback] [scrollback]
lines=10000 lines=10000
multiplier=7.0
[cursor] [cursor]
style=block style=block
+1 -1
View File
@@ -19,7 +19,7 @@ o.window({
}, { tag = "+floating-window" }) }, { tag = "+floating-window" })
o.window("dev.tensaku.Tensaku", { float = true }) o.window("dev.tensaku.Tensaku", { float = true })
o.window("dev.tensaku.Tensaku", { center = true }) o.window("dev.tensaku.Tensaku", { center = true })
o.window("org.gnome.Calculator", { float = true }) o.window("omacalc", { float = true })
-- Fullscreen screensaver. -- Fullscreen screensaver.
o.window("org.omarchy.screensaver", { fullscreen = true }) o.window("org.omarchy.screensaver", { fullscreen = true })
+2 -1
View File
@@ -8,7 +8,8 @@ o.bind("SUPER + ESCAPE", "System menu", "omarchy-menu toggle system")
o.bind("XF86PowerOff", "Power menu", "omarchy-menu toggle system", { locked = true }) o.bind("XF86PowerOff", "Power menu", "omarchy-menu toggle system", { locked = true })
o.bind("SUPER + K", "Show key bindings", "omarchy-menu-keybindings") o.bind("SUPER + K", "Show key bindings", "omarchy-menu-keybindings")
o.bind("SUPER + ALT + K", "Show Tmux key bindings", "omarchy-menu-tmux-keybindings") o.bind("SUPER + ALT + K", "Show Tmux key bindings", "omarchy-menu-tmux-keybindings")
o.bind("XF86Calculator", "Calculator", "gnome-calculator") o.bind("XF86Calculator", "Calculator", "omacalc")
o.bind("SUPER + SHIFT + EQUAL", "Calculator", "omacalc")
o.bind_toggle("SUPER + SHIFT + SPACE", "Toggle top bar", "bar") o.bind_toggle("SUPER + SHIFT + SPACE", "Toggle top bar", "bar")
o.bind("SUPER + CTRL + SPACE", "Background switcher", "omarchy-menu toggle background") o.bind("SUPER + CTRL + SPACE", "Background switcher", "omarchy-menu toggle background")
+8
View File
@@ -122,6 +122,14 @@
"setup.keybindings": {"icon":"","label":"Keybindings","when":"[[ -f ~/.config/hypr/bindings.lua ]]","action":"omarchy-launch-config-editor \"$HOME/.config/hypr/bindings.lua\""}, "setup.keybindings": {"icon":"","label":"Keybindings","when":"[[ -f ~/.config/hypr/bindings.lua ]]","action":"omarchy-launch-config-editor \"$HOME/.config/hypr/bindings.lua\""},
"setup.input": {"icon":"","label":"Input","when":"[[ -f ~/.config/hypr/input.lua ]]","action":"omarchy-launch-config-editor \"$HOME/.config/hypr/input.lua\""}, "setup.input": {"icon":"","label":"Input","when":"[[ -f ~/.config/hypr/input.lua ]]","action":"omarchy-launch-config-editor \"$HOME/.config/hypr/input.lua\""},
"setup.direct-boot": {"icon":"","label":"Direct Boot","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-direct-boot"}, "setup.direct-boot": {"icon":"","label":"Direct Boot","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-direct-boot"},
"setup.network": {"icon":"󰛳","label":"Network","aliases":["network"]},
"setup.network.dns": {"icon":"󰇖","label":"DNS","aliases":["dns"]},
"setup.network.dns.dhcp": {"icon":"󰩟","label":"DHCP","checked":"[[ \"$(omarchy-dns)\" == \"DHCP\" ]]","action":"omarchy-dns DHCP"},
"setup.network.dns.cloudflare": {"icon":"󰅟","label":"Cloudflare","checked":"[[ \"$(omarchy-dns)\" == \"Cloudflare\" ]]","action":"omarchy-dns Cloudflare"},
"setup.network.dns.google": {"icon":"󰊭","label":"Google","checked":"[[ \"$(omarchy-dns)\" == \"Google\" ]]","action":"omarchy-dns Google"},
"setup.network.dns.custom": {"icon":"","label":"Custom","checked":"[[ \"$(omarchy-dns)\" == \"Custom\" ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-dns Custom'"},
"setup.network.qr": {"icon":"󰐲","label":"QR Code","aliases":["wifi-qr"],"when":"[[ $(omarchy-network-status) == wifi* ]]","action":"omarchy-shell omarchy.network showQr"},
"setup.network.speedtest": {"icon":"󰓅","label":"Speed Test","aliases":["speedtest","speed-test"],"action":"omarchy-shell omarchy.network speedTest"},
"setup.default": {"icon":"","label":"Defaults","aliases":["default","defaults"]}, "setup.default": {"icon":"","label":"Defaults","aliases":["default","defaults"]},
"setup.default.agent": {"icon":"󰚩","label":"Agent"}, "setup.default.agent": {"icon":"󰚩","label":"Agent"},
"setup.default.agent.claude": {"icon":"󰛄","label":"Claude","checked":"[[ \"$(omarchy-default-agent)\" == \"claude\" ]]","action":"omarchy-default-agent claude"}, "setup.default.agent.claude": {"icon":"󰛄","label":"Claude","checked":"[[ \"$(omarchy-default-agent)\" == \"claude\" ]]","action":"omarchy-default-agent claude"},
+3
View File
@@ -127,6 +127,9 @@ New migration format:
- Use helper commands such as `omarchy-cmd-present`, `omarchy-cmd-missing`, - Use helper commands such as `omarchy-cmd-present`, `omarchy-cmd-missing`,
`omarchy-pkg-add`, `omarchy-pkg-drop`, `omarchy-pkg-present`, and `omarchy-pkg-add`, `omarchy-pkg-drop`, `omarchy-pkg-present`, and
`omarchy-pkg-missing` when appropriate. `omarchy-pkg-missing` when appropriate.
- Never restart the Omarchy shell. `omarchy update` restarts it unconditionally
after migrations run, and the login-time shell already runs current code and
hot-reloads `shell.json` edits.
Example: Example:
+2 -2
View File
@@ -26,7 +26,7 @@ The design goal is:
| `~/.local/state/omarchy/current/` | user | Generated active theme, selected theme name, and current background symlink. | | `~/.local/state/omarchy/current/` | user | Generated active theme, selected theme name, and current background symlink. |
| `~/.local/state/omarchy/migrations/` | user | Per-user migration markers. | | `~/.local/state/omarchy/migrations/` | user | Per-user migration markers. |
| `~/.local/state/omarchy/reboot-required` | user | Optional reboot marker checked by `omarchy-update-restart`. | | `~/.local/state/omarchy/reboot-required` | user | Optional reboot marker checked by `omarchy-update-restart`. |
| `~/.local/state/omarchy/restart-*-required` | user | Optional service/app restart markers checked by `omarchy-update-restart`. | | `~/.local/state/omarchy/restart-*-required` | user | Optional service/app restart markers checked by `omarchy-update-restart`. The shell needs no marker: it is restarted unconditionally after every update. |
## Migration layout ## Migration layout
@@ -261,7 +261,7 @@ scripts.
| `omarchy-update-mise` | Runs `mise up` for mise-managed tools. | **Keep.** Mise-managed tools are intentionally part of the blessed update path. | | `omarchy-update-mise` | Runs `mise up` for mise-managed tools. | **Keep.** Mise-managed tools are intentionally part of the blessed update path. |
| `omarchy-update-orphan-pkgs` | Lists orphans and prompts before removal; noninteractive mode never removes. | **Keep for now.** Safe because it is prompt-only. | | `omarchy-update-orphan-pkgs` | Lists orphans and prompts before removal; noninteractive mode never removes. | **Keep for now.** Safe because it is prompt-only. |
| `omarchy-update-analyze-logs` | Scans `/tmp/omarchy-update.log` for known failure patterns, currently initramfs generation. | **Keep/expand.** Useful safety net; should grow only for high-signal checks. | | `omarchy-update-analyze-logs` | Scans `/tmp/omarchy-update.log` for known failure patterns, currently initramfs generation. | **Keep/expand.** Useful safety net; should grow only for high-signal checks. |
| `omarchy-update-restart` | Prompts for reboot after kernel/Hyprland updates and restarts components with `restart-*-required` markers. | **Keep.** Important final step; may eventually include service-restart checks. | | `omarchy-update-restart` | Prompts for reboot after kernel/Hyprland updates, restarts components with `restart-*-required` markers, and always restarts the shell. | **Keep.** Important final step; may eventually include service-restart checks. |
| `omarchy-update-firmware` | Manual firmware update command using fwupd. Not part of the normal update pipeline. | **Keep separate.** Firmware is not a routine system update step. | | `omarchy-update-firmware` | Manual firmware update command using fwupd. Not part of the normal update pipeline. | **Keep separate.** Firmware is not a routine system update step. |
| `omarchy-update-time` | Restarts `systemd-timesyncd`. | **Question.** Not really an update command. Consider renaming/moving under system/time maintenance. | | `omarchy-update-time` | Restarts `systemd-timesyncd`. | **Question.** Not really an update command. Consider renaming/moving under system/time maintenance. |
+1 -2
View File
@@ -42,7 +42,6 @@ fontconfig
foot foot
fzf fzf
git git
gnome-calculator
gnome-keyring gnome-keyring
gnome-themes-extra gnome-themes-extra
grim grim
@@ -91,6 +90,7 @@ nss-mdns
nvim nvim
obs-studio obs-studio
obsidian obsidian
omacalc
omacut omacut
omawrite omawrite
omarchy-nvim omarchy-nvim
@@ -109,7 +109,6 @@ qrencode
quickshell-git quickshell-git
ripgrep ripgrep
ruby ruby
rust
tensaku tensaku
sddm sddm
slurp slurp
-1
View File
@@ -5,5 +5,4 @@ if ! omarchy-pkg-present quickshell-git; then
# quickshell package in place; packages depending on quickshell stay # quickshell package in place; packages depending on quickshell stay
# satisfied through the provides. # satisfied through the provides.
sudo pacman -S --noconfirm --ask 4 quickshell-git sudo pacman -S --noconfirm --ask 4 quickshell-git
omarchy-state set restart-shell-required
fi fi
-2
View File
@@ -37,5 +37,3 @@ if [[ -s $config_file ]]; then
.bar.layout.center |= place_indicators_before_clock .bar.layout.center |= place_indicators_before_clock
' "$config_file" >"$tmp" && mv "$tmp" "$config_file" || rm -f "$tmp" ' "$config_file" >"$tmp" && mv "$tmp" "$config_file" || rm -f "$tmp"
fi fi
omarchy-restart-shell
-4
View File
@@ -118,7 +118,3 @@ if [[ -s $config_file ]] && grep -q 'TmuxAlert' "$config_file"; then
rm -f "$tmp" rm -f "$tmp"
fi fi
# Nothing to restart from a TTY or over ssh, and that is no reason to stop the
# rest of the queue: hand it to the post-update restart instead.
omarchy-restart-shell >/dev/null 2>&1 || omarchy-state set restart-shell-required
-2
View File
@@ -45,5 +45,3 @@ if [[ -s $config_file ]] && omarchy-cmd-present jq; then
end end
' "$config_file" >"$tmp" && mv "$tmp" "$config_file" || rm -f "$tmp" ' "$config_file" >"$tmp" && mv "$tmp" "$config_file" || rm -f "$tmp"
fi fi
omarchy-restart-shell
+18 -7
View File
@@ -1,9 +1,20 @@
echo "Install default coding agent mise wrappers" echo "Speed up mouse scrolling in foot"
if [[ ! -f $HOME/.local/state/omarchy/preinstalls-removed ]]; then foot_config="$HOME/.config/foot/foot.ini"
omarchy-mise-install github:can1357/oh-my-pi omp
omarchy-mise-install npm:@xai-official/grok grok if [[ -f $foot_config ]] && ! grep -q '^multiplier=' "$foot_config"; then
omarchy-mise-install crush if grep -qxF '[scrollback]' "$foot_config"; then
elif [[ -f $HOME/.local/bin/omp ]] && grep -Fq 'mise use -g "oh-my-pi"' "$HOME/.local/bin/omp"; then tmp=$(mktemp)
rm -f "$HOME/.local/bin/omp" awk '
{ print }
!inserted && $0 == "[scrollback]" {
print "multiplier=7.0"
inserted = 1
}
' "$foot_config" >"$tmp"
cat "$tmp" >"$foot_config"
rm -f "$tmp"
else
printf '\n[scrollback]\nmultiplier=7.0\n' >>"$foot_config"
fi
fi fi
+4
View File
@@ -0,0 +1,4 @@
echo "Replace GNOME Calculator with Omacalc"
omarchy-pkg-add omacalc
omarchy-pkg-drop gnome-calculator
+9
View File
@@ -0,0 +1,9 @@
echo "Install default coding agent mise wrappers"
if [[ ! -f $HOME/.local/state/omarchy/preinstalls-removed ]]; then
omarchy-mise-install github:can1357/oh-my-pi omp
omarchy-mise-install npm:@xai-official/grok grok
omarchy-mise-install crush
elif [[ -f $HOME/.local/bin/omp ]] && grep -Fq 'mise use -g "oh-my-pi"' "$HOME/.local/bin/omp"; then
rm -f "$HOME/.local/bin/omp"
fi
+12 -1
View File
@@ -12,18 +12,29 @@ var WEEKDAY_NAMES = ["sunday", "monday", "tuesday", "wednesday", "thursday", "fr
// ---- Bar label formats. Right-clicking the clock walks these in order and // ---- Bar label formats. Right-clicking the clock walks these in order and
// writes the result back to shell.json, so the label the bar shows and // writes the result back to shell.json, so the label the bar shows and
// the format the config stores are always the same thing. // the format the config stores are always the same thing.
//
// The locale-shaped time presets are each followed by their 12-hour twin, so
// the walk from a 24-hour label to the same label in AM/PM is a single right
// click rather than a lap of the ring. The ISO preset is deliberately left
// without one: ISO 8601 writes time on a 24-hour clock, so an AM/PM variant
// would contradict the only thing that format is for.
var CLOCK_FORMATS = [ var CLOCK_FORMATS = [
"dddd HH:mm", "dddd HH:mm",
"dddd h:mm AP",
"HH:mm", "HH:mm",
"h:mm AP",
"ddd d MMM HH:mm", "ddd d MMM HH:mm",
"ddd d MMM h:mm AP",
"d MMMM 'W'ww yyyy", "d MMMM 'W'ww yyyy",
"yyyy-MM-dd HH:mm" "yyyy-MM-dd HH:mm"
] ]
// Vertical bars have room for a few stacked lines and nothing else, so the // Vertical bars have room for a few stacked lines and nothing else, so the
// ring stays short. // ring stays short. AM/PM costs a fourth line, which is why only the plain
// time carries it here.
var VERTICAL_CLOCK_FORMATS = [ var VERTICAL_CLOCK_FORMATS = [
"HH\n—\nmm", "HH\n—\nmm",
"h\n—\nmm\nAP",
"dd\nMMM\n'W'ww\n''yy", "dd\nMMM\n'W'ww\n''yy",
"HH\nmm" "HH\nmm"
] ]
-7
View File
@@ -250,12 +250,6 @@ function formatRate(bytesPerSec) {
return formatBytes(bytesPerSec) + "/s" return formatBytes(bytesPerSec) + "/s"
} }
function formatSpeedMbps(mbps) {
var value = parseFloat(mbps)
if (!isFinite(value) || value <= 0) return "--"
return value.toFixed(value > 0 && value < 10 ? 1 : 0) + " Mbps"
}
// `hasSamples` false means no probe has come back yet, which is different from // `hasSamples` false means no probe has come back yet, which is different from
// a probe that timed out. The rows stay mounted through that gap and read "--" // a probe that timed out. The rows stay mounted through that gap and read "--"
// so the grid doesn't reflow a second after the panel opens. // so the grid doesn't reflow a second after the panel opens.
@@ -362,7 +356,6 @@ if (typeof module !== "undefined") {
formatPacketLoss: formatPacketLoss, formatPacketLoss: formatPacketLoss,
formatBytes: formatBytes, formatBytes: formatBytes,
formatRate: formatRate, formatRate: formatRate,
formatSpeedMbps: formatSpeedMbps,
formatPingLatency: formatPingLatency, formatPingLatency: formatPingLatency,
wifiRow: wifiRow, wifiRow: wifiRow,
sortWifiRows: sortWifiRows, sortWifiRows: sortWifiRows,
+142 -112
View File
@@ -17,9 +17,28 @@ Panel {
manageIpc: false manageIpc: false
// Centralized close so callers can't forget to drop the passphrase prompt. // Centralized close so callers can't forget to drop the passphrase prompt.
readonly property bool overlayVisible: qrVisible || speedTestModalOpen
// Shadows the base open(): a summon or toggle while a centered card is up
// dismisses the card instead of opening the compact panel behind an
// exclusive overlay. The base toggle() dispatches here, so the keybind,
// the bar icon, and every IPC route all get this behavior.
function open() {
if (overlayVisible) {
hideWifiQr()
hideSpeedTest()
return
}
root.controller.show()
}
function close() { function close() {
root.controller.hide() root.controller.hide()
cancelPasswordPrompt() cancelPasswordPrompt()
// The centered cards outlive the compact panel, but the widget's
// canonical close must not leave an overlay (or its traffic) behind.
hideWifiQr()
hideSpeedTest()
} }
function cancelPasswordPrompt() { function cancelPasswordPrompt() {
@@ -83,8 +102,9 @@ Panel {
property var bandAvailable: [] property var bandAvailable: []
property string pendingBand: "" property string pendingBand: ""
property bool speedTestRunning: false property bool speedTestRunning: false
property bool speedTestHasRun: false property bool speedTestModalOpen: false
property bool speedTestExpectedStop: false property bool speedTestExpectedStop: false
property bool pendingSpeedRun: false
property string speedTestPhase: "" property string speedTestPhase: ""
property string speedTestStderr: "" property string speedTestStderr: ""
property string speedTestDownloadMbps: "" property string speedTestDownloadMbps: ""
@@ -111,6 +131,8 @@ Panel {
property string qrError: "" property string qrError: ""
property bool qrLoading: false property bool qrLoading: false
property bool qrExpectedStop: false property bool qrExpectedStop: false
property bool pendingQrShow: false
property bool pendingQrDetect: false
property string qrPassword: "" property string qrPassword: ""
property bool qrPasswordVisible: false property bool qrPasswordVisible: false
property string qrPasswordError: "" property string qrPasswordError: ""
@@ -127,9 +149,9 @@ Panel {
property bool cursorActive: false property bool cursorActive: false
// Keyboard focus zone for the panel. j/k crosses row boundaries: // Keyboard focus zone for the panel. j/k crosses row boundaries:
// header actions ⇄ band ⇄ DNS row ⇄ speed test ⇄ Wi-Fi networks. h/l move // header actions ⇄ band ⇄ DNS row ⇄ Wi-Fi networks. h/l move
// within header actions, band pills, or DNS providers. // within header actions, band pills, or DNS providers.
property string focusSection: "dns" // "header" | "band" | "dns" | "speed" | "wifi" property string focusSection: "dns" // "header" | "band" | "dns" | "wifi"
property int headerIndex: 0 property int headerIndex: 0
readonly property bool canDisconnect: !!connectedWifiNetwork readonly property bool canDisconnect: !!connectedWifiNetwork
readonly property bool headerHasDisconnect: false readonly property bool headerHasDisconnect: false
@@ -139,9 +161,11 @@ Panel {
// "off" beside a perfectly live Ethernet connection. // "off" beside a perfectly live Ethernet connection.
readonly property bool canToggleWifi: networkManagerAvailable && wifiStationAvailable readonly property bool canToggleWifi: networkManagerAvailable && wifiStationAvailable
readonly property int qrHeaderIndex: canShareWifi ? 0 : -1 readonly property int qrHeaderIndex: canShareWifi ? 0 : -1
readonly property int toggleHeaderIndex: canToggleWifi ? (canShareWifi ? 1 : 0) : -1 readonly property int speedHeaderIndex: canRunSpeedTest ? (canShareWifi ? 1 : 0) : -1
readonly property int headerActionCount: (canShareWifi ? 1 : 0) + (canToggleWifi ? 1 : 0) readonly property int toggleHeaderIndex: canToggleWifi ? (canShareWifi ? 1 : 0) + (canRunSpeedTest ? 1 : 0) : -1
readonly property int headerActionCount: (canShareWifi ? 1 : 0) + (canRunSpeedTest ? 1 : 0) + (canToggleWifi ? 1 : 0)
readonly property bool qrHeaderHasCursor: cursorActive && focusSection === "header" && headerIndex === qrHeaderIndex readonly property bool qrHeaderHasCursor: cursorActive && focusSection === "header" && headerIndex === qrHeaderIndex
readonly property bool speedHeaderHasCursor: cursorActive && focusSection === "header" && headerIndex === speedHeaderIndex
readonly property bool toggleHeaderHasCursor: cursorActive && focusSection === "header" && headerIndex === toggleHeaderIndex readonly property bool toggleHeaderHasCursor: cursorActive && focusSection === "header" && headerIndex === toggleHeaderIndex
readonly property string toggleHint: Networking.wifiEnabled ? "Turn Wi-Fi off" : "Turn Wi-Fi on" readonly property string toggleHint: Networking.wifiEnabled ? "Turn Wi-Fi off" : "Turn Wi-Fi on"
readonly property var dnsProviders: ["DHCP", "Cloudflare", "Google", "Custom"] readonly property var dnsProviders: ["DHCP", "Cloudflare", "Google", "Custom"]
@@ -167,8 +191,8 @@ Panel {
readonly property bool bandPillsVisible: canSelectBand && bandPinned readonly property bool bandPillsVisible: canSelectBand && bandPinned
readonly property string bandSectionTitle: Model.bandSectionTitle(bandEffective, bandCurrent) readonly property string bandSectionTitle: Model.bandSectionTitle(bandEffective, bandCurrent)
readonly property bool bandBusy: pendingBand !== "" readonly property bool bandBusy: pendingBand !== ""
// The speed test section only exists once there's an interface to test, so // The speed test needs an interface to test, so its hero action only
// the Run button only joins the cursor chain then. // appears once there is one.
readonly property bool canRunSpeedTest: !!info.iface readonly property bool canRunSpeedTest: !!info.iface
property int bandIndex: 0 property int bandIndex: 0
// The band section has up to two cursor rows: the Automatic switch on the // The band section has up to two cursor rows: the Automatic switch on the
@@ -191,10 +215,6 @@ Panel {
} }
} }
onCanRunSpeedTestChanged: {
if (!canRunSpeedTest && focusSection === "speed") focusSection = "dns"
}
// Collapsing the pills out from under the cursor would leave it pointing at // Collapsing the pills out from under the cursor would leave it pointing at
// nothing, so send it up to the switch that is still on screen. // nothing, so send it up to the switch that is still on screen.
onBandPillsVisibleChanged: { onBandPillsVisibleChanged: {
@@ -226,10 +246,20 @@ Panel {
function hide() { root.close() } function hide() { root.close() }
function toggle() { root.toggle() } function toggle() { root.toggle() }
function toggleNetwork() { root.toggleNetwork() } function toggleNetwork() { root.toggleNetwork() }
// Menu routes: summon the centered cards directly, panel open or not.
function showQr() {
root.refresh()
root.showWifiQr(true)
}
function speedTest() {
root.refresh()
root.showSpeedTest()
}
} }
function activateHeader() { function activateHeader() {
if (headerIndex === qrHeaderIndex) showWifiQr() if (headerIndex === qrHeaderIndex) showWifiQr()
else if (headerIndex === speedHeaderIndex) showSpeedTest()
else if (headerIndex === toggleHeaderIndex) toggleNetwork() else if (headerIndex === toggleHeaderIndex) toggleNetwork()
} }
@@ -427,14 +457,27 @@ Panel {
readonly property string icon: Model.connectionIcon(kind, signalStrength) readonly property string icon: Model.connectionIcon(kind, signalStrength)
function showWifiQr() { function showWifiQr(forceDetect) {
if (qrProc.running || !info.iface || info.type !== "wifi") return if (qrProc.running) {
// A dismissal's SIGTERM is still in flight; Process.running stays true
// until the child exits, so queue the reopen for onExited.
if (qrExpectedStop) {
pendingQrShow = true
pendingQrDetect = !!forceDetect
}
return
}
qrSize = 0 qrSize = 0
qrRows = [] qrRows = []
qrError = "" qrError = ""
qrLoading = true qrLoading = true
qrExpectedStop = false qrExpectedStop = false
qrProc.command = ["omarchy-network-qr", info.iface] // The panel's own button shares the interface it is showing. The IPC
// route forces self-detection instead: details polling stops while the
// panel is closed, so its cached interface can be stale.
qrProc.command = !forceDetect && info.type === "wifi" && info.iface
? ["omarchy-network-qr", info.iface]
: ["omarchy-network-qr"]
qrProc.running = true qrProc.running = true
// Leave the compact network panel behind while the centered share card is open. // Leave the compact network panel behind while the centered share card is open.
@@ -443,6 +486,7 @@ Panel {
} }
function hideWifiQr() { function hideWifiQr() {
pendingQrShow = false
if (qrProc.running) { if (qrProc.running) {
qrExpectedStop = true qrExpectedStop = true
qrProc.running = false qrProc.running = false
@@ -563,10 +607,6 @@ Panel {
return Model.formatRate(bytesPerSec) return Model.formatRate(bytesPerSec)
} }
function formatSpeedMbps(mbps) {
return Model.formatSpeedMbps(mbps)
}
function formatPingLatency(ms) { function formatPingLatency(ms) {
return Model.formatPingLatency(ms, hasInternetPing) return Model.formatPingLatency(ms, hasInternetPing)
} }
@@ -660,10 +700,42 @@ Panel {
speedTestError = "" speedTestError = ""
} }
// The speed test lives in a centered modal card like the QR share.
// Opening it starts a fresh run; dismissing it stops the traffic, so the
// download workers never keep saturating the link behind a closed card.
function showSpeedTest() {
if (!speedTestModalOpen) {
speedTestModalOpen = true
controller.hide()
cancelPasswordPrompt()
}
runSpeedTest()
}
function hideSpeedTest() {
speedTestModalOpen = false
pendingSpeedRun = false
speedTestPhaseTimer.stop()
// Clear the phase before killing the process: onExited advances to the
// upload phase when it still reads "down".
speedTestPhase = ""
speedTestRunning = false
if (speedTestProc.running) {
speedTestExpectedStop = true
speedTestProc.running = false
}
}
function runSpeedTest() { function runSpeedTest() {
if (speedTestProc.running) return if (speedTestProc.running) {
// A dismissal's SIGTERM is still in flight; Process.running stays true
// until the child exits, so queue the fresh run for onExited.
if (speedTestExpectedStop) pendingSpeedRun = true
return
}
speedTestError = "" speedTestError = ""
speedTestHasRun = true speedTestDownloadMbps = ""
speedTestUploadMbps = ""
speedTestRunning = true speedTestRunning = true
startSpeedTestPhase("down") startSpeedTestPhase("down")
} }
@@ -883,6 +955,12 @@ Panel {
} }
onExited: function(exitCode) { onExited: function(exitCode) {
root.qrLoading = false root.qrLoading = false
if (root.pendingQrShow) {
root.pendingQrShow = false
root.qrExpectedStop = false
Qt.callLater(function() { root.showWifiQr(root.pendingQrDetect) })
return
}
if (root.qrExpectedStop) return if (root.qrExpectedStop) return
if (exitCode !== 0 || root.qrSize === 0) { if (exitCode !== 0 || root.qrSize === 0) {
root.qrSize = 0 root.qrSize = 0
@@ -949,6 +1027,13 @@ Panel {
onExited: function(exitCode) { onExited: function(exitCode) {
speedTestPhaseTimer.stop() speedTestPhaseTimer.stop()
if (root.pendingSpeedRun) {
root.pendingSpeedRun = false
root.speedTestExpectedStop = false
if (root.speedTestModalOpen) Qt.callLater(root.runSpeedTest)
return
}
if (!root.speedTestExpectedStop && exitCode !== 0) { if (!root.speedTestExpectedStop && exitCode !== 0) {
root.speedTestError = root.speedTestStderr || "Speed test failed" root.speedTestError = root.speedTestStderr || "Speed test failed"
root.speedTestPhase = "" root.speedTestPhase = ""
@@ -1134,25 +1219,15 @@ Panel {
root.focusSection = "header" root.focusSection = "header"
root.headerIndex = 0 root.headerIndex = 0
} }
} else if (root.canRunSpeedTest) {
root.focusSection = "speed"
} else if (root.wifiNetworks.length > 0) {
root.focusSection = "wifi"
if (root.selectedIndex < 0) root.selectedIndex = 0
}
} else if (root.focusSection === "speed") {
if (dy < 0) {
root.focusSection = "dns"
} else if (root.wifiNetworks.length > 0) { } else if (root.wifiNetworks.length > 0) {
root.focusSection = "wifi" root.focusSection = "wifi"
if (root.selectedIndex < 0) root.selectedIndex = 0 if (root.selectedIndex < 0) root.selectedIndex = 0
} }
} else { // wifi } else { // wifi
// k from the top row escapes back up into the speed test's Run // k from the top row escapes back up to the DNS row rather than
// button, or DNS when there is no speed test, rather than wrapping // wrapping around to the bottom of the list.
// around to the bottom of the list.
if (dy < 0 && root.selectedIndex <= 0) { if (dy < 0 && root.selectedIndex <= 0) {
root.focusSection = root.canRunSpeedTest ? "speed" : "dns" root.focusSection = "dns"
root.wifiActionFocused = false root.wifiActionFocused = false
} }
else root.selectByDelta(dy) else root.selectByDelta(dy)
@@ -1170,7 +1245,6 @@ Panel {
if (root.focusSection === "header") root.activateHeader() if (root.focusSection === "header") root.activateHeader()
else if (root.focusSection === "band") root.activateBand() else if (root.focusSection === "band") root.activateBand()
else if (root.focusSection === "dns") root.activateDns() else if (root.focusSection === "dns") root.activateDns()
else if (root.focusSection === "speed") root.runSpeedTest()
else root.activateSelected() else root.activateSelected()
} }
} }
@@ -1229,6 +1303,22 @@ Panel {
onClicked: root.showWifiQr() onClicked: root.showWifiQr()
} }
Button {
id: speedAction
visible: root.canRunSpeedTest
iconText: "󰓅"
tooltipText: "Run a speed test"
foreground: root.bar.foreground
fontFamily: root.bar.fontFamily
iconSize: Style.font.subtitle * 1.5
horizontalPadding: Style.space(5)
verticalPadding: Style.space(2)
hasCursor: root.speedHeaderHasCursor
Layout.alignment: Qt.AlignVCenter
onHovered: function(on) { if (on) root.setHeaderCursor(root.speedHeaderIndex) }
onClicked: root.showSpeedTest()
}
ToggleSwitch { ToggleSwitch {
id: powerSwitch id: powerSwitch
visible: root.canToggleWifi visible: root.canToggleWifi
@@ -1543,84 +1633,6 @@ Panel {
} }
PanelSeparator {
visible: !!root.info.iface
foreground: root.bar.foreground
}
Column {
visible: !!root.info.iface
width: parent.width
spacing: Style.space(12)
Column {
width: parent.width
spacing: Style.space(8)
Item {
width: parent.width
implicitHeight: Math.max(speedTestHeader.implicitHeight, speedRunButton.implicitHeight)
PanelSectionHeader {
id: speedTestHeader
text: "SPEED TEST"
foreground: root.bar.foreground
fontFamily: root.bar.fontFamily
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
}
// Scaled to the band section's AUTOMATIC row -- caption type and
// trimmed padding -- so both header lines carry a control of the
// same visual weight instead of this one dominating.
Button {
id: speedRunButton
text: root.speedTestRunning ? "Running..." : "Run"
tooltipText: "Run using fast.com"
enabled: !root.speedTestRunning
foreground: root.bar.foreground
fontFamily: root.bar.fontFamily
fontSize: Style.font.caption
horizontalPadding: Style.space(8)
verticalPadding: Style.space(2)
bordered: true
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
hasCursor: root.cursorActive && root.focusSection === "speed"
onClicked: root.runSpeedTest()
onHovered: function(isHovered) {
if (!isHovered) return
root.cursorActive = true
root.focusSection = "speed"
}
}
}
Row {
id: speedTestValues
visible: root.speedTestHasRun
width: parent.width
spacing: Style.space(20)
readonly property real cellWidth: Math.max(0, (width - spacing * 3) / 4)
InfoLabel { width: speedTestValues.cellWidth; text: "Download" }
DetailValue { width: speedTestValues.cellWidth; text: root.formatSpeedMbps(root.speedTestDownloadMbps) }
InfoLabel { width: speedTestValues.cellWidth; text: "Upload" }
DetailValue { width: speedTestValues.cellWidth; text: root.formatSpeedMbps(root.speedTestUploadMbps) }
}
InfoValue {
visible: root.speedTestError !== ""
text: root.speedTestError
color: root.bar.urgent
width: parent.width
elide: Text.ElideRight
}
}
}
// Wi-Fi networks (only if a Wi-Fi station is available). // Wi-Fi networks (only if a Wi-Fi station is available).
PanelSeparator { PanelSeparator {
visible: root.wifiStationAvailable visible: root.wifiStationAvailable
@@ -1708,6 +1720,24 @@ Panel {
onPasswordToggleRequested: root.toggleQrPassword() onPasswordToggleRequested: root.toggleQrPassword()
} }
SpeedTestPanel {
anchorItem: button
bar: root.bar
running: root.speedTestRunning
phase: root.speedTestPhase
downloadMbps: root.speedTestDownloadMbps
uploadMbps: root.speedTestUploadMbps
error: root.speedTestError
connectionName: {
if (root.info.type === "wifi") return root.info.ssid || "Wi-Fi"
if (root.info.type === "ethernet") return "Ethernet"
return ""
}
open: root.speedTestModalOpen
onCloseRequested: root.hideSpeedTest()
onRunAgainRequested: root.runSpeedTest()
}
// One Wi-Fi band pill. `active` (fill) is the band actually in use and // One Wi-Fi band pill. `active` (fill) is the band actually in use and
// `selected` (bold) is the pinned choice; with Automatic on nothing is // `selected` (bold) is the pinned choice; with Automatic on nothing is
// pinned, so only the live band lights up and the two can no longer read as // pinned, so only the live band lights up and the two can no longer read as
@@ -0,0 +1,392 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Shapes
import Quickshell
import Quickshell.Wayland
import qs.Commons
import qs.Ui
// Centered speed test overlay. No card: like the Tucson's floating cluster,
// the two dials (download left, upload right) sit directly on a darkened
// scrim -- open 270° arcs, faint tick rings, hubless gradient needles, and a
// digital readout in the middle. Esc, the scrim, or the corner dismiss close
// it; the needles sweep to full scale and back on open, then track the live
// readings.
PanelWindow {
id: root
required property Item anchorItem
required property QtObject bar
required property bool running
required property string phase // "down" | "up" | ""
required property string downloadMbps
required property string uploadMbps
required property string error
required property string connectionName
property bool open: false
signal closeRequested()
signal runAgainRequested()
readonly property real downloadValue: toMbps(downloadMbps)
readonly property real uploadValue: toMbps(uploadMbps)
readonly property bool failed: error !== ""
readonly property bool finished: !running && !failed && (downloadValue > 0 || uploadValue > 0)
function toMbps(raw) {
var value = parseFloat(raw)
return isFinite(value) && value > 0 ? value : 0
}
visible: open
// The window is instantiated hidden, so re-acquire focus after mapping and
// fire the ignition sweep once the surface is actually on screen.
onOpenChanged: {
if (open) Qt.callLater(function() {
if (!root.open) return
keyCatcher.forceActiveFocus()
downDial.ignite()
upDial.ignite()
})
}
screen: anchorItem.QsWindow.window ? anchorItem.QsWindow.window.screen : null
anchors { top: true; bottom: true; left: true; right: true }
color: "transparent"
exclusionMode: ExclusionMode.Ignore
WlrLayershell.namespace: "omarchy-network-speedtest"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
// Deep scrim: with no card behind them, the floating dials need the
// backdrop to carry the contrast on any wallpaper, like the near-black
// panel behind a real cluster.
Rectangle {
anchors.fill: parent
color: Qt.rgba(0, 0, 0, 0.78)
MouseArea {
anchors.fill: parent
onClicked: root.closeRequested()
}
}
Item {
id: keyCatcher
anchors.fill: parent
focus: true
Keys.onEscapePressed: root.closeRequested()
Keys.onReturnPressed: if (!root.running) root.runAgainRequested()
Keys.onEnterPressed: if (!root.running) root.runAgainRequested()
Item {
id: cluster
anchors.centerIn: parent
width: content.implicitWidth
height: content.implicitHeight
// Narrow or heavily scaled outputs: shrink the whole cluster rather
// than clipping it at the screen edge.
scale: Math.min(1,
(keyCatcher.width - Style.space(32)) / Math.max(1, width),
(keyCatcher.height - Style.space(32)) / Math.max(1, height))
// Swallow clicks so only the scrim outside the cluster dismisses.
MouseArea { anchors.fill: parent; onClicked: {} }
ColumnLayout {
id: content
anchors.fill: parent
spacing: Style.space(16)
Text {
visible: root.connectionName !== ""
text: root.connectionName.toUpperCase()
color: Qt.darker(root.bar.foreground, 1.4)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
font.letterSpacing: 2
Layout.fillWidth: true
horizontalAlignment: Text.AlignHCenter
}
Row {
spacing: Style.space(48)
Layout.alignment: Qt.AlignHCenter
SpeedDial {
id: downDial
label: "DOWNLOAD"
value: root.downloadValue
live: root.running && root.phase === "down"
}
SpeedDial {
id: upDial
label: "UPLOAD"
value: root.uploadValue
live: root.running && root.phase === "up"
}
}
// Centered on the dial pair. Fades rather than unmounts while a run
// is in flight, so the cluster never shifts.
Button {
text: "Run Again"
tooltipText: "Measure again via fast.com"
bordered: true
enabled: !root.running
opacity: root.running ? 0 : 1
foreground: root.bar.foreground
fontFamily: root.bar.fontFamily
fontSize: Style.font.bodySmall
horizontalPadding: Style.space(14)
verticalPadding: Style.space(4)
Layout.alignment: Qt.AlignHCenter
onClicked: root.runAgainRequested()
Behavior on opacity {
NumberAnimation { duration: 240; easing.type: Easing.OutCubic }
}
}
Text {
visible: root.failed
text: root.error
color: root.bar.urgent
font.family: root.bar.fontFamily
font.pixelSize: Style.font.bodySmall
wrapMode: Text.Wrap
Layout.fillWidth: true
Layout.maximumWidth: Style.space(440)
horizontalAlignment: Text.AlignHCenter
}
}
}
}
// One floating cluster dial: an open 270° scale with the gap at the
// bottom, a faint tick ring, a glowing accent value arc, a hubless needle
// that fades toward the pivot, and a digital readout in the middle. All
// writes to the needle funnel through `shown` so the ignition sweep and
// live readings share one animation.
component SpeedDial: Item {
id: dial
required property string label
required property real value
required property bool live
readonly property real diameter: Style.space(210)
// 0° = 3 o'clock, increasing clockwise (PathAngleArc's convention).
readonly property real dialStart: 135
readonly property real dialSweep: 270
readonly property int tickCount: 46
readonly property real arcWidth: Style.space(4)
readonly property real arcRadius: diameter / 2 - arcWidth
readonly property color trackColor: Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.14)
readonly property color minorTickColor: Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.12)
readonly property color majorTickColor: Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.3)
// The dial that isn't measuring yet sits dimmed until it gets a figure.
readonly property bool engaged: live || value > 0
property real shown: 0
// The digital readout stays on the real figure while the ignition sweep
// drives the needle -- a cluster sweeps its gauges, not its numerals.
readonly property real reading: ignition.running ? value : shown
property real fullScale: 100
readonly property var scaleStops: [100, 250, 500, 1000, 2500, 5000, 10000]
readonly property real fraction: fullScale > 0 ? Math.max(0, Math.min(1, shown / fullScale)) : 0
readonly property bool arcVisible: fraction > 0.004
width: diameter
height: diameter
opacity: engaged ? 1 : 0.5
Behavior on opacity {
NumberAnimation { duration: 240; easing.type: Easing.OutCubic }
}
// Live readings land once a second; glide between them rather than snap.
Behavior on shown {
enabled: !ignition.running
NumberAnimation { duration: 600; easing.type: Easing.OutCubic }
}
Behavior on fullScale {
enabled: !ignition.running
NumberAnimation { duration: 400; easing.type: Easing.OutCubic }
}
// A fresh measurement re-ranges from the base scale. Without this, one
// unusually fast run would compress every later one for the lifetime of
// the shell process.
onLiveChanged: {
if (live) fullScale = scaleStops[0]
}
onValueChanged: {
// Latch the scale upward to the next stop when a reading approaches the
// rim. Never shrink mid-run; a fresh run just re-sweeps from zero.
for (var i = 0; i < scaleStops.length; i++) {
if (value <= scaleStops[i] * 0.92) {
if (scaleStops[i] > fullScale) fullScale = scaleStops[i]
break
}
if (i === scaleStops.length - 1) fullScale = scaleStops[i]
}
if (!ignition.running) shown = value
}
function ignite() {
ignition.restart()
}
// Car-cluster power-on: needle sweeps to full scale and falls back before
// the live figures take over.
SequentialAnimation {
id: ignition
NumberAnimation { target: dial; property: "shown"; to: dial.fullScale; duration: 550; easing.type: Easing.InOutCubic }
NumberAnimation { target: dial; property: "shown"; to: 0; duration: 650; easing.type: Easing.OutCubic }
onFinished: dial.shown = dial.value
}
Shape {
anchors.fill: parent
preferredRendererType: Shape.CurveRenderer
// Track: the full scale, always visible, dim.
ShapePath {
strokeWidth: dial.arcWidth
strokeColor: dial.trackColor
fillColor: "transparent"
capStyle: ShapePath.RoundCap
PathAngleArc {
centerX: dial.width / 2
centerY: dial.height / 2
radiusX: dial.arcRadius
radiusY: dial.arcRadius
startAngle: dial.dialStart
sweepAngle: dial.dialSweep
}
}
// Soft under-glow beneath the value arc, standing in for the backlit
// ring of a real cluster. Both arcs go transparent at rest, or their
// round caps would leave a stray dot at the foot of the scale.
ShapePath {
strokeWidth: dial.arcWidth * 3
strokeColor: dial.arcVisible ? Qt.rgba(Color.accent.r, Color.accent.g, Color.accent.b, 0.18) : "transparent"
fillColor: "transparent"
capStyle: ShapePath.RoundCap
PathAngleArc {
centerX: dial.width / 2
centerY: dial.height / 2
radiusX: dial.arcRadius
radiusY: dial.arcRadius
startAngle: dial.dialStart
sweepAngle: dial.dialSweep * dial.fraction
}
}
// Value: fills behind the needle.
ShapePath {
strokeWidth: dial.arcWidth
strokeColor: dial.arcVisible ? Color.accent : "transparent"
fillColor: "transparent"
capStyle: ShapePath.RoundCap
PathAngleArc {
centerX: dial.width / 2
centerY: dial.height / 2
radiusX: dial.arcRadius
radiusY: dial.arcRadius
startAngle: dial.dialStart
sweepAngle: dial.dialSweep * dial.fraction
}
}
}
// Faint tick ring just inside the arc; every fifth tick is a major.
Repeater {
model: dial.tickCount
Item {
required property int index
readonly property bool major: index % 5 === 0
anchors.fill: parent
rotation: dial.dialStart + (index / (dial.tickCount - 1)) * dial.dialSweep - 270
Rectangle {
anchors.horizontalCenter: parent.horizontalCenter
y: dial.arcWidth * 2 + (parent.major ? 0 : Style.space(2))
width: parent.major ? Math.max(2, Style.space(2)) : 1
height: parent.major ? Style.space(10) : Style.space(6)
radius: width / 2
color: parent.major ? dial.majorTickColor : dial.minorTickColor
}
}
}
// Hubless needle: a slender sliver that fades out toward the pivot, so
// it reads as floating like the rest of the cluster.
Item {
anchors.fill: parent
rotation: dial.dialStart + dial.fraction * dial.dialSweep - 270
Rectangle {
anchors.horizontalCenter: parent.horizontalCenter
y: dial.arcWidth * 2 + Style.space(10)
width: Math.max(2, Style.space(3))
height: dial.diameter * 0.32
radius: width / 2
gradient: Gradient {
GradientStop { position: 0.0; color: Color.accent }
GradientStop { position: 0.55; color: Color.accent }
GradientStop { position: 1.0; color: "transparent" }
}
}
}
Column {
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.verticalCenter
anchors.topMargin: Style.space(14)
spacing: 0
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: dial.reading < 10 ? dial.reading.toFixed(1) : Math.round(dial.reading).toString()
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.display
font.bold: true
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: "Mbps"
color: Qt.darker(root.bar.foreground, 1.4)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.caption
}
}
// The 90° gap at the bottom of the scale is where a cluster prints its
// unit; here it names the direction.
Text {
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
text: dial.label
color: Qt.darker(root.bar.foreground, 1.3)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
font.letterSpacing: 1.5
}
}
}
+43 -23
View File
@@ -5,6 +5,8 @@ import Quickshell.Wayland
import qs.Commons import qs.Commons
import qs.Ui import qs.Ui
// Centered Wi-Fi share overlay, presented like the speed test: no card,
// just the QR code floating on a heavy scrim. Esc or the scrim dismiss it.
PanelWindow { PanelWindow {
id: root id: root
@@ -43,9 +45,11 @@ PanelWindow {
WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
// Deep scrim: the floating code needs the backdrop to carry the contrast
// on any wallpaper.
Rectangle { Rectangle {
anchors.fill: parent anchors.fill: parent
color: Qt.rgba(0, 0, 0, 0.45) color: Qt.rgba(0, 0, 0, 0.78)
MouseArea { MouseArea {
anchors.fill: parent anchors.fill: parent
@@ -53,42 +57,49 @@ PanelWindow {
} }
} }
BorderSurface { Item {
width: Style.space(320) id: keyCatcher
height: content.implicitHeight + Style.space(48) anchors.fill: parent
anchors.centerIn: parent focus: true
radius: Style.cornerRadius
color: Color.menu.background
borderSpec: Border.surfaceSpec("menu", "border", Color.popups.border, Math.max(1, Style.space(2)))
MouseArea { anchors.fill: parent; onClicked: {} } Keys.onEscapePressed: root.closeRequested()
Item { Item {
id: keyCatcher anchors.centerIn: parent
anchors.fill: parent width: content.implicitWidth
anchors.margins: Style.space(24) height: content.implicitHeight
focus: true // Narrow or heavily scaled outputs: shrink the whole card rather than
// clipping it at the screen edge.
scale: Math.min(1,
(keyCatcher.width - Style.space(32)) / Math.max(1, width),
(keyCatcher.height - Style.space(32)) / Math.max(1, height))
Keys.onEscapePressed: root.closeRequested() // Swallow clicks so only the scrim outside the content dismisses.
MouseArea { anchors.fill: parent; onClicked: {} }
ColumnLayout { ColumnLayout {
id: content id: content
anchors.fill: parent anchors.fill: parent
spacing: Style.space(12) spacing: Style.space(16)
Text { Text {
text: "Share " + (root.ssid || "Wi-Fi") text: (root.ssid || "Wi-Fi").toUpperCase()
color: root.bar.foreground color: Qt.darker(root.bar.foreground, 1.4)
font.family: root.bar.fontFamily font.family: root.bar.fontFamily
font.pixelSize: Style.font.title font.pixelSize: Style.font.caption
font.bold: true font.bold: true
font.letterSpacing: 2
elide: Text.ElideRight elide: Text.ElideRight
Layout.fillWidth: true Layout.maximumWidth: Style.space(320)
Layout.alignment: Qt.AlignHCenter
horizontalAlignment: Text.AlignHCenter horizontalAlignment: Text.AlignHCenter
} }
// Render every QR module as an integer-sized native rectangle. This // Render every QR module as an integer-sized native rectangle. This
// stays crisp and avoids temporary images and file-cache races. // stays crisp and avoids temporary images and file-cache races. Only
// the dark modules paint, so the white canvas can keep its rounded
// corners; the spec quiet zone baked into the matrix keeps the code
// itself clear of them.
Rectangle { Rectangle {
id: qrCanvas id: qrCanvas
readonly property int moduleSize: root.qrSize > 0 readonly property int moduleSize: root.qrSize > 0
@@ -99,6 +110,7 @@ PanelWindow {
width: root.qrSize * moduleSize width: root.qrSize * moduleSize
height: width height: width
color: "white" color: "white"
radius: Style.cornerRadius
Layout.alignment: Qt.AlignHCenter Layout.alignment: Qt.AlignHCenter
Grid { Grid {
@@ -115,7 +127,7 @@ PanelWindow {
width: qrCanvas.moduleSize width: qrCanvas.moduleSize
height: qrCanvas.moduleSize height: qrCanvas.moduleSize
color: root.qrRows[matrixRow].charAt(matrixColumn) === "1" ? "#111111" : "white" color: root.qrRows[matrixRow].charAt(matrixColumn) === "1" ? "#111111" : "transparent"
} }
} }
} }
@@ -124,7 +136,9 @@ PanelWindow {
Text { Text {
visible: root.loading visible: root.loading
text: "Generating QR code…" text: "Generating QR code…"
color: root.bar.foreground color: Qt.darker(root.bar.foreground, 1.3)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.bodySmall
Layout.fillWidth: true Layout.fillWidth: true
horizontalAlignment: Text.AlignHCenter horizontalAlignment: Text.AlignHCenter
} }
@@ -133,15 +147,20 @@ PanelWindow {
visible: root.error !== "" visible: root.error !== ""
text: root.error text: root.error
color: root.bar.urgent color: root.bar.urgent
font.family: root.bar.fontFamily
font.pixelSize: Style.font.bodySmall
wrapMode: Text.Wrap wrapMode: Text.Wrap
Layout.fillWidth: true Layout.fillWidth: true
Layout.maximumWidth: Style.space(320)
horizontalAlignment: Text.AlignHCenter horizontalAlignment: Text.AlignHCenter
} }
Text { Text {
visible: root.showingQr visible: root.showingQr
text: "Scan to join this network" text: "Scan to join this network"
color: root.bar.foreground color: Qt.darker(root.bar.foreground, 1.3)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.bodySmall
Layout.fillWidth: true Layout.fillWidth: true
horizontalAlignment: Text.AlignHCenter horizontalAlignment: Text.AlignHCenter
} }
@@ -157,6 +176,7 @@ PanelWindow {
font.pixelSize: Style.font.bodySmall font.pixelSize: Style.font.bodySmall
wrapMode: Text.WrapAnywhere wrapMode: Text.WrapAnywhere
Layout.fillWidth: true Layout.fillWidth: true
Layout.maximumWidth: Style.space(320)
horizontalAlignment: Text.AlignHCenter horizontalAlignment: Text.AlignHCenter
MouseArea { MouseArea {
+2 -2
View File
@@ -4,7 +4,7 @@
"name": "Network", "name": "Network",
"version": "1.0.0", "version": "1.0.0",
"author": "Omarchy", "author": "Omarchy",
"description": "Wi-Fi list, connection state, and QR sharing", "description": "Wi-Fi list, connection state, QR sharing, and speed test",
"kinds": [ "kinds": [
"bar-widget" "bar-widget"
], ],
@@ -13,7 +13,7 @@
}, },
"barWidget": { "barWidget": {
"displayName": "Network", "displayName": "Network",
"description": "Wi-Fi list, connection state, and QR sharing", "description": "Wi-Fi list, connection state, QR sharing, and speed test",
"category": "Network", "category": "Network",
"allowMultiple": false "allowMultiple": false
} }
+30 -2
View File
@@ -87,6 +87,7 @@ Panel {
onLocationQueryChanged: { onLocationQueryChanged: {
if (savingLocation) savingLocationQueryStarted = true if (savingLocation) savingLocationQueryStarted = true
forecastRetries = 0 forecastRetries = 0
dailyForecastRetries = 0
forecastProc.running = false forecastProc.running = false
dailyForecastProc.running = false dailyForecastProc.running = false
Qt.callLater(refresh) Qt.callLater(refresh)
@@ -112,6 +113,7 @@ Panel {
} }
property int forecastRetries: 0 property int forecastRetries: 0
property int dailyForecastRetries: 0
// Click-to-edit state for the location label. // Click-to-edit state for the location label.
property bool editingLocation: false property bool editingLocation: false
@@ -148,6 +150,11 @@ Panel {
readonly property string reportHumidity: current ? (current.humidity + "%") : "" readonly property string reportHumidity: current ? (current.humidity + "%") : ""
function refresh() { function refresh() {
// Each full refresh cycle gets a fresh retry budget, so an earlier
// exhausted round (e.g. waking with the network still down) doesn't
// starve retries for the rest of the session.
forecastRetries = 0
dailyForecastRetries = 0
if (!forecastProc.running) forecastProc.running = true if (!forecastProc.running) forecastProc.running = true
if (root.locationQuery === "" && !locationProc.running) locationProc.running = true if (root.locationQuery === "" && !locationProc.running) locationProc.running = true
// With stored coordinates this fetches open-meteo right away — no need // With stored coordinates this fetches open-meteo right away — no need
@@ -368,22 +375,42 @@ Panel {
onTriggered: if (!forecastProc.running) forecastProc.running = true onTriggered: if (!forecastProc.running) forecastProc.running = true
} }
// With configured coordinates this fetch is the only thing that updates the
// bar icon, so a dropped response (e.g. waking before the network is back)
// must retry rather than wait out the refresh timer with a stale icon.
function scheduleDailyForecastRetry() {
if (dailyForecastRetries >= 3) return
dailyForecastRetries++
dailyForecastRetryTimer.restart()
}
Timer {
id: dailyForecastRetryTimer
interval: 2500
onTriggered: root.refreshDailyForecast(null)
}
Process { Process {
id: dailyForecastProc id: dailyForecastProc
stdout: StdioCollector { stdout: StdioCollector {
waitForEnd: true waitForEnd: true
onStreamFinished: { onStreamFinished: {
var raw = String(text || "").trim() var raw = String(text || "").trim()
if (!raw) return if (!raw) {
root.scheduleDailyForecastRetry()
return
}
try { try {
var parsed = JSON.parse(raw) var parsed = JSON.parse(raw)
var parsedCurrent = Model.openMeteoCurrentCondition(parsed) var parsedCurrent = Model.openMeteoCurrentCondition(parsed)
root.dailyForecastReport = parsed root.dailyForecastReport = parsed
root.label = Model.currentIcon(parsedCurrent, root.label) root.label = Model.currentIcon(parsedCurrent, root.label)
root.dailyForecastRetries = 0
if (Model.weatherResponseCompletesSave(root.hasConfiguredCoordinates, "open-meteo")) if (Model.weatherResponseCompletesSave(root.hasConfiguredCoordinates, "open-meteo"))
root.finishSavingLocation() root.finishSavingLocation()
} catch (e) { } catch (e) {
// Keep last-good daily forecast on parse failure. // Keep last-good daily forecast visible, but try again shortly.
root.scheduleDailyForecastRetry()
} }
} }
} }
@@ -418,6 +445,7 @@ Panel {
if (!root.savingLocationQueryStarted) { if (!root.savingLocationQueryStarted) {
root.savingLocationQueryStarted = true root.savingLocationQueryStarted = true
root.forecastRetries = 0 root.forecastRetries = 0
root.dailyForecastRetries = 0
forecastProc.running = false forecastProc.running = false
dailyForecastProc.running = false dailyForecastProc.running = false
Qt.callLater(root.refresh) Qt.callLater(root.refresh)
+26 -1
View File
@@ -138,7 +138,32 @@ assertDeepEqual(calendar.clockFormatRing('', '', []), ['HH:mm'], 'clock keeps a
assertEqual(calendar.nextClockFormat(ring, ring[0]), ring[1], 'clock steps to the next format') assertEqual(calendar.nextClockFormat(ring, ring[0]), ring[1], 'clock steps to the next format')
assertEqual(calendar.nextClockFormat(ring, ring[ring.length - 1]), ring[0], 'clock wraps the format ring') assertEqual(calendar.nextClockFormat(ring, ring[ring.length - 1]), ring[0], 'clock wraps the format ring')
assertEqual(calendar.nextClockFormat(ring, 'HH:mm:ss'), ring[0], 'clock starts at the top from a format outside the ring') assertEqual(calendar.nextClockFormat(ring, 'HH:mm:ss'), ring[0], 'clock starts at the top from a format outside the ring')
assertEqual(calendar.clockFormats(true)[0], 'HH\n\u2014\nmm', 'clock keeps stacked formats for vertical bars') // Both rings, contents and order: a right click walks this list and writes
// the result back to shell.json, so an inserted preset should have to be
// acknowledged here.
assertDeepEqual(
calendar.clockFormats(false),
[
'dddd HH:mm', 'dddd h:mm AP',
'HH:mm', 'h:mm AP',
'ddd d MMM HH:mm', 'ddd d MMM h:mm AP',
"d MMMM 'W'ww yyyy",
// No twin: ISO 8601 writes time on a 24-hour clock, so an AM/PM variant
// would contradict the one thing that format is for.
'yyyy-MM-dd HH:mm'
],
'clock offers the horizontal presets in this order'
)
assertDeepEqual(
calendar.clockFormats(true),
['HH\n\u2014\nmm', 'h\n\u2014\nmm\nAP', "dd\nMMM\n'W'ww\n''yy", 'HH\nmm'],
'clock offers the stacked presets in this order'
)
assertEqual(
calendar.nextClockFormat(ring, 'dddd HH:mm'),
'dddd h:mm AP',
'clock reaches an AM/PM twin in one right click'
)
assertEqual(calendar.isoWeekLiteral(2026, 0, 5), '02', 'clock zero-pads the ISO week token') assertEqual(calendar.isoWeekLiteral(2026, 0, 5), '02', 'clock zero-pads the ISO week token')
// ---- widget wiring // ---- widget wiring
+2 -2
View File
@@ -104,7 +104,7 @@ source "$ROOT/migrations/1785617047.sh" >/dev/null
grep -Fx "$omp_package omp" "$stub_log" >/dev/null || fail "Oh My Pi migration creates a working lazy stub" grep -Fx "$omp_package omp" "$stub_log" >/dev/null || fail "Oh My Pi migration creates a working lazy stub"
: >"$stub_log" : >"$stub_log"
source "$ROOT/migrations/1785633225.sh" >/dev/null source "$ROOT/migrations/1785846769.sh" >/dev/null
grep -Fx "$omp_package omp" "$stub_log" >/dev/null || fail "agent migration repairs the Oh My Pi lazy stub" grep -Fx "$omp_package omp" "$stub_log" >/dev/null || fail "agent migration repairs the Oh My Pi lazy stub"
grep -Fx "$grok_package grok" "$stub_log" >/dev/null || fail "agent migration creates the Grok lazy stub" grep -Fx "$grok_package grok" "$stub_log" >/dev/null || fail "agent migration creates the Grok lazy stub"
grep -Fx "$crush_package" "$stub_log" >/dev/null || fail "agent migration creates the Crush lazy stub" grep -Fx "$crush_package" "$stub_log" >/dev/null || fail "agent migration creates the Crush lazy stub"
@@ -114,7 +114,7 @@ touch "$test_home/.local/state/omarchy/preinstalls-removed"
"$ROOT/bin/omarchy-mise-install" oh-my-pi omp "$ROOT/bin/omarchy-mise-install" oh-my-pi omp
: >"$stub_log" : >"$stub_log"
source "$ROOT/migrations/1785617047.sh" >/dev/null source "$ROOT/migrations/1785617047.sh" >/dev/null
source "$ROOT/migrations/1785633225.sh" >/dev/null source "$ROOT/migrations/1785846769.sh" >/dev/null
[[ ! -s $stub_log ]] || fail "agent migrations respect the preinstall opt-out" [[ ! -s $stub_log ]] || fail "agent migrations respect the preinstall opt-out"
[[ ! -e $test_home/.local/bin/omp ]] || fail "agent migration removes the obsolete Oh My Pi wrapper after opt-out" [[ ! -e $test_home/.local/bin/omp ]] || fail "agent migration removes the obsolete Oh My Pi wrapper after opt-out"
rm "$test_home/.local/state/omarchy/preinstalls-removed" rm "$test_home/.local/state/omarchy/preinstalls-removed"
+12 -2
View File
@@ -14,7 +14,9 @@ fake_hyprctl="$test_tmp/hyprctl"
signature="test-signature" signature="test-signature"
runtime_dir="$run_root/1000" runtime_dir="$run_root/1000"
mkdir -p "$runtime_dir/hypr/$signature" dead_signature="dead-signature"
mkdir -p "$runtime_dir/hypr/$signature" "$runtime_dir/hypr/$dead_signature"
cat >"$fake_hyprctl" <<'BASH' cat >"$fake_hyprctl" <<'BASH'
#!/bin/bash #!/bin/bash
@@ -22,6 +24,10 @@ cat >"$fake_hyprctl" <<'BASH'
printf '%s\t%s\n' "$XDG_RUNTIME_DIR" "$*" >>"$FAKE_HYPRCTL_LOG" printf '%s\t%s\n' "$XDG_RUNTIME_DIR" "$*" >>"$FAKE_HYPRCTL_LOG"
case "$*" in case "$*" in
*'--instance dead-signature '*)
printf "Couldn't connect to %s/hypr/dead-signature/.socket.sock. (4)\n" "$XDG_RUNTIME_DIR"
exit 4
;;
*'getoption misc.disable_autoreload'*) *'getoption misc.disable_autoreload'*)
printf '{"option":"misc.disable_autoreload","bool":%s,"set":true}\n' "${FAKE_DISABLE_AUTORELOAD:-false}" printf '{"option":"misc.disable_autoreload","bool":%s,"set":true}\n' "${FAKE_DISABLE_AUTORELOAD:-false}"
;; ;;
@@ -39,7 +45,7 @@ FAKE_HYPRCTL_LOG="$hyprctl_log" \
HYPRCTL="$fake_hyprctl" \ HYPRCTL="$fake_hyprctl" \
OMARCHY_HYPRLAND_RELOAD_GUARD_RUN_ROOT="$run_root" \ OMARCHY_HYPRLAND_RELOAD_GUARD_RUN_ROOT="$run_root" \
OMARCHY_HYPRLAND_RELOAD_GUARD_STATE_DIR="$state_dir" \ OMARCHY_HYPRLAND_RELOAD_GUARD_STATE_DIR="$state_dir" \
"$ROOT/bin/omarchy-hyprland-reload-guard" pause "$ROOT/bin/omarchy-hyprland-reload-guard" pause 2>"$test_tmp/pause-stderr"
state_file="$state_dir/$signature" state_file="$state_dir/$signature"
[[ -f $state_file ]] || fail "reload guard stores Hyprland state on pause" [[ -f $state_file ]] || fail "reload guard stores Hyprland state on pause"
@@ -48,6 +54,10 @@ grep -Fx "$expected_state" "$state_file" >/dev/null || fail "reload guard record
grep -F 'hl.config({ misc = { disable_autoreload = true }, debug = { suppress_errors = true } })' "$hyprctl_log" >/dev/null || fail "reload guard pauses autoreload with hyprctl eval" grep -F 'hl.config({ misc = { disable_autoreload = true }, debug = { suppress_errors = true } })' "$hyprctl_log" >/dev/null || fail "reload guard pauses autoreload with hyprctl eval"
pass "reload guard pauses live Hyprland reloads" pass "reload guard pauses live Hyprland reloads"
[[ ! -e $state_dir/$dead_signature ]] || fail "reload guard skips instances hyprctl cannot reach"
[[ ! -s $test_tmp/pause-stderr ]] || fail "reload guard pauses dead Hyprland instances quietly" "$(cat "$test_tmp/pause-stderr")"
pass "reload guard skips dead Hyprland instances quietly"
: >"$hyprctl_log" : >"$hyprctl_log"
FAKE_HYPRCTL_LOG="$hyprctl_log" \ FAKE_HYPRCTL_LOG="$hyprctl_log" \
HYPRCTL="$fake_hyprctl" \ HYPRCTL="$fake_hyprctl" \
+19 -6
View File
@@ -10,7 +10,9 @@ mkdir -p "$tmp/bin"
cat >"$tmp/bin/nmcli" <<'EOF' cat >"$tmp/bin/nmcli" <<'EOF'
#!/bin/bash #!/bin/bash
if [[ $* == *GENERAL.CON-UUID* ]]; then if [[ $* == *"DEVICE,TYPE,STATE"* ]]; then
printf 'eth0:ethernet:connected\nwlan0:wifi:connected\n'
elif [[ $* == *GENERAL.CON-UUID* ]]; then
echo test-uuid echo test-uuid
else else
printf '%s' "$QR_NMCLI_FIELDS" printf '%s' "$QR_NMCLI_FIELDS"
@@ -30,11 +32,12 @@ chmod +x "$tmp/bin/nmcli" "$tmp/bin/qrencode"
run_success_case() { run_success_case() {
local description=$1 fields=$2 expected_payload=$3 local description=$1 fields=$2 expected_payload=$3
shift 3
local expected output payload local expected output payload
export QR_NMCLI_FIELDS=$fields export QR_NMCLI_FIELDS=$fields
export QR_PAYLOAD_FILE="$tmp/payload" export QR_PAYLOAD_FILE="$tmp/payload"
output=$(PATH="$tmp/bin:$PATH" "$ROOT/bin/omarchy-network-qr" wlan0) output=$(PATH="$tmp/bin:$PATH" "$ROOT/bin/omarchy-network-qr" "$@")
expected=$'100\n010\n001' expected=$'100\n010\n001'
[[ $output == "$expected" ]] || fail "$description emits a compact module matrix" "expected: $expected\nactual: $output" [[ $output == "$expected" ]] || fail "$description emits a compact module matrix" "expected: $expected\nactual: $output"
@@ -46,24 +49,34 @@ run_success_case() {
run_success_case \ run_success_case \
"network QR helper escapes WPA credentials through stdin" \ "network QR helper escapes WPA credentials through stdin" \
$'Cafe;Guest\\5G\nwpa-psk\np,a:ss;word\\42\nno\n' \ $'Cafe;Guest\\5G\nwpa-psk\np,a:ss;word\\42\nno\n' \
'WIFI:T:WPA;S:Cafe\;Guest\\5G;P:p\,a\:ss\;word\\42;;' 'WIFI:T:WPA;S:Cafe\;Guest\\5G;P:p\,a\:ss\;word\\42;;' \
wlan0
# With no interface argument the helper finds the connected Wi-Fi device.
run_success_case \
"network QR helper detects the Wi-Fi interface" \
$'Cafe Detected\nwpa-psk\nsecret\nno\n' \
'WIFI:T:WPA;S:Cafe Detected;P:secret;;'
run_success_case \ run_success_case \
"network QR helper supports open networks" \ "network QR helper supports open networks" \
$'Cafe Open\nnone\n\nno\n' \ $'Cafe Open\nnone\n\nno\n' \
'WIFI:T:nopass;S:Cafe Open;P:;;' 'WIFI:T:nopass;S:Cafe Open;P:;;' \
wlan0
run_success_case \ run_success_case \
"network QR helper marks hidden networks" \ "network QR helper marks hidden networks" \
$'Hidden Network\nwpa-psk\nsecret\nyes\n' \ $'Hidden Network\nwpa-psk\nsecret\nyes\n' \
'WIFI:T:WPA;S:Hidden Network;P:secret;H:true;;' 'WIFI:T:WPA;S:Hidden Network;P:secret;H:true;;' \
wlan0
# NetworkManager models WEP as key-mgmt "none" plus a wep-key, which must not # NetworkManager models WEP as key-mgmt "none" plus a wep-key, which must not
# be mistaken for an open network. # be mistaken for an open network.
run_success_case \ run_success_case \
"network QR helper encodes WEP networks" \ "network QR helper encodes WEP networks" \
$'Old Router\nnone\n\nno\nwep-secret\n' \ $'Old Router\nnone\n\nno\nwep-secret\n' \
'WIFI:T:WEP;S:Old Router;P:wep-secret;;' 'WIFI:T:WEP;S:Old Router;P:wep-secret;;' \
wlan0
export QR_NMCLI_FIELDS=$'Enterprise\nwpa-eap\nsecret\nno\n' export QR_NMCLI_FIELDS=$'Enterprise\nwpa-eap\nsecret\nno\n'
export QR_PAYLOAD_FILE="$tmp/enterprise-payload" export QR_PAYLOAD_FILE="$tmp/enterprise-payload"
-1
View File
@@ -85,7 +85,6 @@ assertDeepEqual(
assertEqual(network.formatBytes(1536), '1.5 KB', 'network formats bytes') assertEqual(network.formatBytes(1536), '1.5 KB', 'network formats bytes')
assertEqual(network.formatRate(1536), '1.5 KB/s', 'network formats rates') assertEqual(network.formatRate(1536), '1.5 KB/s', 'network formats rates')
assertEqual(network.formatSpeedMbps('250.4'), '250 Mbps', 'network formats speed test results')
assertEqual(network.formatPingLatency('2.54'), '2.5 ms', 'network formats low ping with precision') assertEqual(network.formatPingLatency('2.54'), '2.5 ms', 'network formats low ping with precision')
assertEqual(network.formatPingLatency('25.4'), '25 ms', 'network formats ping') assertEqual(network.formatPingLatency('25.4'), '25 ms', 'network formats ping')
assertEqual(network.formatPingLatency(''), 'Timeout', 'network formats missing ping as timeout') assertEqual(network.formatPingLatency(''), 'Timeout', 'network formats missing ping as timeout')
@@ -234,13 +234,8 @@ pass "alert removal handles the older indicators key"
fail "alert removal leaves an already-empty list alone" "$(jq -c '.bar.layout.right[1]' "$shell_config")" fail "alert removal leaves an already-empty list alone" "$(jq -c '.bar.layout.right[1]' "$shell_config")"
pass "alert removal leaves an already-empty list alone" pass "alert removal leaves an already-empty list alone"
(($(wc -l <"$SHELL_RESTARTS") == 1)) || fail "alert removal restarts the shell" # The running shell hot-reloads shell.json and the post-update restart is
[[ ! -s $STATE_CALLS ]] || fail "a restarted shell needs no deferred restart" "$(cat "$STATE_CALLS")" # unconditional, so the migration itself never touches the shell.
pass "alert removal restarts the shell" [[ ! -s $SHELL_RESTARTS ]] || fail "alert removal leaves shell restarts to the update" "$(cat "$SHELL_RESTARTS")"
[[ ! -s $STATE_CALLS ]] || fail "alert removal defers no shell restart" "$(cat "$STATE_CALLS")"
# Migrations run from a TTY or over ssh have no shell to restart, and stopping pass "alert removal leaves shell restarts to the update"
# there would strand every migration queued behind this one.
reset_home
SHELL_RESTART_STATUS=1 run_migration
grep -Fxq 'set restart-shell-required' "$STATE_CALLS" || fail "an unavailable shell defers its restart" "$(cat "$STATE_CALLS")"
pass "an unavailable shell defers its restart"