Merge #14433 input methods and remove Rime
The keyboard choice and omarchy-setup-input own Chinese input now. The new passwordless sudo and input tooltips get Chinese text.
This commit is contained in:
commit
fdd457c0fa
86 files changed
+2545
-207
No files matched your search
@@ -99,12 +99,11 @@ Exceptions are allowed for migration and package-helper scripts where the helper
|
||||
# Region
|
||||
|
||||
- `/etc/omarchy/region` holds `cn`; no file means Global. `omarchy-region` prints the region and switches it (`omarchy region cn|global`); the profile lives in `default/regions/cn/`, its contract in `docs/regions.md`, user-facing docs in `manual/52-china.md`.
|
||||
- `install/config/region.sh` is the one system leaf for both the ISO install (the `/etc/omarchy/region` marker the installer derives from the timezone) and the runtime switch (`OMARCHY_REGION`): region file, the profile drop-ins it symlinks, the Rime packages, and the locale. `install/user/region.sh` runs `rime-ice-installer --asset-dir /opt/packages/rime-ice` only in the `iso-chroot` context, where the ISO bundles that data.
|
||||
- `install/config/region.sh` is the one system leaf for both the ISO install (the `/etc/omarchy/region` marker the installer derives from the timezone) and the runtime switch (`OMARCHY_REGION`): region file, the profile drop-ins it symlinks, and the locale. Input methods are not part of a region: the installer's keyboard choice and `omarchy-setup-input` own them (`docs/input-methods.md`).
|
||||
- `omarchy-apply-pacman` renders pacman for every caller: the channel templates, the profile's `pacman/mirrorlist-<channel>.prepend` ahead of the mirrorlist, and its `pacman.conf.append`. Nothing in `omarchy update` or migrations may change the region or `/etc/locale.conf`.
|
||||
- China-only menu rows carry `"when":"[[ \"$(omarchy-region)\" == \"China\" ]]"` on every row, not only the submenu, because search checks each row's own guard.
|
||||
- Chinese menu labels live in `default/omarchy/omarchy-menu.zh-cn.jsonc`, merged when `LANG` starts with `zh_CN`; give a new menu row with translatable words a label there.
|
||||
- Shell text goes through `I18n.tr("English %1", arg)` from `qs.Commons`, with its Chinese in `shell/Commons/I18nZh.js` keyed by the English. Notifications and OSD messages are worded by their sender with `omarchy-text "English %1" "$arg"` (Python: `text()`, QML: `I18n.tr`), which reads the same table; the shell shows them as given, so user text is never translated. `test/shell.d/shell-i18n-test.sh` fails on a missing entry.
|
||||
- `rime-ice-installer` is built from `omarchy-pkgs` with `omarchy-offline.patch` until it ships in the Omarchy repo.
|
||||
|
||||
# Config Structure
|
||||
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Name=Disk Usage
|
||||
Exec=xdg-terminal-exec --app-id=TUI.float -e bash -c "dua i /"
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Icon=disk-usage
|
||||
StartupNotify=true
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Match the Dell XPS 13 DX13260 with an Intel Panther Lake GPU.
|
||||
|
||||
omarchy-hw-match "DX13260" && omarchy-hw-intel-ptl
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Configure or inspect the input method selected at installation
|
||||
# omarchy:args=<status|configure [--seed|--defaults] [none|mozc|hangul|pinyin|chewing]>
|
||||
# omarchy:hidden=true
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
python "$OMARCHY_PATH/default/input-methods/configure.py" "$@"
|
||||
@@ -332,6 +332,7 @@ dynamic_bindings() {
|
||||
static_bindings() {
|
||||
echo "SHIFT ALT,L,Copy URL from Web App,sendshortcut,SHIFT ALT,L,"
|
||||
echo "SHIFT ALT,D,Download Video from Web App,sendshortcut,SHIFT ALT,D,"
|
||||
echo "CTRL SHIFT,SPACE,Switch input language,exec,fcitx5-remote -t"
|
||||
}
|
||||
|
||||
# Actions Omarchy binds to a second chord meant as an alternative, named one at
|
||||
@@ -533,7 +534,7 @@ output_binding_records_uncached() {
|
||||
|
||||
keybindings_cache_key() {
|
||||
{
|
||||
printf 'v14\n'
|
||||
printf 'v15\n'
|
||||
hyprctl devices 2>/dev/null | grep -F 'active keymap:'
|
||||
hyprctl binds 2>/dev/null
|
||||
} | sha256sum | awk '{ print $1 }'
|
||||
|
||||
@@ -599,6 +599,7 @@ keyboard_form() {
|
||||
done
|
||||
|
||||
apply_keyboard "$keyboard"
|
||||
omarchy_persist_input_selection
|
||||
}
|
||||
|
||||
confirm_reboot() {
|
||||
|
||||
+1
-6
@@ -33,7 +33,7 @@ if [[ $channel != "stable" && $channel != "rc" && $channel != "edge" ]]; then
|
||||
fi
|
||||
|
||||
if [[ $1 == "cn" ]]; then
|
||||
# Packages first against current mirrors, so a failed install leaves the machine Global.
|
||||
# Upgrade first: the keyring install below syncs new databases.
|
||||
omarchy-update-pacman -Syu --noconfirm
|
||||
sudo env OMARCHY_REGION=cn OMARCHY_PATH="$OMARCHY_PATH" bash -e "$OMARCHY_PATH/install/config/region.sh"
|
||||
# Trust ArchLinuxCN before the refresh upgrades through it.
|
||||
@@ -44,11 +44,6 @@ if [[ $1 == "cn" ]]; then
|
||||
sudo pacman --config "$rendered/pacman.conf" -Sy --noconfirm --needed "${profile_packages[@]}"
|
||||
sudo pacman-key --populate archlinuxcn
|
||||
omarchy-refresh-pacman "$channel"
|
||||
if [[ ! -f $HOME/.local/share/fcitx5/rime/rime_ice.schema.yaml ]]; then
|
||||
# A running fcitx5 only sees the new Rime addon after a restart.
|
||||
systemctl --user try-restart omarchy-fcitx5.service
|
||||
rime-ice-installer --yes
|
||||
fi
|
||||
else
|
||||
sudo env OMARCHY_REGION=global OMARCHY_PATH="$OMARCHY_PATH" bash -e "$OMARCHY_PATH/install/config/region.sh"
|
||||
omarchy-refresh-pacman "$channel"
|
||||
|
||||
@@ -2,9 +2,30 @@
|
||||
|
||||
# omarchy:summary=Restart the XCompose input method service (fcitx5) to apply new compose key settings.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# A fcitx5 running outside the unit owns org.fcitx.Fcitx5, so the unit's
|
||||
# instance would exit on arrival and the stale process would keep serving the
|
||||
# old ~/.XCompose -- a restart that reports success and changes nothing.
|
||||
systemctl --user stop omarchy-fcitx5.service
|
||||
pkill -x fcitx5 >/dev/null 2>&1 || true
|
||||
pkill -u "$UID" -x fcitx5 >/dev/null 2>&1 || true
|
||||
for attempt in {1..50}; do
|
||||
if ! pgrep -u "$UID" -x fcitx5 >/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
if pgrep -u "$UID" -x fcitx5 >/dev/null; then
|
||||
# An activation concurrent with stop may have restarted the unit.
|
||||
systemctl --user stop omarchy-fcitx5.service
|
||||
pkill -u "$UID" -x fcitx5 >/dev/null 2>&1 || true
|
||||
for attempt in {1..50}; do
|
||||
if ! pgrep -u "$UID" -x fcitx5 >/dev/null; then break; fi
|
||||
sleep 0.1
|
||||
done
|
||||
fi
|
||||
if pgrep -u "$UID" -x fcitx5 >/dev/null; then
|
||||
echo "Fcitx did not stop; input service was not restarted." >&2
|
||||
exit 1
|
||||
fi
|
||||
systemctl --user start omarchy-fcitx5.service
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Set up Japanese, Korean, or Chinese typing
|
||||
# omarchy:args=[mozc|hangul|pinyin|chewing]
|
||||
# omarchy:examples=omarchy setup input | omarchy setup input mozc
|
||||
# omarchy:requires-sudo=true
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if (( $# > 1 )); then
|
||||
echo "Usage: omarchy setup input [mozc|hangul|pinyin|chewing]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
method=${1:-}
|
||||
if [[ -z $method ]]; then
|
||||
choice=$(jq -r 'to_entries[] | select(.key != "none") | .value.label' "$OMARCHY_PATH/default/input-methods/presets.json" |
|
||||
gum choose --header "Select input language")
|
||||
method=$(jq -r --arg label "$choice" 'to_entries[] | select(.value.label == $label) | .key' "$OMARCHY_PATH/default/input-methods/presets.json")
|
||||
fi
|
||||
|
||||
case "$method" in
|
||||
mozc | hangul | pinyin | chewing) ;;
|
||||
*) echo "Unknown input method: $method" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
mapfile -t packages < <(jq -r --arg method "$method" '.[$method].packages[]' "$OMARCHY_PATH/default/input-methods/presets.json")
|
||||
omarchy-pkg-add "${packages[@]}"
|
||||
omarchy-input-method configure "$method"
|
||||
echo "Input method added. The default switch is Ctrl+Shift+Space or the Japanese/Korean input key; existing custom keys are preserved."
|
||||
+194
-47
@@ -1,7 +1,7 @@
|
||||
#!/bin/bash -p
|
||||
|
||||
# omarchy:summary=Toggle passwordless sudo for the current user.
|
||||
# omarchy:args=[MINUTES]
|
||||
# omarchy:args=[MINUTES|permanent|--active|--disable]
|
||||
# omarchy:requires-sudo=true
|
||||
|
||||
if [[ $- != *p* && ${BASH_SOURCE[0]} == "$0" ]]; then
|
||||
@@ -22,7 +22,6 @@ fi
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
readonly DEFAULT_MINUTES=15
|
||||
readonly MAX_MINUTES=1440
|
||||
readonly LOCK_FILE=/run/lock/omarchy-sudo-passwordless.lock
|
||||
readonly BOOT_CLEANUP_FILE=/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf
|
||||
@@ -34,7 +33,7 @@ readonly INSTALLED_SELF=/usr/bin/omarchy-sudo-passwordless
|
||||
readonly STATUS_INACTIVE=3
|
||||
|
||||
usage() {
|
||||
echo "Usage: omarchy-sudo-passwordless [MINUTES]" >&2
|
||||
echo "Usage: omarchy-sudo-passwordless [MINUTES|permanent|--active|--disable]" >&2
|
||||
echo "MINUTES must be between 1 and $MAX_MINUTES." >&2
|
||||
exit 1
|
||||
}
|
||||
@@ -43,6 +42,10 @@ valid_minutes() {
|
||||
[[ $1 =~ ^0*[1-9][0-9]{0,3}$ ]] && ((10#$1 <= MAX_MINUTES))
|
||||
}
|
||||
|
||||
valid_duration() {
|
||||
[[ $1 == "permanent" ]] || valid_minutes "$1"
|
||||
}
|
||||
|
||||
valid_uid() {
|
||||
[[ $1 =~ ^0*[1-9][0-9]{0,9}$ ]] && ((10#$1 <= 4294967294))
|
||||
}
|
||||
@@ -96,14 +99,39 @@ with_root_lock() {
|
||||
}
|
||||
|
||||
rule_file() {
|
||||
printf '/etc/sudoers.d/99-omarchy-nopasswd-%s' "$1"
|
||||
local uid=$((10#$1))
|
||||
if [[ ${2:-} == "permanent" ]]; then
|
||||
printf '/etc/sudoers.d/99-omarchy-permanent-nopasswd-%s' "$uid"
|
||||
else
|
||||
printf '/etc/sudoers.d/99-omarchy-nopasswd-%s' "$uid"
|
||||
fi
|
||||
}
|
||||
|
||||
# The sudoers rule is the only grant record. A missing file is distinct from
|
||||
# an unreadable, unsafe, or administrator-modified file.
|
||||
read_grant() {
|
||||
local file contents
|
||||
local file permanent contents
|
||||
file=$(rule_file "$1")
|
||||
permanent=$(rule_file "$1" permanent)
|
||||
if [[ -e $permanent || -L $permanent ]]; then
|
||||
verify_root_path "$permanent" && [[ -f $permanent ]] || return 2
|
||||
contents=$(/usr/bin/cat -- "$permanent") || return 2
|
||||
[[ $contents =~ ^([a-z_][a-z0-9_-]*\$?)\ ALL=\(ALL\)\ NOPASSWD:\ ALL$ ]] || return 2
|
||||
GRANT_NAME=${BASH_REMATCH[1]}
|
||||
GRANT_DEADLINE=""
|
||||
valid_account_name "$GRANT_NAME" || return 2
|
||||
if [[ -e $file || -L $file ]]; then
|
||||
# A SIGKILL between publication and removal can leave both policies.
|
||||
# Only accept a matching generated timed rule. Permanent access already
|
||||
# exists, so old expiry callbacks must not revoke it; disable removes
|
||||
# both files, and the next duration change finishes their replacement.
|
||||
verify_root_path "$file" && [[ -f $file ]] || return 2
|
||||
contents=$(/usr/bin/cat -- "$file") || return 2
|
||||
[[ $contents =~ ^([a-z_][a-z0-9_-]*\$?)\ ALL=\(ALL\)\ NOTAFTER=([0-9]{14}Z)\ NOPASSWD:\ ALL$ ]] || return 2
|
||||
[[ ${BASH_REMATCH[1]} == "$GRANT_NAME" ]] || return 2
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
[[ -e $file || -L $file ]] || return "$STATUS_INACTIVE"
|
||||
verify_root_path "$file" && [[ -f $file ]] || return 2
|
||||
contents=$(/usr/bin/cat -- "$file") || return 2
|
||||
@@ -121,6 +149,9 @@ classify_generated_rule() {
|
||||
[[ -f $file && ! -L $file ]] || return 1
|
||||
contents=$(/usr/bin/cat -- "$file") || return 2
|
||||
suffix=${file##*/99-omarchy-nopasswd-}
|
||||
if [[ $file == */99-omarchy-permanent-nopasswd-* ]]; then
|
||||
suffix=${file##*/99-omarchy-permanent-nopasswd-}
|
||||
fi
|
||||
|
||||
# The legacy command wrote the caller's unvalidated name into both the
|
||||
# filename and the rule. That exact relationship is its fingerprint, so an
|
||||
@@ -138,14 +169,21 @@ classify_generated_rule() {
|
||||
valid_account_name "$name" && [[ $contents =~ ^[a-z_][a-z0-9_-]*\$?\ ALL=\(ALL\)\ NOTAFTER=[0-9]{14}Z\ NOPASSWD:\ ALL$ ]]
|
||||
}
|
||||
|
||||
cleanup_uid_locked() {
|
||||
local file
|
||||
file=$(rule_file "$1")
|
||||
cleanup_rule_file() {
|
||||
local file=$1
|
||||
[[ -e $file || -L $file ]] || return 0
|
||||
verify_root_path "$file" && classify_generated_rule "$file" || return 1
|
||||
/usr/bin/rm -f -- "$file" && [[ ! -e $file && ! -L $file ]]
|
||||
}
|
||||
|
||||
cleanup_uid_locked() {
|
||||
local file duration
|
||||
for duration in temporary permanent; do
|
||||
file=$(rule_file "$1" "$duration")
|
||||
cleanup_rule_file "$file" || return 1
|
||||
done
|
||||
}
|
||||
|
||||
# The generated prefix is reserved: boot cleanup and the package hook already
|
||||
# remove everything in it, and the legacy writer could produce a rule whose
|
||||
# body differs from its filename. Nothing unrecognized may stay live there, but
|
||||
@@ -263,6 +301,7 @@ migrate_locked() {
|
||||
expire_locked() {
|
||||
local status now
|
||||
if read_grant "$1"; then
|
||||
[[ -n $GRANT_DEADLINE ]] || return 0
|
||||
now=$(/usr/bin/date -u +%Y%m%d%H%M%SZ) || return 2
|
||||
[[ $now < $GRANT_DEADLINE ]] && return 0
|
||||
cleanup_uid_locked "$1"
|
||||
@@ -281,6 +320,7 @@ status_locked() {
|
||||
resolve_account "$1" || return 2
|
||||
if read_grant "$1"; then
|
||||
[[ $GRANT_NAME == "$ACCOUNT_NAME" ]] || return 2
|
||||
[[ -n $GRANT_DEADLINE ]] || return 0
|
||||
now=$(/usr/bin/date -u +%Y%m%d%H%M%SZ) || return 2
|
||||
if [[ $now < $GRANT_DEADLINE ]]; then
|
||||
return 0
|
||||
@@ -300,7 +340,11 @@ finish_enable() {
|
||||
if cleanup_uid_locked "$uid"; then
|
||||
[[ -z $timer ]] || /usr/bin/systemctl stop "$timer.timer" "$timer.service" >/dev/null 2>&1 || true
|
||||
else
|
||||
echo "Could not revoke passwordless sudo; expiry remains armed. Administrator cleanup is required." >&2
|
||||
if [[ -n $timer ]]; then
|
||||
echo "Could not revoke passwordless sudo; expiry remains armed. Administrator cleanup is required." >&2
|
||||
else
|
||||
echo "Could not revoke passwordless sudo. Administrator cleanup is required." >&2
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
[[ -z $pending ]] || /usr/bin/rm -f -- "$pending"
|
||||
@@ -309,9 +353,15 @@ finish_enable() {
|
||||
|
||||
enable_locked() (
|
||||
local uid=$1 minutes=$2 now expires deadline token timer="" pending="" file status
|
||||
resolve_account "$uid" && valid_minutes "$minutes" || return 1
|
||||
verify_boot_cleanup && verify_root_path /etc/sudoers.d || return 1
|
||||
file=$(rule_file "$uid")
|
||||
resolve_account "$uid" && valid_duration "$minutes" || return 1
|
||||
verify_root_path /etc/sudoers.d || return 1
|
||||
if [[ $minutes != "permanent" ]]; then
|
||||
verify_boot_cleanup || {
|
||||
echo "Temporary sudo cleanup is unavailable or a settings package transaction is incomplete. Check the omarchy-settings installation." >&2
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
file=$(rule_file "$uid" "$minutes")
|
||||
if read_grant "$uid"; then
|
||||
[[ $GRANT_NAME == "$ACCOUNT_NAME" ]] || return 1
|
||||
else
|
||||
@@ -320,39 +370,69 @@ enable_locked() (
|
||||
fi
|
||||
trap finish_enable EXIT
|
||||
omarchy_security_install_signal_exit_traps
|
||||
now=$(/usr/bin/date +%s) || return 1
|
||||
expires=$((now + 10#$minutes * 60))
|
||||
deadline=$(/usr/bin/date -u -d "@$expires" +%Y%m%d%H%M%SZ) || return 1
|
||||
pending=$(/usr/bin/mktemp /etc/sudoers.d/.omarchy-nopasswd.XXXXXX) || return 1
|
||||
/usr/bin/printf '%s ALL=(ALL) NOTAFTER=%s NOPASSWD: ALL\n' "$ACCOUNT_NAME" "$deadline" >"$pending" || return 1
|
||||
if [[ $minutes == "permanent" ]]; then
|
||||
/usr/bin/printf '%s ALL=(ALL) NOPASSWD: ALL\n' "$ACCOUNT_NAME" >"$pending" || return 1
|
||||
else
|
||||
now=$(/usr/bin/date +%s) || return 1
|
||||
expires=$((now + 10#$minutes * 60))
|
||||
deadline=$(/usr/bin/date -u -d "@$expires" +%Y%m%d%H%M%SZ) || return 1
|
||||
/usr/bin/printf '%s ALL=(ALL) NOTAFTER=%s NOPASSWD: ALL\n' "$ACCOUNT_NAME" "$deadline" >"$pending" || return 1
|
||||
fi
|
||||
/usr/bin/chown root:root "$pending" && /usr/bin/chmod 0440 "$pending" || return 1
|
||||
/usr/sbin/visudo -cf "$pending" >/dev/null || return 1
|
||||
token=$(/usr/bin/tr -d '-' </proc/sys/kernel/random/uuid) || return 1
|
||||
[[ $token =~ ^[0-9a-f]{32}$ ]] || return 1
|
||||
timer="omarchy-nopasswd-expire-$uid-$token"
|
||||
/usr/bin/systemd-run --quiet --collect --on-calendar="@$expires" \
|
||||
--timer-property=AccuracySec=1s --unit="$timer" \
|
||||
-- "$INSTALLED_SELF" __expire "$uid" || return 1
|
||||
/usr/bin/systemctl is-active --quiet "$timer.timer" || return 1
|
||||
if [[ $minutes != "permanent" ]]; then
|
||||
token=$(/usr/bin/tr -d '-' </proc/sys/kernel/random/uuid) || return 1
|
||||
[[ $token =~ ^[0-9a-f]{32}$ ]] || return 1
|
||||
timer="omarchy-nopasswd-expire-$uid-$token"
|
||||
/usr/bin/systemd-run --quiet --collect --on-calendar="@$expires" \
|
||||
--timer-property=AccuracySec=1s --unit="$timer" \
|
||||
-- "$INSTALLED_SELF" __expire "$uid" || return 1
|
||||
/usr/bin/systemctl is-active --quiet "$timer.timer" || return 1
|
||||
fi
|
||||
# The temporary filename contains a dot, so sudo ignores it. Rename within
|
||||
# sudoers.d publishes the complete validated policy in one operation.
|
||||
/usr/bin/mv -fT -- "$pending" "$file" || return 1
|
||||
pending=""
|
||||
now=$(/usr/bin/date +%s) || return 1
|
||||
(( now < expires )) && verify_boot_cleanup && /usr/bin/systemctl is-active --quiet "$timer.timer"
|
||||
# Keep access uninterrupted during renewal. When changing duration type,
|
||||
# remove the other rule only after its replacement is published. The lock
|
||||
# keeps status and expiry callbacks from observing both rules together.
|
||||
if [[ $minutes == "permanent" ]]; then
|
||||
cleanup_rule_file "$(rule_file "$uid")" || return 1
|
||||
else
|
||||
cleanup_rule_file "$(rule_file "$uid" permanent)" || return 1
|
||||
fi
|
||||
if [[ $minutes != "permanent" ]]; then
|
||||
now=$(/usr/bin/date +%s) || return 1
|
||||
(( now < expires )) && verify_boot_cleanup && /usr/bin/systemctl is-active --quiet "$timer.timer"
|
||||
fi
|
||||
)
|
||||
|
||||
root_dispatch() {
|
||||
local action="$1"
|
||||
local action="$1" status
|
||||
shift
|
||||
case "$action" in
|
||||
__status)
|
||||
(($# == 1)) && verify_sudo_caller "$1" || return 2
|
||||
with_root_lock status_locked "$1"
|
||||
if with_root_lock status_locked "$1"; then
|
||||
return 0
|
||||
else
|
||||
status=$?
|
||||
# Reserve status 1 for sudo refusing noninteractive authentication.
|
||||
# Every internal inspection failure, including lock failures, is 2.
|
||||
(( status == STATUS_INACTIVE )) || status=2
|
||||
return "$status"
|
||||
fi
|
||||
;;
|
||||
__enable)
|
||||
(($# == 2)) && verify_sudo_caller "$1" && valid_minutes "$2" || return 1
|
||||
with_root_lock enable_locked "$1" "$2"
|
||||
(($# == 2)) && verify_sudo_caller "$1" && valid_duration "$2" || return 1
|
||||
if with_root_lock enable_locked "$1" "$2"; then
|
||||
return 0
|
||||
else
|
||||
status=$?
|
||||
echo "Could not enable passwordless sudo. Check existing sudoers policy and cleanup support." >&2
|
||||
return "$status"
|
||||
fi
|
||||
;;
|
||||
__disable)
|
||||
(($# == 1)) && verify_sudo_caller "$1" || return 1
|
||||
@@ -382,6 +462,14 @@ root_dispatch() {
|
||||
esac
|
||||
}
|
||||
|
||||
show_enabled() {
|
||||
if [[ $minutes == "permanent" ]]; then
|
||||
echo "Passwordless sudo has been ENABLED permanently. Run this command again to disable it."
|
||||
else
|
||||
echo "Passwordless sudo has been ENABLED. It will automatically disable in ${minutes} minutes."
|
||||
fi
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
__status|__enable|__disable|__expire|__cleanup-all|__package-removing|__migrate|__migration-complete)
|
||||
action=$1
|
||||
@@ -391,9 +479,30 @@ case "${1:-}" in
|
||||
;;
|
||||
esac
|
||||
|
||||
# Prefer matched policy tags to executing a privileged command, avoiding
|
||||
# authentication/session logs under the default listing policy. Restrictive
|
||||
# listpw settings require the root-status fallback. Neither path prompts or
|
||||
# trusts cached credentials as evidence of passwordless access.
|
||||
if [[ ${1:-} == "--active" ]]; then
|
||||
(($# == 1)) || usage
|
||||
uid=$(/usr/bin/id -u)
|
||||
if policy=$(/usr/bin/sudo -n -N -l -l -- "$INSTALLED_SELF" __status "$uid" 2>/dev/null); then
|
||||
/usr/bin/grep -q '!authenticate' <<<"$policy"
|
||||
else
|
||||
# listpw=always can require authentication to list a passwordless grant.
|
||||
# With a command, -k ignores cached credentials without invalidating them.
|
||||
status=0
|
||||
/usr/bin/sudo -kn -- "$INSTALLED_SELF" __status "$uid" 2>/dev/null || status=$?
|
||||
# An internal inspection error still proves that sudo ran the helper
|
||||
# without authentication. Keep the warning visible for unsafe policy.
|
||||
(( status == 0 || status == 2 )) || exit 1
|
||||
fi
|
||||
exit
|
||||
fi
|
||||
|
||||
(($# <= 1)) || usage
|
||||
minutes=${1:-$DEFAULT_MINUTES}
|
||||
valid_minutes "$minutes" || usage
|
||||
minutes=${1:-}
|
||||
(($# == 0)) || [[ $minutes == "--disable" ]] || valid_duration "$minutes" || usage
|
||||
uid=$(/usr/bin/id -u)
|
||||
valid_uid "$uid" || {
|
||||
echo "omarchy-sudo-passwordless: cannot grant passwordless sudo to this account" >&2
|
||||
@@ -411,38 +520,76 @@ omarchy_security_install_sudo_cleanup_traps
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "Toggle passwordless sudo..."
|
||||
if /usr/bin/sudo -N -- "$INSTALLED_SELF" __status "$uid"; then
|
||||
if (($# == 0)); then
|
||||
/usr/bin/sudo -N -- "$INSTALLED_SELF" __disable "$uid"
|
||||
echo "Passwordless sudo has been DISABLED. Sudo will require a password again."
|
||||
else
|
||||
/usr/bin/sudo -N -- "$INSTALLED_SELF" __enable "$uid" "$minutes"
|
||||
echo "Passwordless sudo expiry updated. It will automatically disable in ${minutes} minutes."
|
||||
# Inspect policy without authenticating. Only inspect the root-owned grant
|
||||
# when policy already permits it without a password; otherwise the eventual
|
||||
# enable action is the sole authentication boundary. enable_locked validates
|
||||
# any existing policy before publishing, including expired or modified rules.
|
||||
status=$STATUS_INACTIVE
|
||||
if policy=$(/usr/bin/sudo -n -N -l -l -- "$INSTALLED_SELF" __status "$uid" 2>/dev/null); then
|
||||
if /usr/bin/grep -q '!authenticate' <<<"$policy"; then
|
||||
status=0
|
||||
/usr/bin/sudo -n -N -- "$INSTALLED_SELF" __status "$uid" || status=$?
|
||||
fi
|
||||
else
|
||||
status=$?
|
||||
if (( status != STATUS_INACTIVE )); then
|
||||
# A listing password is independent of command authorization. An active
|
||||
# grant can still be inspected/revoked without prompting under listpw=always.
|
||||
status=0
|
||||
/usr/bin/sudo -n -N -- "$INSTALLED_SELF" __status "$uid" 2>/dev/null || status=$?
|
||||
if (( status == 1 )) && [[ $minutes != "--disable" ]]; then
|
||||
# No noninteractive inspection is available. Offer the confirmed enable
|
||||
# flow; its single authenticated action validates existing policy itself.
|
||||
status=$STATUS_INACTIVE
|
||||
fi
|
||||
fi
|
||||
if (( status == 0 )) && { (($# == 0)) || [[ $minutes == "--disable" ]]; }; then
|
||||
/usr/bin/sudo -n -N -- "$INSTALLED_SELF" __disable "$uid"
|
||||
echo "Passwordless sudo has been DISABLED. Sudo will require a password again."
|
||||
elif (( status == STATUS_INACTIVE )) && [[ $minutes == "--disable" ]]; then
|
||||
echo "Passwordless sudo is already DISABLED."
|
||||
elif (( status == 0 )) && [[ $minutes != "permanent" ]]; then
|
||||
/usr/bin/sudo -N -- "$INSTALLED_SELF" __enable "$uid" "$minutes"
|
||||
show_enabled
|
||||
else
|
||||
if (( status != 0 && status != STATUS_INACTIVE )); then
|
||||
echo "Could not safely inspect passwordless sudo; no grant will be enabled. Resolve the reported authorization or cleanup error first." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z $minutes ]]; then
|
||||
choice=$(/usr/bin/gum choose --header "How long should passwordless sudo stay enabled?" "15 minutes" "1 Hour" "1 Day" "Permanently") || exit 130
|
||||
case "$choice" in
|
||||
"15 minutes") minutes=15 ;;
|
||||
"1 Hour") minutes=60 ;;
|
||||
"1 Day") minutes=1440 ;;
|
||||
"Permanently") minutes=permanent ;;
|
||||
*) exit 130 ;;
|
||||
esac
|
||||
fi
|
||||
if [[ $minutes == "permanent" ]]; then
|
||||
duration="permanently"
|
||||
else
|
||||
duration="for ${minutes} minutes"
|
||||
fi
|
||||
echo ""
|
||||
echo "⚠️ WARNING: This will allow ANY process running as your user to"
|
||||
echo "execute ANY command as root WITHOUT a password for ${minutes} minutes."
|
||||
echo "execute ANY command as root WITHOUT a password $duration."
|
||||
echo ""
|
||||
echo "This is useful for AI agents that need to run sudo commands,"
|
||||
echo "but it significantly weakens the security of your system."
|
||||
echo "Anyone or anything with access to your user account gets full root."
|
||||
echo ""
|
||||
echo "Passwordless sudo will automatically disable after ${minutes} minutes,"
|
||||
echo "including if the machine reboots before the deadline."
|
||||
echo "Run this command again to disable it early."
|
||||
if [[ $minutes == "permanent" ]]; then
|
||||
echo "Passwordless sudo will remain enabled across reboots until you disable it."
|
||||
else
|
||||
echo "Passwordless sudo will automatically disable after ${minutes} minutes,"
|
||||
echo "including if the machine reboots before the deadline."
|
||||
fi
|
||||
echo "Run this command again to disable it."
|
||||
echo ""
|
||||
|
||||
if /usr/bin/gum confirm "Enable passwordless sudo for ${minutes} minutes? This is a significant security risk!"; then
|
||||
if /usr/bin/gum confirm "Enable passwordless sudo $duration? This is a significant security risk!"; then
|
||||
/usr/bin/sudo -N -- "$INSTALLED_SELF" __enable "$uid" "$minutes"
|
||||
echo ""
|
||||
echo "Passwordless sudo has been ENABLED. It will automatically disable in ${minutes} minutes."
|
||||
show_enabled
|
||||
else
|
||||
echo "Aborted. No changes made."
|
||||
fi
|
||||
|
||||
+1
-1
@@ -127,7 +127,7 @@ if [[ ${1:-} == "-y" ]] || omarchy-update-confirm; then
|
||||
omarchy-update-system-pkgs
|
||||
|
||||
omarchy-migrate
|
||||
omarchy-update-orphan-pkgs
|
||||
omarchy-update-orphan-pkgs -y
|
||||
|
||||
omarchy-update-analyze-logs
|
||||
omarchy-update-status
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Review and optionally remove orphaned system packages after updates
|
||||
# omarchy:args=[-y]
|
||||
# omarchy:examples=omarchy update orphan-pkgs | omarchy update orphan-pkgs -y
|
||||
# omarchy:requires-sudo=true
|
||||
|
||||
set -e
|
||||
@@ -12,18 +14,18 @@ echo -e "\e[32m\nOrphan system packages\e[0m"
|
||||
printf ' %s\n' "${orphans[@]}"
|
||||
echo
|
||||
|
||||
if [[ ! -t 0 || ! -t 1 ]]; then
|
||||
if [[ ${1:-} != "-y" && ( ! -t 0 || ! -t 1 ) ]]; then
|
||||
echo "${#orphans[@]} orphaned package(s) found. Re-run omarchy-update-orphan-pkgs in a terminal to review/remove them."
|
||||
echo
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! gum confirm --default=false "Remove ${#orphans[@]} orphaned package(s)?"; then
|
||||
if [[ ${1:-} != "-y" ]] && ! gum confirm --default=false "Remove ${#orphans[@]} orphaned package(s)?"; then
|
||||
echo "Keeping orphaned packages."
|
||||
echo
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo -e "\e[32m\nRemoving orphan system packages\e[0m"
|
||||
sudo pacman -Rns "${orphans[@]}"
|
||||
omarchy-pkg-drop "${orphans[@]}"
|
||||
echo
|
||||
@@ -1802,6 +1802,8 @@ copy_missing_config_defaults() {
|
||||
while IFS= read -r -d '' source_path; do
|
||||
rel="${source_path#$source_dir/}"
|
||||
is_retired_config_file "$rel" && continue
|
||||
# The input migration preserves working shortcuts in existing IME profiles.
|
||||
[[ $rel == "fcitx5/config" ]] && continue
|
||||
|
||||
target_path="$target_dir/$rel"
|
||||
[[ -e $target_path || -L $target_path ]] && continue
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Keep Pinyin offline without prompting to enable cloud prediction.
|
||||
FirstRun=False
|
||||
CloudPinyinEnabled=False
|
||||
@@ -0,0 +1,2 @@
|
||||
[TriggerKey]
|
||||
0=Super+semicolon
|
||||
@@ -0,0 +1 @@
|
||||
Allow Overriding System XKB Settings=False
|
||||
@@ -0,0 +1,20 @@
|
||||
# Leave Ctrl+Space to tmux/Herdr and the Super shortcuts to the desktop.
|
||||
[Hotkey/TriggerKeys]
|
||||
0=Control+Shift+space
|
||||
1=Zenkaku_Hankaku
|
||||
2=Hangul
|
||||
|
||||
[Hotkey/ActivateKeys]
|
||||
0=Henkan
|
||||
|
||||
[Hotkey/DeactivateKeys]
|
||||
0=Muhenkan
|
||||
|
||||
[Hotkey/AltTriggerKeys]
|
||||
|
||||
[Hotkey/EnumerateGroupForwardKeys]
|
||||
|
||||
[Hotkey/EnumerateGroupBackwardKeys]
|
||||
|
||||
[Behavior]
|
||||
ActiveByDefault=False
|
||||
@@ -0,0 +1,66 @@
|
||||
local paths = require("default.hypr.paths")
|
||||
|
||||
-- Same terminal tag test as clipboard.lua.
|
||||
local function active_window_is_terminal()
|
||||
local window = hl.get_active_window()
|
||||
if not window then
|
||||
return false
|
||||
end
|
||||
|
||||
for _, tag in ipairs(window.tags or {}) do
|
||||
if tag:gsub("%*$", "") == "terminal" then
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
-- Read on demand so adding an engine in a live desktop needs no reload.
|
||||
local function has_input_method()
|
||||
local file = io.open(paths.config_home .. "/fcitx5/profile", "r")
|
||||
if not file then return false end
|
||||
local in_item, found = false, false
|
||||
for line in file:lines() do
|
||||
if line:match("^%[") then
|
||||
in_item = line:match("^%[Groups/%d+/Items/%d+%]$") ~= nil
|
||||
elseif in_item then
|
||||
local name = line:match("^Name=(.+)$")
|
||||
if name and not name:match("^keyboard%-") then found = true end
|
||||
end
|
||||
end
|
||||
file:close()
|
||||
return found
|
||||
end
|
||||
|
||||
-- Preserve custom Ctrl + Space triggers. Without a
|
||||
-- [Hotkey/TriggerKeys] list in the config, fcitx5 uses its default, which has
|
||||
-- Ctrl + Space.
|
||||
local function fcitx5_toggles_on_ctrl_space()
|
||||
local file = io.open(paths.config_home .. "/fcitx5/config", "r")
|
||||
if not file then
|
||||
return true
|
||||
end
|
||||
|
||||
local in_triggers, has_triggers, toggles = false, false, false
|
||||
for line in file:lines() do
|
||||
if line:match("^%[") then
|
||||
in_triggers = line == "[Hotkey/TriggerKeys]"
|
||||
has_triggers = has_triggers or in_triggers
|
||||
elseif in_triggers and line:match("^%d+=Control%+space$") then
|
||||
toggles = true
|
||||
end
|
||||
end
|
||||
|
||||
file:close()
|
||||
return toggles or not has_triggers
|
||||
end
|
||||
|
||||
-- Pass the tmux/Herdr prefix through and return to direct input, so the next
|
||||
-- key reaches the multiplexer instead of the engine's composition.
|
||||
o.bind("CTRL + SPACE", "Direct input for the terminal prefix", function()
|
||||
if active_window_is_terminal() and has_input_method() and not fcitx5_toggles_on_ctrl_space() then
|
||||
-- Wait for the local controller reply before dispatching the next key.
|
||||
o.shell_succeeds("timeout 0.3 fcitx5-remote --check -c")
|
||||
end
|
||||
end, { non_consuming = true })
|
||||
@@ -0,0 +1,43 @@
|
||||
-- Defaults for Japanese (JIS) keyboards, which only apply when the installed
|
||||
-- layout is jp. See default/hypr/keyboard.lua.
|
||||
|
||||
local keyboard = require("default.hypr.keyboard")
|
||||
|
||||
if not keyboard.jis() then
|
||||
return
|
||||
end
|
||||
|
||||
-- Same down/up split as the universal clipboard shortcuts in clipboard.lua.
|
||||
local function send_shortcut_once(mods, key)
|
||||
hl.dispatch(hl.dsp.send_key_state({ mods = mods, key = key, state = "down" }))
|
||||
|
||||
hl.timer(function()
|
||||
hl.dispatch(hl.dsp.send_key_state({ mods = mods, key = key, state = "up" }))
|
||||
end, { timeout = 50, type = "oneshot" })
|
||||
end
|
||||
|
||||
local function active_window_class()
|
||||
local window = hl.get_active_window()
|
||||
return ((window and window.class) or ""):lower()
|
||||
end
|
||||
|
||||
-- On JIS, + is Shift + ;, so apps see Ctrl + Shift + semicolon and never zoom
|
||||
-- in. Hand them Ctrl + keypad plus instead, which Chromium, Electron apps,
|
||||
-- VS Code, and Firefox read as zoom in. Obsidian only listens for Ctrl + ^,
|
||||
-- which JIS types without Shift.
|
||||
local zoom_in_overrides = {
|
||||
obsidian = { "CTRL", "asciicircum" },
|
||||
}
|
||||
|
||||
o.bind("CTRL + SHIFT + semicolon", "Zoom in (JIS + key)", function()
|
||||
local class = active_window_class()
|
||||
|
||||
for pattern, chord in pairs(zoom_in_overrides) do
|
||||
if class:find(pattern, 1, true) then
|
||||
send_shortcut_once(chord[1], chord[2])
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
send_shortcut_once("CTRL", "KP_Add")
|
||||
end)
|
||||
+2
-21
@@ -1,32 +1,13 @@
|
||||
-- https://wiki.hypr.land/Configuring/Basics/Variables/#input
|
||||
|
||||
local function read_vconsole()
|
||||
local values = {}
|
||||
local file = io.open("/etc/vconsole.conf", "r")
|
||||
if not file then
|
||||
return values
|
||||
end
|
||||
|
||||
for line in file:lines() do
|
||||
local key, value = line:match("^%s*([%w_]+)%s*=%s*(.-)%s*$")
|
||||
if key and value then
|
||||
value = value:gsub("%s+#.*$", "")
|
||||
value = value:gsub('^"(.*)"$', "%1")
|
||||
value = value:gsub("^'(.*)'$", "%1")
|
||||
values[key] = value
|
||||
end
|
||||
end
|
||||
|
||||
file:close()
|
||||
return values
|
||||
end
|
||||
local keyboard = require("default.hypr.keyboard")
|
||||
|
||||
-- Layouts that can't type Latin letters. Keep in sync with the list in
|
||||
-- etc/mkinitcpio.conf.d/omarchy_hooks.conf.
|
||||
local non_latin_layouts =
|
||||
" af am ara bd bg by et ge gr il in iq ir kg kh kz la lk mk mm mn mv np rs ru sy th tj ua "
|
||||
|
||||
local vconsole = read_vconsole()
|
||||
local vconsole = keyboard.installed()
|
||||
|
||||
local kb_layout = vconsole.XKBLAYOUT or "us"
|
||||
local kb_variant = vconsole.XKBVARIANT or ""
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
-- The keyboard layout picked at install time. The installer and first-boot
|
||||
-- setup persist it to /etc/vconsole.conf, so Hyprland reads it from there
|
||||
-- instead of rewriting every user's config.
|
||||
|
||||
local keyboard = {}
|
||||
|
||||
local function read_values(path)
|
||||
local values = {}
|
||||
local file = io.open(path, "r")
|
||||
if not file then
|
||||
return values
|
||||
end
|
||||
|
||||
for line in file:lines() do
|
||||
local key, value = line:match("^%s*([%w_]+)%s*=%s*(.-)%s*$")
|
||||
if key and value then
|
||||
value = value:gsub("%s+#.*$", "")
|
||||
value = value:gsub('^"(.*)"$', "%1")
|
||||
value = value:gsub("^'(.*)'$", "%1")
|
||||
values[key] = value
|
||||
end
|
||||
end
|
||||
|
||||
file:close()
|
||||
return values
|
||||
end
|
||||
|
||||
function keyboard.vconsole()
|
||||
return read_values("/etc/vconsole.conf")
|
||||
end
|
||||
|
||||
function keyboard.installed()
|
||||
local values = keyboard.vconsole()
|
||||
if read_values("/etc/omarchy/input-method").XKB_LAYOUT == "kr"
|
||||
and (values.XKBLAYOUT or "us") == "us"
|
||||
and (values.XKBVARIANT or "") == "" then
|
||||
values.XKBLAYOUT, values.XKBVARIANT = "kr", ""
|
||||
end
|
||||
return values
|
||||
end
|
||||
|
||||
-- Japanese (JIS) keyboards put +, ^, and the Henkan/Muhenkan keys where US
|
||||
-- keyboards don't, so a few defaults only apply when that layout leads.
|
||||
function keyboard.jis()
|
||||
local layout = keyboard.installed().XKBLAYOUT or "us"
|
||||
return layout:match("^[^,]*") == "jp"
|
||||
end
|
||||
|
||||
return keyboard
|
||||
@@ -8,6 +8,8 @@ require("default.hypr.autostart")
|
||||
if _G.omarchy_default_bindings ~= false then
|
||||
require("default.hypr.bindings.media")
|
||||
require("default.hypr.bindings.clipboard")
|
||||
require("default.hypr.bindings.input-method")
|
||||
require("default.hypr.bindings.jis")
|
||||
require("default.hypr.bindings.tiling")
|
||||
require("default.hypr.bindings.utilities")
|
||||
require("default.hypr.bindings.voxtype")
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
"""One Fcitx setup path for offline finalization and a running desktop."""
|
||||
|
||||
import argparse
|
||||
from contextlib import contextmanager
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
ROOT = Path(os.environ["OMARCHY_PATH"])
|
||||
PRESETS = json.loads((ROOT / "default/input-methods/presets.json").read_text())
|
||||
NON_LATIN = set("af am ara bd bg by et ge gr il in iq ir kg kh kz la lk mk mm mn mv np rs ru sy th tj ua".split())
|
||||
CONTROLLER = ["busctl", "--user", "call", "org.fcitx.Fcitx5", "/controller", "org.fcitx.Fcitx.Controller1"]
|
||||
|
||||
|
||||
def run(args, **kwargs):
|
||||
return subprocess.run(args, check=True, text=True, capture_output=True, **kwargs).stdout.strip()
|
||||
|
||||
|
||||
def read(path):
|
||||
return path.read_text() if path.exists() else ""
|
||||
|
||||
|
||||
def values(text):
|
||||
result = {}
|
||||
for line in text.splitlines():
|
||||
match = re.match(r"^\s*([\w ]+)\s*=\s*(.*?)\s*(?:\s+#.*)?$", line)
|
||||
if match:
|
||||
result[match[1].strip()] = match[2].strip("\"'")
|
||||
return result
|
||||
|
||||
|
||||
def sections(text):
|
||||
result = {}
|
||||
current = ""
|
||||
for line in text.splitlines():
|
||||
match = re.match(r"^\s*\[([^]]+)\]\s*$", line)
|
||||
if match:
|
||||
current = match[1]
|
||||
result.setdefault(current, {})
|
||||
elif current:
|
||||
result[current].update(values(line))
|
||||
return result
|
||||
|
||||
|
||||
def atomic_write(path, text):
|
||||
# Follow a user's config symlink rather than replacing the link itself.
|
||||
path = path.resolve()
|
||||
if read(path) == text:
|
||||
return False
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
mode = path.stat().st_mode & 0o777 if path.exists() else 0o644
|
||||
fd, temporary = tempfile.mkstemp(prefix=".omarchy-input-", dir=path.parent)
|
||||
try:
|
||||
with os.fdopen(fd, "w") as file:
|
||||
file.write(text)
|
||||
os.chmod(temporary, mode)
|
||||
os.replace(temporary, path)
|
||||
finally:
|
||||
if os.path.exists(temporary):
|
||||
os.unlink(temporary)
|
||||
return True
|
||||
|
||||
|
||||
def key_list(entries):
|
||||
# Fcitx canonicalizes modifiers (e.g. Shift+Super instead of Super+Shift).
|
||||
return {frozenset(value.split("+")) for value in entries.values()}
|
||||
|
||||
|
||||
def defaults(config_home, fresh=False):
|
||||
changed = False
|
||||
files = ["config", "conf/quickphrase.conf", "conf/wayland.conf", "conf/xcb.conf", "conf/pinyin.conf"]
|
||||
for name in files:
|
||||
target = config_home / "fcitx5" / name
|
||||
original = read(target)
|
||||
shipped = read(ROOT / "config/fcitx5" / name)
|
||||
if name == "config" or name == "conf/quickphrase.conf":
|
||||
existing = sections(original)
|
||||
additions = []
|
||||
for section, entries in sections(shipped).items():
|
||||
# Missing hotkey sections also represent a choice: Fcitx's defaults.
|
||||
if not fresh and section.startswith("Hotkey/"):
|
||||
continue
|
||||
# Existing lists (including explicitly empty lists) are user choices.
|
||||
# A fresh profile has no working IME shortcut to preserve.
|
||||
stock_lists = {
|
||||
"Hotkey/TriggerKeys": {"0": "Control+space", "1": "Zenkaku_Hankaku", "2": "Hangul"},
|
||||
"Hotkey/ActivateKeys": {"0": "Hangul_Hanja"},
|
||||
"Hotkey/DeactivateKeys": {"0": "Hangul_Romaja"},
|
||||
"Hotkey/AltTriggerKeys": {"0": "Shift_L"},
|
||||
"Hotkey/EnumerateGroupForwardKeys": {"0": "Super+space"},
|
||||
"Hotkey/EnumerateGroupBackwardKeys": {"0": "Super+Shift+space"},
|
||||
"TriggerKey": {"0": "Super+grave", "1": "Super+semicolon"},
|
||||
}
|
||||
if fresh and section in existing and section in stock_lists and (key_list(existing[section]) == key_list(stock_lists[section]) or (not existing[section] and section in {"Hotkey/ActivateKeys", "Hotkey/DeactivateKeys"})):
|
||||
original = re.sub(r"(?ms)^\[" + re.escape(section) + r"\]\s*\n.*?(?=^\[|\Z)", "", original)
|
||||
existing.pop(section)
|
||||
if section not in existing:
|
||||
additions.append(f"[{section}]\n" + "".join(f"{key}={value}\n" for key, value in entries.items()))
|
||||
if additions:
|
||||
changed |= atomic_write(target, original.rstrip() + ("\n\n" if original.strip() else "") + "\n".join(additions))
|
||||
elif name == "conf/pinyin.conf":
|
||||
# Suppress the first-use cloud prompt while preserving any explicit
|
||||
# cloud-prediction preference and all other existing engine settings.
|
||||
if not original:
|
||||
updated = shipped
|
||||
elif "FirstRun" in values(original):
|
||||
updated = re.sub(r"(?m)^\s*FirstRun\s*=.*$", "FirstRun=False", original)
|
||||
else:
|
||||
updated = "FirstRun=False\n" + original
|
||||
changed |= atomic_write(target, updated)
|
||||
elif not target.exists() and not target.is_symlink():
|
||||
changed |= atomic_write(target, shipped)
|
||||
return changed
|
||||
|
||||
|
||||
def activation_default():
|
||||
data_home = Path(os.environ.get("XDG_DATA_HOME") or str(Path.home() / ".local/share"))
|
||||
path = data_home / "dbus-1/services/org.fcitx.Fcitx5.service"
|
||||
original = read(path)
|
||||
# Repair the package's plain activation entry, but retain custom launchers.
|
||||
packaged = values(original).get("Exec") in ("/usr/bin/fcitx5", "/usr/bin/fcitx5 -d")
|
||||
if not original or (packaged and "SystemdService" not in values(original)):
|
||||
return atomic_write(path, read(ROOT / "default/input-methods/org.fcitx.Fcitx5.service"))
|
||||
return False
|
||||
|
||||
|
||||
@contextmanager
|
||||
def transaction(config_home, live=False):
|
||||
data_home = Path(os.environ.get("XDG_DATA_HOME") or str(Path.home() / ".local/share"))
|
||||
paths = [config_home / "fcitx5" / name for name in ["profile", "config", "conf/quickphrase.conf", "conf/wayland.conf", "conf/xcb.conf", "conf/pinyin.conf"]]
|
||||
paths += [config_home / "fontconfig/conf.d/50-omarchy-input-method.conf", data_home / "dbus-1/services/org.fcitx.Fcitx5.service"]
|
||||
before = {path.resolve(): path.read_text() if path.exists() else None for path in paths}
|
||||
try:
|
||||
yield
|
||||
except BaseException as setup_error:
|
||||
if live:
|
||||
# Stop before restoring disk: a graceful stop saves the in-memory profile.
|
||||
try:
|
||||
run(["systemctl", "--user", "stop", "omarchy-fcitx5.service"])
|
||||
subprocess.run(["pkill", "-u", str(os.getuid()), "-x", "fcitx5"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
for attempt in range(50):
|
||||
if subprocess.run(["pgrep", "-u", str(os.getuid()), "-x", "fcitx5"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode != 0:
|
||||
break
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
raise RuntimeError("Fcitx did not stop for rollback; previous settings could not safely be restored")
|
||||
except (OSError, subprocess.CalledProcessError, RuntimeError) as error:
|
||||
raise RuntimeError(f"Could not stop Fcitx for input rollback: {error} (setup failed: {setup_error})") from error
|
||||
for path, original in before.items():
|
||||
if original is None:
|
||||
path.unlink(missing_ok=True)
|
||||
else:
|
||||
atomic_write(path, original)
|
||||
if live:
|
||||
# Restore memory as well as disk; otherwise Fcitx saves the failed change
|
||||
# again on exit. Bus activation caches also need to forget the new entry.
|
||||
reload_bus()
|
||||
try:
|
||||
run(["omarchy-restart-xcompose"])
|
||||
run(CONTROLLER + ["ReloadConfig"])
|
||||
except (OSError, subprocess.CalledProcessError, RuntimeError) as error:
|
||||
print(f"Could not reload restored input settings: {error}", file=sys.stderr)
|
||||
raise
|
||||
|
||||
|
||||
def selection():
|
||||
vconsole = values(read(Path(os.environ.get("OMARCHY_VCONSOLE", "/etc/vconsole.conf"))))
|
||||
preference = values(read(Path(os.environ.get("OMARCHY_INPUT_SELECTION", "/etc/omarchy/input-method"))))
|
||||
layout = vconsole.get("XKBLAYOUT", "us").split(",")[0] or "us"
|
||||
variant = vconsole.get("XKBVARIANT", "").split(",")[0]
|
||||
if preference.get("XKB_LAYOUT") == "kr" and vconsole.get("XKBLAYOUT", "us") == "us" and not variant:
|
||||
layout, variant = "kr", ""
|
||||
method = preference.get("INPUT_METHOD", "mozc" if layout == "jp" else "none")
|
||||
if method not in PRESETS:
|
||||
raise ValueError(f"Unknown installed input method: {method}")
|
||||
if layout in NON_LATIN:
|
||||
layout, variant = "us", ""
|
||||
group_layout = layout + (f"-{variant}" if variant else "")
|
||||
if not re.fullmatch(r"[A-Za-z0-9_-]+", group_layout):
|
||||
raise ValueError("Invalid installed keyboard layout")
|
||||
return method, group_layout
|
||||
|
||||
|
||||
def stock_profile(text):
|
||||
if not text.strip():
|
||||
return True
|
||||
groups = sections(text)
|
||||
items = [data.get("Name", "") for key, data in groups.items() if re.fullmatch(r"Groups/\d+/Items/\d+", key)]
|
||||
group = groups.get("Groups/0", {})
|
||||
return (items == ["keyboard-us"]
|
||||
and list(key for key in groups if re.fullmatch(r"Groups/\d+", key)) == ["Groups/0"]
|
||||
and group.get("Name", "Default") == "Default"
|
||||
and group.get("Default Layout", "us") == "us"
|
||||
and not groups.get("Groups/0/Items/0", {}).get("Layout"))
|
||||
|
||||
|
||||
def profile_text(layout, method):
|
||||
default_im = method if method != "none" else f"keyboard-{layout}"
|
||||
text = f"[Groups/0]\nName=Default\nDefault Layout={layout}\nDefaultIM={default_im}\n\n[Groups/0/Items/0]\nName=keyboard-{layout}\nLayout=\n"
|
||||
if method != "none":
|
||||
text += f"\n[Groups/0/Items/1]\nName={method}\nLayout=\n"
|
||||
return text + "\n[GroupOrder]\n0=Default\n"
|
||||
|
||||
|
||||
def font_default(config_home, method):
|
||||
variant = PRESETS[method]["font"]
|
||||
path = config_home / "fontconfig/conf.d/50-omarchy-input-method.conf"
|
||||
if not variant or path.exists() or path.is_symlink():
|
||||
return
|
||||
# Only CJK fallback changes; retain the interface and terminal's Latin font.
|
||||
rules = []
|
||||
for families, face in [("sans-serif|Liberation Sans", "Sans"), ("serif|Liberation Serif", "Serif"), ("monospace|JetBrainsMono Nerd Font", "Sans Mono")]:
|
||||
for family in families.split("|"):
|
||||
rules.append(f'<alias><family>{family}</family><accept><family>Noto {face} CJK {variant}</family></accept></alias>')
|
||||
atomic_write(path, '<?xml version="1.0"?>\n<!DOCTYPE fontconfig SYSTEM "urn:fontconfig:fonts.dtd">\n<fontconfig>\n' + "\n".join(rules) + "\n</fontconfig>\n")
|
||||
|
||||
|
||||
def live_group():
|
||||
group = run(["fcitx5-remote", "-q"])
|
||||
info = json.loads(run(CONTROLLER[:1] + ["--json=short"] + CONTROLLER[1:] + ["InputMethodGroupInfo", "s", group]))["data"]
|
||||
layout, items = info[:2]
|
||||
return group, layout, items
|
||||
|
||||
|
||||
def live_set(group, layout, items):
|
||||
args = [group, layout, str(len(items))]
|
||||
for name, item_layout in items:
|
||||
args.extend([name, item_layout])
|
||||
run(CONTROLLER + ["SetInputMethodGroupInfo", "ssa(ss)"] + args)
|
||||
run(CONTROLLER + ["Save"])
|
||||
|
||||
|
||||
def reload_bus():
|
||||
# Offline finalization and updates over SSH may have no session bus.
|
||||
try:
|
||||
run(["busctl", "--user", "call", "org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "ReloadConfig"])
|
||||
except (OSError, subprocess.CalledProcessError, RuntimeError) as error:
|
||||
print(f"Input activation reload deferred until login: {error}", file=sys.stderr)
|
||||
|
||||
|
||||
def available_methods():
|
||||
return json.loads(run(CONTROLLER[:1] + ["--json=short"] + CONTROLLER[1:] + ["AvailableInputMethods"]))["data"][0]
|
||||
|
||||
|
||||
def wait_ready(method):
|
||||
for attempt in range(100):
|
||||
try:
|
||||
available = available_methods()
|
||||
if any(item[0] == method for item in available) and live_group()[2]:
|
||||
return
|
||||
except (OSError, ValueError, KeyError, IndexError, subprocess.CalledProcessError):
|
||||
pass
|
||||
time.sleep(0.1)
|
||||
raise RuntimeError(f"Fcitx did not load the {method} engine after restart")
|
||||
|
||||
|
||||
def live_append(method):
|
||||
group, layout, items = live_group()
|
||||
if any(item[0] == method for item in items):
|
||||
return
|
||||
try:
|
||||
live_set(group, layout, items + [[method, ""]])
|
||||
if not any(item[0] == method for item in live_group()[2]):
|
||||
raise RuntimeError(f"Fcitx did not retain the {method} engine")
|
||||
except BaseException:
|
||||
live_set(group, layout, items)
|
||||
raise
|
||||
|
||||
|
||||
def select_live_method(method, profile):
|
||||
# Clients reconnect asynchronously after restart. SetCurrentIM silently does
|
||||
# nothing without a focused input context, so verify the saved group default.
|
||||
for attempt in range(30):
|
||||
run(["fcitx5-remote", "-s", method])
|
||||
run(["fcitx5-remote", "-c"])
|
||||
run(CONTROLLER + ["Save"])
|
||||
group = run(["fcitx5-remote", "-q"])
|
||||
for section, entries in sections(read(profile)).items():
|
||||
if re.fullmatch(r"Groups/\d+", section) and entries.get("Name") == group and entries.get("DefaultIM") == method:
|
||||
return
|
||||
time.sleep(0.1)
|
||||
print(f"Input method added; select {method} in Fcitx settings to switch to it.", file=sys.stderr)
|
||||
|
||||
|
||||
def configure(args):
|
||||
config_home = Path(os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config"))
|
||||
running = subprocess.run(["pgrep", "-u", str(os.getuid()), "-x", "fcitx5"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0
|
||||
# Finalization can be retried after login. A live daemon owns its profile.
|
||||
if args.seed and running:
|
||||
return
|
||||
with transaction(config_home, live=running and not args.defaults):
|
||||
configure_inner(args, config_home, running)
|
||||
|
||||
|
||||
def configure_inner(args, config_home, running):
|
||||
if args.seed or args.defaults:
|
||||
method, layout = selection()
|
||||
else:
|
||||
method, layout = args.method or selection()[0], None
|
||||
if args.method:
|
||||
method = args.method
|
||||
profile = config_home / "fcitx5/profile"
|
||||
original = read(profile)
|
||||
if args.seed and not stock_profile(original):
|
||||
return
|
||||
if not args.seed and not args.defaults and not running:
|
||||
raise RuntimeError("Log in to an Omarchy desktop before changing input methods")
|
||||
if not args.seed and not args.defaults and method == "none":
|
||||
raise ValueError("Choose an input engine to add")
|
||||
|
||||
if args.seed:
|
||||
atomic_write(profile, profile_text(layout, method))
|
||||
defaults(config_home, fresh=True)
|
||||
activation_default()
|
||||
font_default(config_home, method)
|
||||
elif args.defaults:
|
||||
fresh = stock_profile(original)
|
||||
changed = defaults(config_home, fresh=fresh)
|
||||
if activation_default():
|
||||
reload_bus()
|
||||
# Upgrades can run through sudo/SSH without access to the live user's bus.
|
||||
# Never overwrite its profile or restart input in that context.
|
||||
if fresh:
|
||||
if running:
|
||||
try:
|
||||
group, old_layout, items = live_group()
|
||||
if old_layout == "us" and items == [["keyboard-us", ""]]:
|
||||
new_items = [[f"keyboard-{layout}", ""]]
|
||||
if method != "none":
|
||||
if any(item[0] == method for item in available_methods()):
|
||||
new_items.append([method, ""])
|
||||
else:
|
||||
print(f"Finish input setup after login: omarchy setup input {method}", file=sys.stderr)
|
||||
live_set(group, layout, new_items)
|
||||
if live_group()[1:] != (layout, new_items):
|
||||
live_set(group, old_layout, items)
|
||||
raise RuntimeError("Fcitx did not retain the installed keyboard settings")
|
||||
except (OSError, ValueError, KeyError, IndexError, RuntimeError, subprocess.CalledProcessError) as error:
|
||||
print(f"Input profile alignment deferred until login: {error}", file=sys.stderr)
|
||||
else:
|
||||
atomic_write(profile, profile_text(layout, method))
|
||||
if running and changed:
|
||||
try:
|
||||
run(CONTROLLER + ["ReloadConfig"])
|
||||
except (OSError, subprocess.CalledProcessError) as error:
|
||||
print(f"Input defaults reload deferred until login: {error}", file=sys.stderr)
|
||||
else:
|
||||
# New addons are discovered only at startup. Supervision remains with the
|
||||
# existing unit; never spawn a second fcitx instance ourselves.
|
||||
activation_default()
|
||||
defaults(config_home, fresh=stock_profile(original))
|
||||
reload_bus()
|
||||
run(["omarchy-restart-xcompose"])
|
||||
wait_ready(method)
|
||||
live_append(method)
|
||||
# The selected language becomes the next active engine; leave Latin input
|
||||
# active until the user deliberately switches. Keep all other group items.
|
||||
select_live_method(method, profile)
|
||||
font_default(config_home, method)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
commands.add_parser("status")
|
||||
configure_parser = commands.add_parser("configure")
|
||||
mode = configure_parser.add_mutually_exclusive_group()
|
||||
mode.add_argument("--seed", action="store_true")
|
||||
mode.add_argument("--defaults", action="store_true")
|
||||
configure_parser.add_argument("method", nargs="?", choices=PRESETS)
|
||||
args = parser.parse_args()
|
||||
if args.command == "status":
|
||||
method, layout = selection()
|
||||
print(json.dumps({"installed_method": method, "layout": layout}))
|
||||
else:
|
||||
configure(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except (OSError, ValueError, RuntimeError, subprocess.CalledProcessError) as error:
|
||||
raise SystemExit(f"Input method setup failed: {error}")
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Live Fcitx state for the existing keyboard layout widget."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
from gi.repository import Gio, GLib
|
||||
|
||||
|
||||
BUS = "org.fcitx.Fcitx5"
|
||||
INTERFACE = "org.fcitx.Fcitx.Controller1"
|
||||
|
||||
|
||||
def call(bus, method, args=None):
|
||||
# Reading the bar must neither activate a stopped service nor wait indefinitely.
|
||||
return bus.call_sync(BUS, "/controller", INTERFACE, method, args, None,
|
||||
Gio.DBusCallFlags.NO_AUTO_START, 1000, None).unpack()
|
||||
|
||||
|
||||
def snapshot(bus):
|
||||
group = call(bus, "CurrentInputMethodGroup")[0]
|
||||
methods = list(dict.fromkeys(item[0] for item in
|
||||
call(bus, "InputMethodGroupInfo", GLib.Variant("(s)", (group,)))[1]))
|
||||
info = call(bus, "CurrentInputMethodInfo")
|
||||
return {"methods": methods, "current": info[0], "name": info[1], "label": info[4], "language": info[5]}
|
||||
|
||||
|
||||
def cycle(bus):
|
||||
state = snapshot(bus)
|
||||
methods = state["methods"]
|
||||
if len(methods) > 1 and state["current"] in methods:
|
||||
following = methods[(methods.index(state["current"]) + 1) % len(methods)]
|
||||
call(bus, "SetCurrentIM", GLib.Variant("(s)", (following,)))
|
||||
|
||||
|
||||
class Indicator:
|
||||
def __init__(self, bus):
|
||||
self.bus = bus
|
||||
self.pending = ""
|
||||
self.last = ""
|
||||
self.entries = {}
|
||||
|
||||
def select_next(self):
|
||||
state = snapshot(self.bus)
|
||||
methods = state["methods"]
|
||||
if len(methods) < 2:
|
||||
return
|
||||
current = self.pending or state["current"] or self.last or methods[0]
|
||||
if current not in methods:
|
||||
current = methods[0]
|
||||
self.pending = methods[(methods.index(current) + 1) % len(methods)]
|
||||
self.refresh()
|
||||
|
||||
def refresh(self):
|
||||
state = snapshot(self.bus)
|
||||
methods = state["methods"]
|
||||
if self.pending not in methods:
|
||||
self.pending = ""
|
||||
if self.pending and state["current"]:
|
||||
# Before the first application gains focus Fcitx has no input context.
|
||||
# Retain the click until it has one, without changing startup defaults.
|
||||
call(self.bus, "SetCurrentIM", GLib.Variant("(s)", (self.pending,)))
|
||||
state = snapshot(self.bus)
|
||||
if state["current"] == self.pending:
|
||||
self.pending = ""
|
||||
if state["current"]:
|
||||
self.last = state["current"]
|
||||
else:
|
||||
chosen = self.pending or (self.last if self.last in methods else "") or next(iter(methods), "")
|
||||
if chosen not in self.entries:
|
||||
self.entries = {entry[0]: entry for entry in call(self.bus, "AvailableInputMethods")[0]}
|
||||
entry = self.entries.get(chosen)
|
||||
if entry:
|
||||
state.update(current=entry[0], name=entry[1], label=entry[4], language=entry[5])
|
||||
return state
|
||||
|
||||
|
||||
def watch(bus):
|
||||
previous = None
|
||||
indicator = Indicator(bus)
|
||||
loop = GLib.MainLoop()
|
||||
buffer = ""
|
||||
timer = 0
|
||||
|
||||
def refresh():
|
||||
nonlocal previous
|
||||
try:
|
||||
state = indicator.refresh()
|
||||
except GLib.Error:
|
||||
indicator.pending = ""
|
||||
indicator.last = ""
|
||||
state = {"methods": [], "current": "", "name": "", "language": ""}
|
||||
line = json.dumps(state, ensure_ascii=False)
|
||||
if line != previous:
|
||||
print(line, flush=True)
|
||||
previous = line
|
||||
|
||||
def poll():
|
||||
nonlocal timer
|
||||
refresh()
|
||||
timer = GLib.timeout_add(50 if indicator.pending else 500, poll)
|
||||
return GLib.SOURCE_REMOVE
|
||||
|
||||
def command(source, condition):
|
||||
nonlocal buffer, timer
|
||||
data = os.read(sys.stdin.fileno(), 4096)
|
||||
if not data:
|
||||
loop.quit()
|
||||
return GLib.SOURCE_REMOVE
|
||||
buffer += data.decode()
|
||||
while "\n" in buffer:
|
||||
line, buffer = buffer.split("\n", 1)
|
||||
if line == "cycle":
|
||||
try:
|
||||
indicator.select_next()
|
||||
except GLib.Error:
|
||||
indicator.pending = ""
|
||||
refresh()
|
||||
GLib.source_remove(timer)
|
||||
timer = GLib.timeout_add(50 if indicator.pending else 500, poll)
|
||||
return GLib.SOURCE_CONTINUE
|
||||
|
||||
# Fcitx's controller has no current-method-changed signal. Keep one bus
|
||||
# connection open instead of spawning a command on every poll. Polling also
|
||||
# follows per-application input contexts and recovers after service restarts.
|
||||
GLib.io_add_watch(sys.stdin, GLib.IO_IN | GLib.IO_HUP, command)
|
||||
poll()
|
||||
loop.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
|
||||
if sys.argv[1:] == ["cycle"]:
|
||||
cycle(bus)
|
||||
else:
|
||||
watch(bus)
|
||||
except GLib.Error as error:
|
||||
raise SystemExit(str(error))
|
||||
@@ -0,0 +1,5 @@
|
||||
# Installed per-user so D-Bus activation uses the supervised Omarchy unit.
|
||||
[D-BUS Service]
|
||||
Name=org.fcitx.Fcitx5
|
||||
Exec=/usr/bin/fcitx5
|
||||
SystemdService=omarchy-fcitx5.service
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"none": {"label": "Keyboard only", "packages": [], "font": ""},
|
||||
"mozc": {"label": "Japanese (Mozc)", "packages": ["fcitx5-mozc"], "font": "JP"},
|
||||
"hangul": {"label": "Korean (Hangul)", "packages": ["fcitx5-hangul"], "font": "KR"},
|
||||
"pinyin": {"label": "Chinese (Simplified, Pinyin)", "packages": ["fcitx5-chinese-addons"], "font": "SC"},
|
||||
"chewing": {"label": "Chinese (Traditional, Zhuyin)", "packages": ["fcitx5-chewing"], "font": "TC"}
|
||||
}
|
||||
@@ -128,6 +128,7 @@
|
||||
"setup.monitors": {"icon":"","label":"Monitors","action":"omarchy-launch-config-editor \"$HOME/.config/hypr/monitors.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-method": {"icon":"","label":"Input Language","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-setup-input'"},
|
||||
"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"},
|
||||
@@ -191,8 +192,8 @@
|
||||
"setup.security.fido2": {"icon":"","label":"Fido2","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-fido2"},
|
||||
"setup.security.sshd": {"icon":"","label":"SSHD","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-sshd"},
|
||||
"setup.security.ssh-agent": {"icon":"","label":"SSH Agent","when":"! systemctl --user -q is-enabled gcr-ssh-agent.socket","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-ssh-agent"},
|
||||
"setup.security.passwordless-sudo": {"icon":"","label":"Passwordless Sudo","action":"omarchy-launch-floating-terminal-with-presentation omarchy-sudo-passwordless"},
|
||||
"setup.security.sudoless-docker": {"icon":"","label":"Sudoless Docker","when":"omarchy-sudo-docker --configured","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-sudoless-docker"},
|
||||
"setup.security.passwordless-sudo": {"icon":"","label":"Passwordless Sudo","action":"omarchy-launch-floating-terminal-with-presentation omarchy-sudo-passwordless"},
|
||||
"setup.config.hyprland": {"icon":"","label":"Hyprland","action":"omarchy-launch-config-editor \"$HOME/.config/hypr/hyprland.lua\""},
|
||||
"setup.config.hyprsunset": {"icon":"","label":"Hyprsunset","action":"omarchy-launch-config-editor ~/.config/hypr/hyprsunset.conf && omarchy-restart-hyprsunset"},
|
||||
"setup.config.xcompose": {"icon":"","label":"XCompose","action":"omarchy-launch-config-editor ~/.XCompose && omarchy-restart-xcompose"},
|
||||
@@ -243,6 +244,7 @@
|
||||
"install.service.dropbox": {"icon":"","label":"Dropbox","disabled":"omarchy-pkg-present dropbox","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-dropbox"},
|
||||
"install.service.spotify": {"icon":"","label":"Spotify","disabled":"omarchy-pkg-present spotify","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-spotify"},
|
||||
"install.service.signal": {"icon":"","label":"Signal","disabled":"omarchy-pkg-present signal-desktop","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-signal"},
|
||||
"install.service.slack": {"icon":"","label":"Slack","disabled":"omarchy-pkg-present slack-desktop","action":"omarchy-install-and-launch Slack slack-desktop slack"},
|
||||
"install.service.tailscale": {"icon":"","label":"Tailscale","disabled":"omarchy-pkg-present tailscale","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-tailscale"},
|
||||
"install.service.nordvpn": {"icon":"","label":"NordVPN","disabled":"omarchy-pkg-present nordvpn-bin","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-nordvpn"},
|
||||
"install.service.once": {"icon":"","label":"ONCE","disabled":"omarchy-pkg-present once-bin","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-once"},
|
||||
|
||||
@@ -88,6 +88,7 @@
|
||||
"setup.monitors": {"label":"显示器"},
|
||||
"setup.keybindings": {"label":"快捷键"},
|
||||
"setup.input": {"label":"输入设备"},
|
||||
"setup.input-method": {"label":"输入语言"},
|
||||
"setup.network": {"label":"网络"},
|
||||
"setup.network.dns.custom": {"label":"自定义"},
|
||||
"setup.network.qr": {"label":"二维码"},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[Unit]
|
||||
Description=Fcitx5 input method (XCompose sequences)
|
||||
Description=Fcitx5 input methods and XCompose sequences
|
||||
# fcitx5 turns the CapsLock compose sequences in ~/.XCompose into text for
|
||||
# Wayland clients.
|
||||
#
|
||||
@@ -19,7 +19,8 @@ PartOf=graphical-session.target
|
||||
ConditionEnvironment=WAYLAND_DISPLAY
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
Type=dbus
|
||||
BusName=org.fcitx.Fcitx5
|
||||
# notificationitem duplicates the tray entry omarchy already renders itself.
|
||||
ExecStart=/usr/bin/fcitx5 --disable notificationitem
|
||||
# always, not on-failure: fcitx5 exits 0 when it detects another instance owning
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Input methods
|
||||
|
||||
The install keyboard picker selects a console keymap plus an optional Fcitx engine. `install/provisioning/setup-form.sh` is shared by the ISO configurator and deferred first-boot owner setup. Its rows are `label|console keymap|input method|desktop XKB override`; the last two fields are optional, so ordinary keyboard choices keep their existing meaning.
|
||||
|
||||
The ISO carries the extra fields in `omarchy_install.input_method` and `omarchy_install.input_xkb_layout`. Both installation paths persist them in `/etc/omarchy/input-method` as `INPUT_METHOD` and `XKB_LAYOUT`. This file is parsed as data, never sourced. Korean has a real US console keymap and a `kr` desktop override; Japanese uses `jp106`, or US when the US-keyboard choice is selected. Console and disk-unlock input remain ordinary keymaps.
|
||||
|
||||
`install/user/input-method.sh` calls `omarchy-input-method configure --seed` during user finalization, before graphical login. The standard engines are in the base package set so deferred provisioning and factory resets do not need network access to configure typing. `default/input-methods/presets.json` is the engine catalog used by the post-install selector.
|
||||
|
||||
`default/input-methods/configure.py` owns the shared configuration behavior. Offline seeding replaces only a missing profile or Fcitx's untouched single `keyboard-us` guess. The group layout matches Hyprland's effective leading layout and variant; non-Latin layouts retain the US-leading behavior used for desktop bindings. The selected composition engine is the group's `DefaultIM`, with `ActiveByDefault=False` so initial typing remains Latin. Existing multilingual profiles and explicitly chosen keyboard layouts are preserved.
|
||||
|
||||
`omarchy setup input` installs the selected engine, restarts the supervised Fcitx unit so it discovers new addons, and appends the engine through `InputMethodGroupInfo`, `SetInputMethodGroupInfo`, and `Save`. It keeps the group name, layout, existing methods, and their layout overrides. There is no parallel unsupervised daemon. A per-user D-Bus activation file delegates `org.fcitx.Fcitx5` to `omarchy-fcitx5.service`; it is loaded before live restart.
|
||||
|
||||
Shortcut defaults reserve Ctrl+Space for Tmux/Herdr, Super+Space for the menu, Super+Shift+Space for the bar, and Super+grave for the console. Input toggles on Ctrl+Shift+Space, Zenkaku/Hankaku, or Hangul; Henkan activates and Muhenkan deactivates. An existing configured list, including an empty list, is preserved. Stock generated lists are repaired only while the user still has the single-keyboard default profile. A terminal Ctrl+Space returns to direct input before the following key, with a 300 ms timeout and no D-Bus activation; a keyboard-only profile skips this step. Wayland and XCB addons do not override the compositor's XKB settings.
|
||||
|
||||
The migration installs the standard engines and applies conservative defaults. It aligns an untouched Fcitx keyboard profile with the install layout, using the live controller when Fcitx is running. It leaves existing multilingual profiles and working trigger keys alone. Atomic file writes follow configuration symlinks, preserve file modes, and roll back on a failed seed or live setup so a retry can complete. Live rollback stops Fcitx before restoring disk, then reloads activation and restarts the unit. Migration bus errors defer profile alignment to the next login and do not restart input. If a pre-update daemon has not discovered a new engine, the migration aligns only its keyboard and prints the setup command.
|
||||
|
||||
Input selection does not change the system locale, repository region, desktop language, or Latin interface font. A new language setup seeds a CJK fallback preference once; additional languages do not overwrite it.
|
||||
|
||||
Focused checks live in `test/shell.d/input-method-test.sh`, `setup-form-test.sh`, `hyprland-keyboard-layout-test.sh`, and `hyprland-jis-bindings-test.sh`. `test/acceptance.d/input-methods-test.sh` exercises real composition in a disposable installed VM and restores the original user configuration. The ISO repository's keyboard tests cover transport and target persistence. Browser input must be verified against the shipped browser versions; the language selector is not a place to guess flags from older guides.
|
||||
|
||||
Default key values are checked against [Fcitx global configuration](https://github.com/fcitx/fcitx5/blob/master/src/lib/fcitx/globalconfig.cpp).
|
||||
@@ -1,24 +1,28 @@
|
||||
# Temporary passwordless sudo
|
||||
# Passwordless sudo
|
||||
|
||||
`omarchy-sudo-passwordless` publishes a bounded grant for the numeric UID authenticated by sudo. Its user interface runs without a reusable sudo timestamp; fixed installed internal actions run as root and serialize on `/run/lock/omarchy-sudo-passwordless.lock`.
|
||||
`omarchy-sudo-passwordless` publishes a timed or permanent grant for the numeric UID authenticated by sudo. Its user interface inspects sudo policy without prompting and authenticates only the enable action when access is off, without a reusable sudo timestamp; fixed installed internal actions run as root and serialize on `/run/lock/omarchy-sudo-passwordless.lock`.
|
||||
|
||||
The menu bar's `PasswordlessSudo` indicator polls `--active` every five seconds and shows the passwordless sudo icon in red while access is active. The probe normally reads sudo's noninteractive long policy listing for the installed `__status` action and checks its `!authenticate` tag. If listing requires authentication, it falls back to a noninteractive `__status` call that ignores cached credentials. Neither path prompts or updates the credential cache. Interactive setup likewise falls back to noninteractive status and, when authentication is unavailable, leaves validation to the single confirmed enable action. Clicking the active indicator runs `--disable` noninteractively without opening a terminal, so a grant expiring between display and click cannot start the enable flow.
|
||||
|
||||
## Grant lifecycle
|
||||
|
||||
The sudoers rule is the only grant record: it contains the resolved account name and a UTC `NOTAFTER` deadline enforced by sudo itself, including after suspend. Publication validates a dot-prefixed temporary file with `visudo`, arms a calendar cleanup timer, then atomically renames the complete rule into place. There is no separate per-user state file to publish, parse, or reconcile. Failure after renewal starts removes the old grant; failed revocation remains an error and leaves the cleanup timer armed.
|
||||
The sudoers rule is the only grant record. A timed grant contains the resolved account name and a UTC `NOTAFTER` deadline enforced by sudo itself, including after suspend. Publication validates a dot-prefixed temporary file with `visudo`, arms a calendar cleanup timer, then atomically renames the complete rule into place. There is no separate per-user state file to publish, parse, or reconcile. Failure after renewal starts removes the old grant; failed revocation remains an error and leaves the cleanup timer armed.
|
||||
|
||||
An internal status result is `0` for an active, validated grant and `3` for confirmed inactive access. All other results are errors, including failed authentication and failed revocation. The user interface only offers a new grant after result `3`. It must not turn an inspection failure into a claim that no grant exists.
|
||||
An internal status result is `0` for an active, validated grant and `3` for confirmed inactive access. All other results are errors, including failed authentication and failed revocation. The user interface offers a duration picker after result `3`. Result `0` toggles access off when no argument is given, or changes its duration when an argument is given. Permanent access always requires confirmation, including when replacing an active timed grant. It must not turn an inspection failure into a claim that no grant exists.
|
||||
|
||||
Calendar timers clean up expired files; their liveness does not define authorization. Callbacks read the current rule and remove it only when expired. Earlier callbacks cannot shorten a renewed grant, so no timer identity needs to be persisted. Old UID-only and token-bearing callbacks remain accepted. Pending callbacks after renewal or manual disable are harmless and expire within the maximum 24-hour grant window. Boot-time tmpfiles cleanup removes the reserved generated filename namespace before users log in; routine non-boot tmpfiles maintenance leaves live grants alone.
|
||||
|
||||
Legacy cleanup uses a root-owned machine marker under `/var/lib/omarchy/migrations/`, written only after successful cleanup under the grant lock. Later accounts can finish their migration queues without sudo and without revoking grants created after the repair. Old grant state files are no longer consulted. A legacy grant is recognized by its exact filename and rule relationship, since the old command wrote the caller's unvalidated name into both, so accounts outside the current name policy are still cleaned up. The generated filename prefix is reserved: boot cleanup and the package hook already remove everything under it, and the old writer could emit a rule whose body differs from its filename, so the migration moves any other file found there into a fresh root-only directory under `/var/lib/omarchy/sudoers-quarantine/`, as `policy` with the original name stored beside it, rather than leaving it live or deleting its content.
|
||||
|
||||
Permanent grants live under `/etc/sudoers.d/99-omarchy-permanent-nopasswd-<uid>`, outside the temporary cleanup namespace. They have no `NOTAFTER` deadline or timer. Status and disable handle both forms; switching duration publishes the replacement before removing the prior policy under the same lock. Old expiry callbacks leave permanent grants intact. If an untrappable interruption leaves both rules, a pair of trusted generated rules for the same account remains inspectable and revocable. Permanent policy continues to govern until an explicit disable or completed duration change; callbacks never remove it merely because the timed companion has expired. Mismatched accounts, symlinks, and administrator-modified companion rules remain errors. Permanent policy survives reboot and package upgrade or removal; disable it before uninstalling the command or downgrading to a version without permanent-grant support, or remove its sudoers file as an administrator afterward.
|
||||
|
||||
## Package ownership
|
||||
|
||||
The packaging companion must put the publication/expiry command, `omarchy-security-functions`, `omarchy-nopasswd-sudo.conf`, and the pre-transaction revocation hook in the settings package together. Removing the desktop runtime alone must leave a working expiry command behind. Stable and development package pairs must transfer ownership in one transaction without duplicate files.
|
||||
|
||||
Before settings removal or upgrade, the installed ALPM `PreTransaction` hook invokes the fixed `__package-removing` action, acquires the same grant lock, sets `/run/omarchy-sudo-passwordless-package-removing` and revokes existing policy. The marker prevents a waiting publisher from creating a new grant while package files change. A successful installation clears the marker only after boot cleanup exists. The hook uses `AbortOnFail` because a scriptlet failure alone does not abort pacman. The scriptlets repeat cleanup as a fallback for upgrades from older packages that have no installed hook. New grants require both the boot rule and hook before publication. Failed or interrupted transactions leave the marker set; retry the package transaction successfully before requesting another grant.
|
||||
Before settings removal or upgrade, the installed ALPM `PreTransaction` hook invokes the fixed `__package-removing` action, acquires the same grant lock, sets `/run/omarchy-sudo-passwordless-package-removing` and revokes existing temporary policy. The marker prevents a waiting publisher from creating a new timed grant while package files change. A successful installation clears the marker only after boot cleanup exists. The hook uses `AbortOnFail` because a scriptlet failure alone does not abort pacman. The scriptlets repeat cleanup as a fallback for upgrades from older packages that have no installed hook. New timed grants require both the boot rule and hook before publication. Failed or interrupted transactions leave the marker set; retry the package transaction successfully before requesting another timed grant.
|
||||
|
||||
The runtime marker need not survive reboot: pre-removal revokes the old grants before package files disappear, and a new invocation independently verifies boot cleanup. Both root operations use fixed machine paths. The marker is not a user-controlled mode switch.
|
||||
The runtime marker need not survive reboot: pre-removal revokes the old timed grants before package files disappear, and a new timed grant independently verifies boot cleanup. Both root operations use fixed machine paths. The marker is not a user-controlled mode switch.
|
||||
|
||||
## Validation
|
||||
|
||||
|
||||
@@ -136,7 +136,7 @@ omarchy-update
|
||||
├─ omarchy-update-stay-awake start
|
||||
├─ run system-package updates
|
||||
├─ run migrations
|
||||
├─ run orphan review and log analysis
|
||||
├─ automatically remove orphan packages and run log analysis
|
||||
├─ omarchy-update-status
|
||||
│ └─ refresh or clear the shell update indicator
|
||||
├─ restart marked services and the shell
|
||||
@@ -158,7 +158,7 @@ Important behavior:
|
||||
- This lifecycle controls authorization created by the protected workflow. `sudo -N` prevents cache updates but can use an existing valid credential, and `sudo -k` revokes the current session's timestamp. It does not isolate the account from unrelated concurrent authentication in another workflow.
|
||||
- Sleep inhibition authenticates before detaching, drops the held command back to the caller, and closes both update lock descriptors before the persistent process starts. Cleanup accepts only caller-owned, mode-0600, single-link state and revalidates the recorded PID, process start time, owner, and random token immediately before every signal.
|
||||
- Channel switching establishes the same boundary before dev link/unlink, refresh and package operations. It keeps the wrapper first when changing source roots, carries the original user PATH into update hooks and mise, and checks after each package transaction that the wrapper still exists before any further privileged step, since a transaction can replace the running tree with a release that predates it; when it is gone, or the destination otherwise lacks it, the switch stops after the package switch with instructions to run that release's update from a fresh session rather than letting a bare `sudo` or an updater that authenticates without `--no-update` publish a timestamp. Failed and interrupted channel switches revoke on exit.
|
||||
- `-y` exports `OMARCHY_UPDATE_UNATTENDED=1` and suppresses Omarchy confirmation prompts. Interactive review steps (orphan removal, conflict handoff) report and skip instead of blocking. Privileged commands still require the one sudo authorization, and AUR installs can prompt separately.
|
||||
- `-y` exports `OMARCHY_UPDATE_UNATTENDED=1` and suppresses Omarchy confirmation prompts. Conflict handoff reports and skips instead of blocking. Orphan packages are automatically removed during every update. Privileged commands still require the one sudo authorization, and AUR installs can prompt separately.
|
||||
- The free-space requirement uses a 10 GiB threshold and stops the update before
|
||||
confirmation when it is not met. If free space cannot be determined, the
|
||||
check is silently skipped. Set `OMARCHY_UPDATE_FORCE=1` to bypass the check.
|
||||
@@ -306,7 +306,7 @@ scripts.
|
||||
| `omarchy-update-available` | Update checker for shell widget and post-update refresh. | **Keep.** Could eventually be renamed `omarchy-update-check`, but current name matches widget semantics. |
|
||||
| `omarchy-update-aur-pkgs` | Updates AUR packages with `yay -Sua` if foreign packages exist and AUR is reachable. | **Question.** Omarchy is package-backed now, but users may still install AUR packages. Keep for now. |
|
||||
| `omarchy-update-mise` | Runs `MISE_MINIMUM_RELEASE_AGE=0 mise up` for mise-managed tools — the override of mise's release-age cooldown is the point. | **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 unless passed `-y`, as the update pipeline does; standalone noninteractive mode only reports. | **Keep for now.** Automates cleanup during updates and supports standalone review. |
|
||||
| `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` | Restarts components selected by `restart-*-required` markers, always restarts the shell, and prompts for reboot after kernel/Hyprland updates. Internal phase flags let the update finish sudo-capable restarts before user hooks and defer only the unprivileged reboot prompt. | **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. |
|
||||
|
||||
@@ -23,8 +23,6 @@ global)
|
||||
done
|
||||
;;
|
||||
cn)
|
||||
# Packages first, so a failed install leaves the machine Global.
|
||||
omarchy-pkg-add fcitx5-configtool fcitx5-rime librime opencc rime-ice-installer
|
||||
for link in "${cn_links[@]}"; do
|
||||
read -r _ target <<<"$link"
|
||||
if [[ -e $target && ! -L $target ]]; then
|
||||
|
||||
@@ -18,6 +18,7 @@ run_logged "$OMARCHY_INSTALL/hardware/intel/ipu7-camera.sh"
|
||||
run_logged "$OMARCHY_INSTALL/hardware/intel/fred.sh"
|
||||
run_logged "$OMARCHY_INSTALL/hardware/intel/fix-wifi7-eht.sh"
|
||||
run_logged "$OMARCHY_INSTALL/hardware/intel/sof-firmware.sh"
|
||||
run_logged "$OMARCHY_INSTALL/hardware/dell-xps13-ptl-speaker-firmware.sh"
|
||||
|
||||
run_logged "$OMARCHY_INSTALL/hardware/fix-elgato-camlink-4k.sh"
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
if omarchy-hw-dell-xps13-dx13260-ptl; then
|
||||
omarchy-pkg-add linux-firmware-cirrus-dx13260
|
||||
fi
|
||||
@@ -21,12 +21,12 @@ cups
|
||||
cups-filters
|
||||
cups-pk-helper
|
||||
ddcutil
|
||||
disktree-bin
|
||||
docker
|
||||
docker-buildx
|
||||
docker-compose
|
||||
dosfstools
|
||||
dotnet-runtime
|
||||
dua-cli
|
||||
evince
|
||||
exfatprogs
|
||||
expac
|
||||
@@ -34,7 +34,11 @@ eza
|
||||
fakeroot
|
||||
fastfetch
|
||||
fcitx5
|
||||
fcitx5-chewing
|
||||
fcitx5-chinese-addons
|
||||
fcitx5-gtk
|
||||
fcitx5-hangul
|
||||
fcitx5-mozc
|
||||
fcitx5-qt
|
||||
fd
|
||||
ffmpeg
|
||||
|
||||
@@ -72,11 +72,3 @@ t2fanrd
|
||||
|
||||
# Framework 16
|
||||
qmk-hid
|
||||
|
||||
# China region input method (install/config/region.sh)
|
||||
dialog
|
||||
fcitx5-configtool
|
||||
fcitx5-rime
|
||||
librime
|
||||
opencc
|
||||
rime-ice-installer
|
||||
@@ -17,7 +17,10 @@
|
||||
# only keys any gum widget exits on, and Ctrl+C arrives as a byte in raw mode,
|
||||
# so it never reaches the shell as SIGINT. Act on the status, never on a trap.
|
||||
#
|
||||
# Callers supply `notice <message> <seconds>` and set every variable these prompts write.
|
||||
# Callers supply `notice <message> <seconds>` for validation feedback, and set
|
||||
# the variables these prompts write: keyboard, keyboard_label,
|
||||
# keyboard_input_method, keyboard_xkb_layout, username,
|
||||
# password, password_confirmation, full_name, email_address, hostname, timezone.
|
||||
|
||||
OMARCHY_FORM_BACK=1
|
||||
OMARCHY_FORM_SIGNAL=130
|
||||
@@ -35,6 +38,8 @@ Azerbaijani|azerty
|
||||
Belarusian|by
|
||||
Belgian|be-latin1
|
||||
Bulgarian|bg-cp1251
|
||||
Chinese (Simplified, Pinyin)|us|pinyin
|
||||
Chinese (Traditional, Zhuyin)|us|chewing
|
||||
Croatian|croat
|
||||
Czech|cz
|
||||
Danish|dk-latin1
|
||||
@@ -53,8 +58,10 @@ Hungarian|hu
|
||||
Icelandic|is-latin1
|
||||
Irish|ie
|
||||
Italian|it
|
||||
Japanese|jp106
|
||||
Japanese|jp106|mozc
|
||||
Japanese (US keyboard)|us|mozc
|
||||
Kazakh|kazakh
|
||||
Korean|us|hangul|kr
|
||||
Kyrgyz|kyrgyz
|
||||
Lao|la-latin1
|
||||
Latvian|lv
|
||||
@@ -146,8 +153,22 @@ omarchy_prompt_keyboard() {
|
||||
gum choose --height 10 --selected "English (US)" --header "$(omarchy_text "Select keyboard layout")") && status=0 || status=$?
|
||||
((status == 0)) || return $status
|
||||
|
||||
keyboard_label="$choice"
|
||||
keyboard=$(printf '%s\n' "$OMARCHY_KEYBOARD_LAYOUTS" | awk -F'|' -v c="$choice" '$1==c{print $2; exit}')
|
||||
local selection
|
||||
selection=$(printf '%s\n' "$OMARCHY_KEYBOARD_LAYOUTS" | awk -F'|' -v c="$choice" '$1==c{print; exit}')
|
||||
[[ -n $selection ]] || return 1
|
||||
IFS='|' read -r keyboard_label keyboard keyboard_input_method keyboard_xkb_layout <<< "$selection"
|
||||
keyboard_input_method=${keyboard_input_method:-none}
|
||||
keyboard_xkb_layout=${keyboard_xkb_layout:-}
|
||||
}
|
||||
|
||||
# Root-side persistence. Console keymaps remain real keymaps; the input
|
||||
# preference survives separately for every user of the installed system.
|
||||
omarchy_persist_input_selection() {
|
||||
local target_root=${1:-} method=${keyboard_input_method:-none} layout=${keyboard_xkb_layout:-}
|
||||
case "$method" in none | mozc | hangul | pinyin | chewing) ;; *) return 1 ;; esac
|
||||
case "$layout" in "" | kr) ;; *) return 1 ;; esac
|
||||
mkdir -p "$target_root/etc/omarchy"
|
||||
printf 'INPUT_METHOD=%s\nXKB_LAYOUT=%s\n' "$method" "$layout" > "$target_root/etc/omarchy/input-method"
|
||||
}
|
||||
|
||||
omarchy_prompt_username() {
|
||||
|
||||
+1
-1
@@ -2,6 +2,7 @@ run_logged "$OMARCHY_INSTALL/user/theme.sh"
|
||||
run_logged "$OMARCHY_INSTALL/user/chromium.sh"
|
||||
run_logged "$OMARCHY_INSTALL/user/git.sh"
|
||||
run_logged "$OMARCHY_INSTALL/user/xcompose.sh"
|
||||
run_logged "$OMARCHY_INSTALL/user/input-method.sh"
|
||||
run_logged "$OMARCHY_INSTALL/user/mise-work.sh"
|
||||
|
||||
run_logged "$OMARCHY_INSTALL/user/hardware/asus/fix-audio-mixer.sh"
|
||||
@@ -13,4 +14,3 @@ run_logged "$OMARCHY_INSTALL/user/hardware/vm-no-animations.sh"
|
||||
|
||||
run_logged "$OMARCHY_INSTALL/user/default-keyring.sh"
|
||||
run_logged "$OMARCHY_INSTALL/user/mise.sh"
|
||||
run_logged "$OMARCHY_INSTALL/user/region.sh"
|
||||
@@ -11,6 +11,8 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
omarchy-input-method configure --defaults || echo "Input method defaults deferred; run omarchy setup input after login." >&2
|
||||
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now \
|
||||
bt-agent.service \
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
omarchy-input-method configure --seed
|
||||
@@ -1,9 +0,0 @@
|
||||
region=$(omarchy-region)
|
||||
|
||||
if [[ $region == "China" ]]; then
|
||||
if [[ $OMARCHY_SETUP_CONTEXT == "iso-chroot" ]]; then
|
||||
rime-ice-installer --yes --asset-dir /opt/packages/rime-ice
|
||||
else
|
||||
echo "Skipping Rime Ice setup in $OMARCHY_SETUP_CONTEXT: only the ISO bundles its assets"
|
||||
fi
|
||||
fi
|
||||
+2
-2
@@ -42,7 +42,7 @@ There are terminal shortcuts too: `a` runs the default agent inline in the curre
|
||||
|
||||
### The agents panel
|
||||
|
||||
The top bar has an agents icon. Before you've set up an agent, its panel walks you through signing in to Claude, Codex, or Grok, and the first one becomes your default agent. After that, the panel puts every subscription on one page: each agent and account with its plan and the percentage used of its 5-hour session and weekly limits (or the remaining prepaid balance), under a line that rotates through what you've used across all of them, like tokens this week and your most used model. Under _Make something_ it offers starter prompts for a new theme, plugin, or app that hand the task to your default agent. Apps are built the way Omarchy's own Hype, Monologue, and Omacut are, in C++ and Qt Quick, following the omarchy-app agent skill, and the compiler and Qt pieces they need come installed. Claude Code, Codex, Grok, and Fireworks are covered out of the box.
|
||||
The top bar has an agents icon. Before you've set up an agent, its panel walks you through signing in to Claude, Codex, or Grok, and the first one becomes your default agent. After that, the panel puts every subscription on one page: each agent and account with its plan, a meter for each of its 5-hour session and weekly limits with the time until it resets (hover for the exact percentage), or the remaining prepaid balance, under a line that rotates through what you've used across all of them, like tokens this week and your most used model. Under _Make something_ it offers starter prompts for a new theme, plugin, or app that hand the task to your default agent. Apps are built the way Omarchy's own Hype, Monologue, and Omacut are, in C++ and Qt Quick, following the omarchy-app agent skill, and the compiler and Qt pieces they need come installed. Claude Code, Codex, Grok, and Fireworks are covered out of the box.
|
||||
|
||||
Left-click the bar icon for the panel, right-click to launch your default agent. The usage records behind it are regenerated every 15 minutes by `omarchy agent usage-update`, and the panel can even merge usage from your other machines via a synced folder. See the README under `$OMARCHY_PATH/shell/plugins/agents/` for the full settings.
|
||||
|
||||
@@ -54,7 +54,7 @@ One account per provider is active, and every new session starts as it: `cx`, `c
|
||||
|
||||
With more than one account, the panel shows each account's limits side by side, with the active one marked. By default Omarchy notifies you when the active account passes 95% of a limit, and clicking the notification switches to the account with the most headroom. Hover an account's line in the panel and click the Autoswitch that appears beside Use (or press `m`), or run `omarchy agent account mode auto`, to have it switch on its own instead; while it's on, Autoswitch takes Use's place, and clicking it again goes back to notifying. Add a number to change the threshold, as in `omarchy agent account mode auto 90`. If every account is over the threshold, you'll hear about it once, along with which account frees up first. While an active account is within 15 points of its threshold (80% at the default), its limits are checked every three minutes rather than every 15.
|
||||
|
||||
`omarchy agent account list` shows every account and its latest limits, `omarchy agent account remove work` forgets one, and `omarchy agent account rename primary Hey` renames one (or click an account's name in the panel). These commands work on your default agent's accounts; to reach the other provider, name it first, as in `omarchy agent account use codex side`. The login you already had in `~/.claude` or `~/.codex` is the _Main_ account and stays where it is. An account left unused for more than about eight hours shows its last known limits until a session runs in it again. MCP servers you had when an account was added come along to it, but ones you add later with `claude mcp add` only reach the account you added them in. Claude Desktop and tools configured with an absolute path to a CLI binary do not follow the switch. An explicit `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, or `GROK_HOME` takes precedence over the selected account.
|
||||
`omarchy agent account list` shows every account and its latest limits, `omarchy agent account remove work` forgets one, and `omarchy agent account rename primary Hey` renames one (or click an account's name in the panel). These commands work on your default agent's accounts; to reach the other provider, name it first, as in `omarchy agent account use codex side`. The login you already had in `~/.claude` or `~/.codex` is the _Main_ account and stays where it is. An account left unused for more than about eight hours can show _Sign-in required_; click it to sign that account in again right in the panel, or start a session in it, which refreshes the sign-in on its own. MCP servers you had when an account was added come along to it, but ones you add later with `claude mcp add` only reach the account you added them in. Claude Desktop and tools configured with an absolute path to a CLI binary do not follow the switch. An explicit `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, or `GROK_HOME` takes precedence over the selected account.
|
||||
|
||||
### Crash diagnosis
|
||||
|
||||
|
||||
@@ -26,10 +26,6 @@ You start it (or reattach to your existing session) with `Super + Ctrl + Return`
|
||||
|
||||
Omarchy has packaged this as _About_ in the Omarchy menu (`Super + Space`).
|
||||
|
||||
## Disk Usage
|
||||
|
||||
When the drive fills up and you have no idea what's eating it, launch _Disk Usage_ from the app launcher (`Super + Space`). It's [dua](https://github.com/Byron/dua-cli) in interactive mode pointed at the whole file system, so you can walk down into whatever directory is the culprit, sorted biggest first, and delete from right inside it.
|
||||
|
||||
## Cliamp
|
||||
|
||||
[Cliamp](https://www.cliamp.stream/) is a retro terminal music player inspired by Winamp 2.x, complete with built-in radio stations for lo-fi beats. Launch it with `Super + Shift + Alt + M`, or from the Omarchy menu under _Apps_. Press `?` for the full keybinding list.
|
||||
|
||||
@@ -8,6 +8,10 @@ Plug in a USB stick or an SD card and it's mounted automatically, so it just sho
|
||||
|
||||
Double-clicking follows sensible defaults: images open in imv, video in mpv, PDFs in Document Viewer, and plain text in Neovim.
|
||||
|
||||
## Disktree
|
||||
|
||||
When the drive fills up and you have no idea what's eating it, launch [Disktree](https://github.com/tobi/disktree) from the app launcher (`Super + Space`). It shows a graphical treemap of your home directory. Larger blocks take up more space; walk into directories with the keyboard or mouse, mark what should go, and review your selections before removing anything. Run `disktree --disk` to scan the whole disk.
|
||||
|
||||
## Obsidian
|
||||
|
||||
[Obsidian](https://obsidian.md/) is a free and highly extensible note taking application that uses simple Markdown files for storage.
|
||||
|
||||
@@ -18,6 +18,10 @@ You start 1Password with `Super + Shift + /`. If it isn't installed yet, that ho
|
||||
|
||||
You start Spotify using `Super + Shift + M`. Like 1Password, the hotkey kicks off the installation first if Spotify isn't installed yet (or use _Install > Service > Spotify_ from the Omarchy menu).
|
||||
|
||||
## Slack
|
||||
|
||||
[Slack](https://slack.com/) is where a lot of teams do their chatting, both inside the company and with outside collaborators. Install it with _Install > Service > Slack_ from the Omarchy menu, and it'll open as soon as it's ready.
|
||||
|
||||
## Dropbox
|
||||
|
||||
[Dropbox](https://www.dropbox.com/) is a great way to sync files between machines while keeping a backup in the cloud. To set it up, select _Install > Service > Dropbox_ from the Omarchy menu. Once it's running, hover the tray in the top right of the bar and right-click the Dropbox icon to finish the setup.
|
||||
|
||||
@@ -59,7 +59,15 @@ On Dell XPS laptops with a haptic touchpad, you can also set the click strength
|
||||
|
||||
### Typing in Chinese, Japanese, and other languages
|
||||
|
||||
Omarchy runs the [fcitx5](https://fcitx-im.org/) input method framework as part of every session — it's what powers the CapsLock compose sequences. That means the plumbing for non-Latin input is already in place: install an input engine like `fcitx5-mozc` (Japanese) or `fcitx5-chinese-addons` (Chinese) with `omarchy pkg add`, plus `fcitx5-configtool` to add the engine to your input methods and set the key that switches between them.
|
||||
Select Japanese, Korean, Chinese (Simplified, Pinyin), or Chinese (Traditional, Zhuyin) when the installer asks for your keyboard. Omarchy configures the keyboard and matching input engine automatically, including during offline first-boot setup. Japanese uses Mozc, Korean uses standard two-set Hangul, Simplified Chinese uses Pinyin, and Traditional Chinese uses Chewing's standard Zhuyin arrangement. Japanese (US keyboard) provides Mozc with a US keyboard layout.
|
||||
|
||||
Typing starts in Latin mode. Press `Ctrl + Shift + Space` to switch input on or off. On Japanese keyboards, 全角/半角 toggles input, 変換 turns it on, and 無変換 turns it off. On a Korean keyboard layout, dedicated 한/영 and 한자 keys switch Hangul and convert to Hanja. `Ctrl + Space` stays available for Tmux and Herdr; CapsLock stays the compose key.
|
||||
|
||||
The keyboard indicator in the menu bar appears for non-English keyboards and whenever you have multiple layouts or input modes. Click it to switch input languages; Japanese displays あ for hiragana. With only one input mode, clicking opens input language setup. For Japanese character choices, switch to Japanese, type a word such as `nihongo`, then press Space twice. Press Enter to accept your choice. If you also have multiple keyboard layouts, right-click the indicator to cycle those.
|
||||
|
||||
To add another language later, use _Setup > Input Language_ or run `omarchy setup input` (or `omarchy setup input mozc`, `hangul`, `pinyin`, or `chewing`). Your existing input methods and custom switching keys are preserved. For alternative engine layouts or keys, install `fcitx5-configtool` with `omarchy pkg add fcitx5-configtool` and launch it from a terminal. Selecting an input language does not change the desktop language.
|
||||
|
||||
On keyboards without dedicated input keys, use `Ctrl + Shift + Space`. This input shortcut takes precedence over app shortcuts such as code completion; you can change it in `fcitx5-configtool`. Tapping Shift alone is not an input switch: Omarchy uses both Shift keys for CapsLock, which changes how input methods see Shift release. Input engines and the CapsLock compose sequences use the [Fcitx5](https://fcitx-im.org/) service already running in every session.
|
||||
|
||||
### Use ALT as SUPER
|
||||
|
||||
|
||||
@@ -20,7 +20,9 @@ It works by restoring the baseline snapshot the installer takes, so it's only av
|
||||
|
||||
## Passwordless sudo
|
||||
|
||||
Sometimes you want `sudo` to stop asking, most often when an AI agent is doing a long stretch of system work for you. _Setup > Security > Passwordless Sudo_ turns that off for 15 wall-clock minutes and then puts it back automatically, including immediately after resuming from a suspend that crossed the deadline. A package-owned boot-time cleanup rule removes the grant before logins if the computer restarts first. Run the command again before the timer runs out to end it early, and pass your own number of minutes (from 1 to 1440) with `omarchy-sudo-passwordless 30` if 15 isn't enough.
|
||||
Sometimes you want `sudo` to stop asking, most often when an AI agent is doing a long stretch of system work for you. _Setup > Security > Passwordless Sudo_ asks how long to allow access: **15 minutes**, **1 Hour**, **1 Day**, or **Permanently**. A red warning icon appears beside the other menu bar indicators while access is active. Click it or run the command again to turn access off. You can also pass your own number of minutes (from 1 to 1440) with `omarchy-sudo-passwordless 30`, or use `omarchy-sudo-passwordless permanent`.
|
||||
|
||||
Timed access expires automatically, including immediately after resuming from a suspend that crossed the deadline. Restarting the computer ends it early. Permanent access survives reboots and stays enabled until you disable it.
|
||||
|
||||
Updating or removing Omarchy's settings package ends any temporary grant before its expiry support changes. If the command reports an authorization or cleanup error, resolve it before trying to enable another grant; an error does not mean passwordless access is inactive.
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ fi
|
||||
drop_npm_grok() {
|
||||
omarchy-cmd-present mise || return 0
|
||||
if [[ -n $(mise ls -g "$npm_grok") ]]; then
|
||||
mise unuse -g "$npm_grok"
|
||||
mise unuse -y -g "$npm_grok"
|
||||
fi
|
||||
if [[ -n $(mise ls -i "$npm_grok") ]]; then
|
||||
mise uninstall -y --all "$npm_grok"
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
echo "Install the Dell XPS 13 Panther Lake speaker firmware"
|
||||
|
||||
if omarchy-hw-dell-xps13-dx13260-ptl; then
|
||||
source "$OMARCHY_PATH/install/hardware/dell-xps13-ptl-speaker-firmware.sh"
|
||||
omarchy-state set reboot-required
|
||||
fi
|
||||
@@ -0,0 +1,9 @@
|
||||
echo "Replace the Disk Usage TUI with Disktree"
|
||||
|
||||
omarchy-pkg-add disktree-bin
|
||||
omarchy-pkg-drop dua-cli
|
||||
|
||||
rm -f "$HOME/.local/share/applications/Disk Usage.desktop"
|
||||
if [[ -d $HOME/.local/share/applications ]]; then
|
||||
update-desktop-database "$HOME/.local/share/applications"
|
||||
fi
|
||||
@@ -0,0 +1,4 @@
|
||||
echo "Configure shared input-method defaults and free desktop shortcuts"
|
||||
|
||||
omarchy-pkg-add fcitx5-mozc fcitx5-hangul fcitx5-chinese-addons fcitx5-chewing
|
||||
omarchy-input-method configure --defaults
|
||||
+10
-1
@@ -601,5 +601,14 @@ var table = {
|
||||
"Pause": "暂停",
|
||||
"Play": "播放",
|
||||
"Next": "下一首",
|
||||
"Previous": "上一首"
|
||||
"Previous": "上一首",
|
||||
"Disable Passwordless Sudo": "关闭免密码 sudo",
|
||||
"Passwordless Sudo": "免密码 sudo",
|
||||
"Could not disable passwordless sudo": "无法关闭免密码 sudo",
|
||||
"Check the sudo configuration and try again.": "请检查 sudo 配置后重试。",
|
||||
"Click to set up an input language": "点击设置输入语言",
|
||||
"Click to switch input language": "点击切换输入语言",
|
||||
"Ctrl + Shift + Space to toggle input": "Ctrl + Shift + Space 切换输入",
|
||||
"Japanese: type a word, then Space twice for character choices": "日语:输入词语后按两次空格选字",
|
||||
"Right-click to switch keyboard layout": "右键切换键盘布局"
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
pragma Singleton
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
property var state: ({})
|
||||
|
||||
function cycle() {
|
||||
if (watcher.running) watcher.write("cycle\n")
|
||||
}
|
||||
|
||||
// One reader and pending selection for every monitor's keyboard widget.
|
||||
Process {
|
||||
id: watcher
|
||||
command: ["/usr/bin/python", Quickshell.env("OMARCHY_PATH") + "/default/input-methods/indicator.py"]
|
||||
running: true
|
||||
stdinEnabled: true
|
||||
stdout: SplitParser {
|
||||
onRead: data => {
|
||||
try { root.state = JSON.parse(data) } catch (e) {}
|
||||
}
|
||||
}
|
||||
onExited: {
|
||||
root.state = ({})
|
||||
restart.restart()
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: restart
|
||||
interval: 5000
|
||||
onTriggered: watcher.running = true
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ singleton Border 1.0 Border.qml
|
||||
singleton Color 1.0 Color.qml
|
||||
singleton I18n 1.0 I18n.qml
|
||||
singleton IpcRegistry 1.0 IpcRegistry.qml
|
||||
singleton InputMethodState 1.0 InputMethodState.qml
|
||||
singleton Style 1.0 Style.qml
|
||||
singleton Util 1.0 Util.qml
|
||||
ShellIpc 1.0 ShellIpc.qml
|
||||
@@ -19,8 +19,9 @@ Every subscription on one page, limits first.
|
||||
per limit window: its meter and the time until it resets (the exact percentage
|
||||
on hover). A model-scoped allowance on the same clock (Claude's Fable weekly
|
||||
limit) is a tick on that window's meter rather than a line of its own; the
|
||||
row's tooltip names it. Sign-in and endpoint trouble shows under the name in the urgent
|
||||
color. Limits kept from an earlier check after a failed one dim, and their
|
||||
row's tooltip names it. A lapsed or missing sign-in shows a _Sign-in required_ link that signs
|
||||
that account in again from the panel; other endpoint trouble shows under the
|
||||
name in the urgent color. Limits kept from an earlier check after a failed one dim, and their
|
||||
tooltip says how old they are.
|
||||
- **Accounts** — an agent with more than one subscription account (see
|
||||
`omarchy agent account`) lists each: name and plan on one line (the email on hover), and its own limit
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
|
||||
BarIndicator {
|
||||
id: root
|
||||
|
||||
property bool granted: false
|
||||
property bool refreshPending: false
|
||||
|
||||
active: granted
|
||||
activeText: ""
|
||||
inactiveText: ""
|
||||
activeTooltipText: I18n.tr("Disable Passwordless Sudo")
|
||||
inactiveTooltipText: I18n.tr("Passwordless Sudo")
|
||||
useActiveColor: true
|
||||
activeColor: Color.urgent
|
||||
|
||||
function refresh() {
|
||||
if (!root.bar) return
|
||||
if (statusProc.running) {
|
||||
root.refreshPending = true
|
||||
return
|
||||
}
|
||||
root.refreshPending = false
|
||||
statusProc.running = true
|
||||
}
|
||||
|
||||
onBarChanged: refresh()
|
||||
Component.onCompleted: refresh()
|
||||
|
||||
Connections {
|
||||
target: root.indicatorHost
|
||||
ignoreUnknownSignals: true
|
||||
function onRefreshRequested() { root.refresh() }
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: 5000
|
||||
repeat: true
|
||||
running: !!root.bar
|
||||
onTriggered: root.refresh()
|
||||
}
|
||||
|
||||
Process {
|
||||
id: statusProc
|
||||
command: ["omarchy-sudo-passwordless", "--active"]
|
||||
onExited: function(exitCode, exitStatus) {
|
||||
root.granted = exitCode === 0 && exitStatus === 0
|
||||
if (root.refreshPending) Qt.callLater(root.refresh)
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: disableProc
|
||||
command: ["omarchy-sudo-passwordless", "--disable"]
|
||||
onExited: function(exitCode, exitStatus) {
|
||||
if ((exitCode !== 0 || exitStatus !== 0) && root.bar)
|
||||
Quickshell.execDetached([Quickshell.env("OMARCHY_PATH") + "/bin/omarchy-notification-send", I18n.tr("Could not disable passwordless sudo"), I18n.tr("Check the sudo configuration and try again.")])
|
||||
if (root.indicatorHost) root.indicatorHost.refresh()
|
||||
else root.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
onPressed: function() {
|
||||
if (!root.bar || disableProc.running) return
|
||||
if (root.granted) disableProc.running = true
|
||||
else root.bar.run("omarchy-launch-floating-terminal-with-presentation omarchy-sudo-passwordless")
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,11 @@
|
||||
"placeholderText": "Search indicators...",
|
||||
"emptyText": "No indicators",
|
||||
"options": [
|
||||
{
|
||||
"value": "PasswordlessSudo",
|
||||
"label": "Passwordless sudo",
|
||||
"description": "Warning while passwordless root access is enabled"
|
||||
},
|
||||
{
|
||||
"value": "Dictation",
|
||||
"label": "Dictation",
|
||||
|
||||
@@ -8,7 +8,7 @@ BarWidget {
|
||||
id: root
|
||||
moduleName: "omarchy.indicators"
|
||||
|
||||
readonly property var defaultIndicatorEntries: [ "Dictation", "ScreenRecording", "Reminder", "NightLight", "Dnd", "StayAwake" ]
|
||||
readonly property var defaultIndicatorEntries: [ "PasswordlessSudo", "Dictation", "ScreenRecording", "Reminder", "NightLight", "Dnd", "StayAwake" ]
|
||||
readonly property var indicatorEntries: indicatorEntriesFromSettings(settings)
|
||||
property var activeIndicatorIds: []
|
||||
property var indicatorActiveStates: ({})
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"name": "Keyboard layout",
|
||||
"version": "1.0.0",
|
||||
"author": "Omarchy",
|
||||
"description": "Current xkb layout, click cycles",
|
||||
"description": "Current keyboard layout and input language, click cycles",
|
||||
"kinds": [
|
||||
"bar-widget"
|
||||
],
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"barWidget": {
|
||||
"displayName": "Keyboard layout",
|
||||
"description": "Current xkb layout, click cycles",
|
||||
"description": "Current keyboard layout and input language, click cycles",
|
||||
"category": "Compositor",
|
||||
"allowMultiple": false
|
||||
},
|
||||
|
||||
@@ -36,6 +36,9 @@ BarWidget {
|
||||
// xkb's own table rather than maintained by hand.
|
||||
property var layoutBriefs: ({})
|
||||
readonly property string layoutLabel: KeyboardLayoutModel.shortLabel(layoutFull, layoutBriefs)
|
||||
readonly property var inputState: InputMethodState.state
|
||||
readonly property bool multipleInputs: (inputState.methods || []).length > 1
|
||||
readonly property string inputLabel: KeyboardLayoutModel.inputLabel(inputState, layoutLabel)
|
||||
|
||||
// A query already in flight was started before this event, so it may read the
|
||||
// layout the switch replaced. Remember the request and re-run once it lands
|
||||
@@ -228,7 +231,7 @@ BarWidget {
|
||||
onTriggered: root.refresh()
|
||||
}
|
||||
|
||||
visible: layoutLabel !== "" && multipleLayouts
|
||||
visible: inputLabel !== "" && KeyboardLayoutModel.showIndicator(layoutLabel, multipleLayouts, multipleInputs)
|
||||
implicitWidth: button.implicitWidth
|
||||
implicitHeight: button.implicitHeight
|
||||
|
||||
@@ -236,10 +239,18 @@ BarWidget {
|
||||
id: button
|
||||
anchors.fill: parent
|
||||
bar: root.bar
|
||||
text: root.layoutLabel
|
||||
text: root.inputLabel
|
||||
fontSize: Style.font.caption
|
||||
horizontalMargin: 6
|
||||
tooltipText: root.layoutFull
|
||||
onPressed: function() { root.cycleLayout() }
|
||||
tooltipText: KeyboardLayoutModel.inputTooltip(root.inputState, root.layoutFull, root.multipleLayouts).split("\n").map(line => I18n.tr(line)).join("\n")
|
||||
onPressed: function(button) {
|
||||
if (root.multipleInputs && button === Qt.LeftButton) {
|
||||
InputMethodState.cycle()
|
||||
} else if (root.multipleLayouts) {
|
||||
root.cycleLayout()
|
||||
} else if (!root.multipleInputs && root.bar) {
|
||||
root.bar.run("omarchy-launch-floating-terminal-with-presentation 'omarchy-setup-input'")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,28 @@ function selectKeyboard(typed, namedByEvent) {
|
||||
}, keyboards[0])
|
||||
}
|
||||
|
||||
function inputLabel(state, fallback) {
|
||||
if (!state || !state.current || state.current.indexOf("keyboard-") === 0) return fallback
|
||||
if (state.label) return String(state.label).substring(0, 3)
|
||||
var labels = { mozc: "あ", hangul: "한", pinyin: "拼", chewing: "注" }
|
||||
return labels[state.current] || shortLabel(state.language || state.name, {})
|
||||
}
|
||||
|
||||
function inputTooltip(state, layout, multipleLayouts) {
|
||||
if (!state || !state.methods || state.methods.length < 2)
|
||||
return layout + (multipleLayouts ? "" : "\nClick to set up an input language")
|
||||
var text = state.name + "\nClick to switch input language\nCtrl + Shift + Space to toggle input"
|
||||
if (state.methods.indexOf("mozc") !== -1)
|
||||
text += "\nJapanese: type a word, then Space twice for character choices"
|
||||
if (multipleLayouts) text += "\nRight-click to switch keyboard layout"
|
||||
return text
|
||||
}
|
||||
|
||||
function showIndicator(layoutLabel, multipleLayouts, multipleInputs) {
|
||||
return multipleInputs || (layoutLabel !== "" &&
|
||||
(multipleLayouts || (layoutLabel !== "EN" && layoutLabel !== "ENG")))
|
||||
}
|
||||
|
||||
function layoutIndex(keyboard) {
|
||||
return (keyboard && keyboard.active_layout_index) || 0
|
||||
}
|
||||
@@ -120,6 +142,9 @@ if (typeof module !== "undefined") {
|
||||
isTypedKeyboard: isTypedKeyboard,
|
||||
layoutBriefs: layoutBriefs,
|
||||
selectKeyboard: selectKeyboard,
|
||||
shortLabel: shortLabel
|
||||
shortLabel: shortLabel,
|
||||
inputLabel: inputLabel,
|
||||
inputTooltip: inputTooltip,
|
||||
showIndicator: showIndicator
|
||||
}
|
||||
}
|
||||
@@ -185,6 +185,12 @@ function snapshotOf(notification, timestamp) {
|
||||
var id = n.id || 0
|
||||
var expireTimeout = Number(n.expireTimeout || 0)
|
||||
if (!isFinite(expireTimeout) || expireTimeout < 0) expireTimeout = 0
|
||||
var actions = []
|
||||
for (var i = 0; n.actions && i < n.actions.length; i++) {
|
||||
var action = n.actions[i]
|
||||
if (action && action.identifier && action.identifier !== "default")
|
||||
actions.push({ identifier: String(action.identifier), text: String(action.text || action.identifier) })
|
||||
}
|
||||
return {
|
||||
id: id,
|
||||
originalId: id,
|
||||
@@ -195,6 +201,7 @@ function snapshotOf(notification, timestamp) {
|
||||
image: n.image || "",
|
||||
glyph: glyphFromHints(n.hints),
|
||||
execArgv: execArgvFromHints(n.hints),
|
||||
actionsJson: JSON.stringify(actions),
|
||||
urgency: n.urgency,
|
||||
expireTimeout: expireTimeout,
|
||||
timestamp: timestamp === undefined ? Date.now() : timestamp
|
||||
@@ -203,7 +210,7 @@ function snapshotOf(notification, timestamp) {
|
||||
|
||||
// Everything the popup card draws, and therefore everything an in-place
|
||||
// update has to write through to the row and its file.
|
||||
var POPUP_ROLES = ["app", "appIcon", "summary", "body", "image", "glyph", "execArgv", "urgency", "expireTimeout"]
|
||||
var POPUP_ROLES = ["app", "appIcon", "summary", "body", "image", "glyph", "execArgv", "actionsJson", "urgency", "expireTimeout"]
|
||||
|
||||
function popupRoles() {
|
||||
return POPUP_ROLES
|
||||
@@ -263,6 +270,8 @@ function historyEntry(value, normalUrgency) {
|
||||
image: e.image || "",
|
||||
glyph: e.glyph || "",
|
||||
execArgv: e.execArgv || "",
|
||||
// Libnotify actions belong to a live sender; never replay stale buttons.
|
||||
actionsJson: "[]",
|
||||
urgency: typeof e.urgency === "number" ? e.urgency : normalUrgency,
|
||||
expireTimeout: 0,
|
||||
timestamp: e.timestamp || 0
|
||||
|
||||
@@ -235,7 +235,7 @@ Item {
|
||||
// about after the popup exists.
|
||||
readonly property var updateSignals: [
|
||||
"summaryChanged", "bodyChanged", "appNameChanged", "appIconChanged",
|
||||
"imageChanged", "urgencyChanged", "expireTimeoutChanged", "hintsChanged"
|
||||
"imageChanged", "urgencyChanged", "expireTimeoutChanged", "hintsChanged", "actionsChanged"
|
||||
]
|
||||
|
||||
// A client that updates a notification through replaces_id does not produce
|
||||
@@ -427,10 +427,26 @@ Item {
|
||||
// libnotify action — they just expect clicking the notification to
|
||||
// focus their window. Fall back to focusing the sending app by class so
|
||||
// that click-to-jump actually works.
|
||||
if (!invoked && entry.actionsJson && entry.actionsJson !== "[]") return
|
||||
if (!invoked) focusApp(entry)
|
||||
dismissPopup(index)
|
||||
}
|
||||
|
||||
function invokePopupAction(index, identifier) {
|
||||
if (index < 0 || index >= popupModel.count) return
|
||||
var entry = popupModel.get(index)
|
||||
var ref = entry && !isRestoredRow(entry) ? liveRefs[entry.originalId] : null
|
||||
if (!ref || !ref.actions) return
|
||||
for (var i = 0; i < ref.actions.length; i++) {
|
||||
var action = ref.actions[i]
|
||||
if (action && action.identifier === identifier) {
|
||||
action.invoke()
|
||||
dismissPopup(index)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to focus an existing Hyprland window matching the notification's
|
||||
// sender. The helper handles case-insensitive class matching.
|
||||
function focusApp(entry) {
|
||||
@@ -726,6 +742,7 @@ Item {
|
||||
image: "",
|
||||
glyph: "",
|
||||
execArgv: "",
|
||||
actionsJson: "[]",
|
||||
urgency: NotificationUrgency.Low,
|
||||
expireTimeout: 0,
|
||||
timestamp: Date.now()
|
||||
@@ -1044,6 +1061,7 @@ Item {
|
||||
required property string body
|
||||
required property string image
|
||||
required property string glyph
|
||||
required property string actionsJson
|
||||
required property int urgency
|
||||
required property double expireTimeout
|
||||
required property double timestamp
|
||||
@@ -1095,9 +1113,11 @@ Item {
|
||||
cornerRadius: service.cornerRadius
|
||||
fontFamily: service.shell && service.shell.bar ? service.shell.bar.fontFamily : ""
|
||||
glyph: cardSlot.glyph
|
||||
actions: JSON.parse(cardSlot.actionsJson || "[]")
|
||||
|
||||
onCloseRequested: service.dismissPopup(cardSlot.index)
|
||||
onCardClicked: service.invokePopupDefault(cardSlot.index)
|
||||
onActionClicked: identifier => service.invokePopupAction(cardSlot.index, identifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ BorderSurface {
|
||||
property int urgency: 1
|
||||
property double timestamp: 0
|
||||
property int cornerRadius: 0
|
||||
property var actions: []
|
||||
|
||||
// System monospace font injected by the container.
|
||||
property string fontFamily: ""
|
||||
@@ -34,6 +35,7 @@ BorderSurface {
|
||||
|
||||
signal closeRequested()
|
||||
signal cardClicked()
|
||||
signal actionClicked(string identifier)
|
||||
// Prefer per-notification media/avatar data, then fall back to the app icon.
|
||||
// The `check` flag avoids Qt's missing-texture placeholder for unknown names.
|
||||
readonly property string smallIconSource: image.length > 0 ? image : iconSource(appIcon)
|
||||
@@ -193,6 +195,27 @@ BorderSurface {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Flow {
|
||||
Layout.fillWidth: true
|
||||
Layout.leftMargin: Style.space(12)
|
||||
Layout.rightMargin: Style.space(12)
|
||||
Layout.bottomMargin: visible ? Style.space(10) : 0
|
||||
visible: root.actions.length > 0
|
||||
spacing: Style.space(8)
|
||||
|
||||
Repeater {
|
||||
model: root.actions
|
||||
delegate: Button {
|
||||
required property var modelData
|
||||
text: modelData.text
|
||||
bordered: true
|
||||
foreground: Color.notifications.text
|
||||
fontFamily: root.fontFamily
|
||||
onClicked: root.actionClicked(modelData.identifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hover-revealed close. Stacked after mainColumn so its MouseArea sits
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
#!/bin/bash
|
||||
|
||||
# VM-only: exercises the shared setup command and real composition for every
|
||||
# shipped engine. Restore the profile, shortcut settings, and font preference.
|
||||
set -euo pipefail
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
selection=$(omarchy-input-method status)
|
||||
initial_method=$(jq -r .installed_method <<< "$selection")
|
||||
initial_layout=$(jq -r .layout <<< "$selection")
|
||||
group=$(fcitx5-remote -q)
|
||||
initial_info=$(busctl --user --json=short call org.fcitx.Fcitx5 /controller org.fcitx.Fcitx.Controller1 InputMethodGroupInfo s "$group")
|
||||
jq -e --arg layout "$initial_layout" '.data[0] == $layout' <<< "$initial_info" >/dev/null ||
|
||||
fail "installed keyboard layout reaches Fcitx"
|
||||
if [[ $initial_method != "none" ]]; then
|
||||
jq -e --arg method "$initial_method" '.data[1] | any(.[0] == $method)' <<< "$initial_info" >/dev/null ||
|
||||
fail "installer-selected engine is ready before live setup"
|
||||
fi
|
||||
pass "installer input selection reaches the first desktop session"
|
||||
|
||||
work=$(mktemp -d)
|
||||
dbus-monitor --session "type='method_call',interface='org.freedesktop.Notifications',member='Notify'" > "$ARTIFACTS/input-method-notifications.log" 2>&1 &
|
||||
notification_monitor=$!
|
||||
config_home=${XDG_CONFIG_HOME:-$HOME/.config}
|
||||
data_home=${XDG_DATA_HOME:-$HOME/.local/share}
|
||||
paths=("$config_home/fcitx5" "$config_home/fontconfig/conf.d/50-omarchy-input-method.conf" "$data_home/dbus-1/services/org.fcitx.Fcitx5.service")
|
||||
for (( i = 0; i < ${#paths[@]}; i++ )); do
|
||||
if [[ -e ${paths[i]} || -L ${paths[i]} ]]; then
|
||||
cp -a "${paths[i]}" "$work/backup-$i"
|
||||
fi
|
||||
done
|
||||
|
||||
cleanup() {
|
||||
kill "$notification_monitor" 2>/dev/null || true
|
||||
wait "$notification_monitor" 2>/dev/null || true
|
||||
close_windows '^org\.omarchy\.ime-test$'
|
||||
close_windows '^org\.omarchy\.ime-test-gtk$|^org\.qt-project\.qml$|^chrome-.*_omarchy-ime-entry\.html-Default$'
|
||||
systemctl --user stop omarchy-fcitx5.service
|
||||
for (( i = 0; i < ${#paths[@]}; i++ )); do
|
||||
rm -rf "${paths[i]}"
|
||||
if [[ -e $work/backup-$i || -L $work/backup-$i ]]; then
|
||||
mkdir -p "$(dirname "${paths[i]}")"
|
||||
cp -a "$work/backup-$i" "${paths[i]}"
|
||||
fi
|
||||
done
|
||||
busctl --user call org.freedesktop.DBus /org/freedesktop/DBus org.freedesktop.DBus ReloadConfig >/dev/null
|
||||
systemctl --user start omarchy-fcitx5.service
|
||||
rm -rf "$work"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
cat > "$work/reader" <<'SH'
|
||||
#!/bin/bash
|
||||
IFS= read -r text
|
||||
printf '%s' "$text" > "$1"
|
||||
sleep 1
|
||||
SH
|
||||
chmod +x "$work/reader"
|
||||
|
||||
cat > "$work/entry.py" <<'PY'
|
||||
import gi
|
||||
gi.require_version("Gtk", "4.0")
|
||||
from gi.repository import Gtk
|
||||
|
||||
app = Gtk.Application(application_id="org.omarchy.ime-test-gtk")
|
||||
def activate(app):
|
||||
window = Gtk.ApplicationWindow(application=app, title="GTK input test")
|
||||
window.set_default_size(600, 160)
|
||||
entry = Gtk.Entry()
|
||||
window.set_child(entry)
|
||||
window.present()
|
||||
entry.grab_focus()
|
||||
app.connect("activate", activate)
|
||||
app.run()
|
||||
PY
|
||||
|
||||
cat > "$work/entry.qml" <<'QML'
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
ApplicationWindow {
|
||||
visible: true
|
||||
width: 600
|
||||
height: 160
|
||||
title: "Qt input test"
|
||||
TextField {
|
||||
anchors.fill: parent
|
||||
focus: true
|
||||
}
|
||||
}
|
||||
QML
|
||||
|
||||
cat > "$work/omarchy-ime-entry.html" <<'HTML'
|
||||
<!doctype html><meta charset="utf-8"><title>Browser input test</title>
|
||||
<input autofocus style="font-size:32px;width:90%" aria-label="Input test">
|
||||
HTML
|
||||
|
||||
compose() {
|
||||
fcitx5-remote -s "$method"
|
||||
fcitx5-remote -c
|
||||
wtype -M ctrl -M shift -k space -m shift -m ctrl
|
||||
case "$method" in
|
||||
mozc) wtype "nihongo"; wtype -k space ;;
|
||||
hangul) wtype "gksrmf" ;;
|
||||
pinyin) wtype "nihao"; wtype -k space ;;
|
||||
chewing) wtype "su3cl3" ;;
|
||||
esac
|
||||
sleep 1
|
||||
}
|
||||
|
||||
verify_text() {
|
||||
case "$method" in
|
||||
mozc) grep -Fxq "日本語" "$work/result" || fail "$1 commits Japanese" ;;
|
||||
hangul) grep -Fxq "한글" "$work/result" || fail "$1 commits Korean" ;;
|
||||
*) python -c 'import pathlib,sys; assert any("\u4e00" <= c <= "\u9fff" for c in pathlib.Path(sys.argv[1]).read_text())' "$work/result" || fail "$1 commits Han characters" ;;
|
||||
esac
|
||||
pass "$1 commits composed text"
|
||||
}
|
||||
|
||||
installed=()
|
||||
for method in mozc hangul pinyin chewing; do
|
||||
omarchy-setup-input "$method" > "$ARTIFACTS/setup-input-$method.log" 2>&1 ||
|
||||
fail "$method setup completes" "$(cat "$ARTIFACTS/setup-input-$method.log")"
|
||||
group=$(fcitx5-remote -q)
|
||||
info=$(busctl --user --json=short call org.fcitx.Fcitx5 /controller org.fcitx.Fcitx.Controller1 InputMethodGroupInfo s "$group")
|
||||
installed+=("$method")
|
||||
for retained in "${installed[@]}"; do
|
||||
jq -e --arg method "$retained" '.data[1] | any(.[0] == $method)' <<< "$info" >/dev/null || fail "$retained is still registered"
|
||||
done
|
||||
owner_pid=$(busctl --user call org.freedesktop.DBus /org/freedesktop/DBus org.freedesktop.DBus GetConnectionUnixProcessID s org.fcitx.Fcitx5 | awk '{print $2}')
|
||||
unit_pid=$(systemctl --user show omarchy-fcitx5.service --property MainPID --value)
|
||||
[[ $owner_pid == "$unit_pid" ]] || fail "Fcitx bus owner is the supervised process"
|
||||
|
||||
launch_app "foot --app-id=org.omarchy.ime-test $work/reader $work/result"
|
||||
wait_until "$method test terminal opens" 15 window_present '^org\.omarchy\.ime-test$'
|
||||
[[ $(fcitx5-remote) == "1" ]] || fail "$method setup starts in Latin mode"
|
||||
compose
|
||||
screenshot "success-input-$method-composition"
|
||||
wtype -k Return
|
||||
sleep 0.3
|
||||
wtype -k Return
|
||||
wait_until "$method commits text" 15 test -s "$work/result"
|
||||
verify_text "$method terminal"
|
||||
fcitx5-remote -c
|
||||
close_windows '^org\.omarchy\.ime-test$'
|
||||
rm -f "$work/result"
|
||||
|
||||
for toolkit in gtk qt browser; do
|
||||
case "$toolkit" in
|
||||
gtk) command="python $work/entry.py"; class='^org\.omarchy\.ime-test-gtk$' ;;
|
||||
qt) command="qml6 $work/entry.qml"; class='^org\.qt-project\.qml$' ;;
|
||||
browser) command="chromium --user-data-dir=$work/chromium --no-first-run --app=file://$work/omarchy-ime-entry.html"; class='^chrome-.*_omarchy-ime-entry\.html-Default$' ;;
|
||||
esac
|
||||
launch_app "uwsm app -- $command"
|
||||
wait_until "$method $toolkit opens" 30 window_present "$class"
|
||||
sleep 1
|
||||
compose
|
||||
screenshot "success-input-$method-$toolkit-composition"
|
||||
wtype -k Return
|
||||
fcitx5-remote -c
|
||||
wl-copy --clear
|
||||
wtype -M ctrl -k a -k c -m ctrl
|
||||
sleep 0.3
|
||||
wl-paste --no-newline > "$work/result" || fail "$method $toolkit copies committed text"
|
||||
verify_text "$method $toolkit"
|
||||
screenshot "success-input-$method-$toolkit-committed"
|
||||
close_windows "$class"
|
||||
wait_until "$toolkit closes" 15 window_absent "$class"
|
||||
done
|
||||
done
|
||||
|
||||
if grep -q 'member=Notify' "$ARTIFACTS/input-method-notifications.log"; then
|
||||
fail "input setup and composition produce no additional notifications" "$(cat "$ARTIFACTS/input-method-notifications.log")"
|
||||
fi
|
||||
pass "all four input engines remain quiet during setup and composition"
|
||||
@@ -0,0 +1,39 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
mkdir -p "$test_tmp/bin" "$test_tmp/home/.local/share/applications"
|
||||
|
||||
for helper in omarchy-pkg-add omarchy-pkg-drop update-desktop-database; do
|
||||
cat > "$test_tmp/bin/$helper" <<'SH'
|
||||
#!/bin/bash
|
||||
printf '%s %s\n' "${0##*/}" "$*" >> "$TEST_LOG"
|
||||
SH
|
||||
chmod +x "$test_tmp/bin/$helper"
|
||||
done
|
||||
|
||||
launcher="$test_tmp/home/.local/share/applications/Disk Usage.desktop"
|
||||
printf '[Desktop Entry]\nExec=dua i /\n' > "$launcher"
|
||||
|
||||
for (( attempt = 0; attempt < 2; attempt++ )); do
|
||||
env HOME="$test_tmp/home" OMARCHY_PATH="$ROOT" TEST_LOG="$test_tmp/log" PATH="$test_tmp/bin:$PATH" \
|
||||
bash -euo pipefail "$ROOT/migrations/1791345484.sh"
|
||||
[[ ! -e $launcher ]] || fail "the migration removes the old Disk Usage launcher"
|
||||
done
|
||||
pass "the Disktree migration removes the old launcher and can run twice"
|
||||
|
||||
expected="omarchy-pkg-add disktree-bin
|
||||
omarchy-pkg-drop dua-cli
|
||||
update-desktop-database $test_tmp/home/.local/share/applications"
|
||||
[[ $(cat "$test_tmp/log") == "$expected"$'\n'"$expected" ]] || fail "the migration installs Disktree before removing dua and refreshing launchers"
|
||||
pass "the migration installs Disktree before removing dua and refreshing launchers"
|
||||
|
||||
rm -r "$test_tmp/home/.local/share/applications"
|
||||
env HOME="$test_tmp/home" OMARCHY_PATH="$ROOT" TEST_LOG="$test_tmp/log" PATH="$test_tmp/bin:$PATH" \
|
||||
bash -euo pipefail "$ROOT/migrations/1791345484.sh"
|
||||
[[ ! -d $test_tmp/home/.local/share/applications ]] || fail "the migration relies on the packaged Disktree launcher"
|
||||
pass "the migration relies on the packaged Disktree launcher when no local launchers exist"
|
||||
@@ -52,7 +52,9 @@ case "$name" in
|
||||
;;
|
||||
mv)
|
||||
[[ ${TEST_PUBLISH_FAIL:-0} != 1 ]] || exit 1
|
||||
[[ ${TEST_REQUIRE_EXISTING_TARGET:-0} != 1 || -f ${@: -1} ]] || exit 1
|
||||
/usr/bin/mv "$@"
|
||||
[[ ${TEST_KILL_AFTER_PUBLISH:-0} != 1 ]] || /usr/bin/kill -KILL "$PPID"
|
||||
[[ ${TEST_POST_PUBLISH_FAIL:-0} != 1 ]] || : >"$TEST_GRANT_ROOT/run/omarchy-sudo-passwordless-package-removing"
|
||||
;;
|
||||
systemd-run)
|
||||
@@ -69,7 +71,15 @@ case "$name" in
|
||||
sudo)
|
||||
if [[ ${1:-} == -h ]]; then echo 'usage: sudo [-N] command'; exit 0; fi
|
||||
if [[ ${1:-} == -k ]]; then exit 0; fi
|
||||
if [[ ${1:-} == -N ]]; then shift; fi
|
||||
if [[ ${1:-} == -n && ${3:-} == -l ]]; then
|
||||
[[ ${TEST_POLICY_FAILURE:-0} != 1 ]] || exit 1
|
||||
default_policy=' Options: authenticate'
|
||||
[[ ${TEST_STATUS:-3} == 3 ]] || default_policy=' Options: !authenticate'
|
||||
printf '%s\n' "${TEST_POLICY:-$default_policy}"
|
||||
exit 0
|
||||
fi
|
||||
if [[ ${1:-} == -n ]]; then shift; fi
|
||||
if [[ ${1:-} == -N || ${1:-} == -kn ]]; then shift; fi
|
||||
if [[ ${1:-} == -- ]]; then shift; fi
|
||||
if [[ ${TEST_MIGRATION:-0} == 1 ]]; then
|
||||
[[ ${TEST_NO_SUDO:-0} != 1 ]] || exit 1
|
||||
@@ -78,7 +88,14 @@ case "$name" in
|
||||
[[ ${2:-} != __status ]] || exit "${TEST_STATUS:-3}"
|
||||
fi
|
||||
;;
|
||||
gum) exit 1 ;;
|
||||
gum)
|
||||
if [[ $1 == choose ]]; then
|
||||
[[ ${TEST_CHOICE_CANCEL:-0} != 1 ]] || exit 130
|
||||
printf '%s\n' "${TEST_CHOICE:-15 minutes}"
|
||||
else
|
||||
exit "${TEST_CONFIRM_STATUS:-1}"
|
||||
fi
|
||||
;;
|
||||
*) exit 99 ;;
|
||||
esac
|
||||
STUB
|
||||
@@ -120,6 +137,6 @@ assert_status() {
|
||||
(( actual == expected )) || fail "expected status $expected, got $actual from $*"
|
||||
}
|
||||
reset_grant() {
|
||||
rm -f "$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-1000" "$test_tmp/run/omarchy-sudo-passwordless-package-removing"
|
||||
rm -f "$test_tmp/etc/sudoers.d/99-omarchy-permanent-nopasswd-1000" "$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-1000" "$test_tmp/run/omarchy-sudo-passwordless-package-removing"
|
||||
: >"$test_tmp/commands"
|
||||
}
|
||||
Executable
+128
@@ -0,0 +1,128 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh"
|
||||
|
||||
require_command lua
|
||||
|
||||
test_dir=$(mktemp -d)
|
||||
trap 'rm -rf "$test_dir"' EXIT
|
||||
|
||||
# Load the JIS bindings on a jp layout and fire one of them in a fake window,
|
||||
# printing what it hands to Hyprland. TEST_FCITX5_CONFIG is the fcitx5 config,
|
||||
# or unset for none.
|
||||
fire() {
|
||||
local keys="$1" class="$2" tags="${3-}"
|
||||
local home="$test_dir/home"
|
||||
|
||||
rm -rf "$home"
|
||||
mkdir -p "$home/.config/fcitx5"
|
||||
if [[ -v TEST_FCITX5_CONFIG ]]; then
|
||||
printf '%s\n' "$TEST_FCITX5_CONFIG" >"$home/.config/fcitx5/config"
|
||||
fi
|
||||
|
||||
if [[ ${TEST_KEYBOARD_ONLY:-false} == "true" ]]; then
|
||||
printf '[Groups/0/Items/0]\nName=keyboard-us\n' > "$home/.config/fcitx5/profile"
|
||||
else
|
||||
printf '[Groups/0/Items/0]\nName=keyboard-us\n[Groups/0/Items/1]\nName=mozc\n' > "$home/.config/fcitx5/profile"
|
||||
fi
|
||||
|
||||
HOME="$home" XDG_CONFIG_HOME="$home/.config" OMARCHY_PATH="$ROOT" \
|
||||
TEST_KEYS="$keys" TEST_CLASS="$class" TEST_TAGS="$tags" TEST_LAYOUT="${TEST_LAYOUT:-jp}" lua <<'LUA'
|
||||
package.path = os.getenv("OMARCHY_PATH") .. "/?.lua;" .. package.path
|
||||
|
||||
local real_open = io.open
|
||||
io.open = function(path, mode)
|
||||
if path == "/etc/vconsole.conf" then
|
||||
local file = io.tmpfile()
|
||||
file:write("XKBLAYOUT=" .. os.getenv("TEST_LAYOUT") .. "\n")
|
||||
file:seek("set")
|
||||
return file
|
||||
end
|
||||
|
||||
return real_open(path, mode)
|
||||
end
|
||||
|
||||
local binds = {}
|
||||
o = {
|
||||
bind = function(keys, description, dispatcher, options)
|
||||
binds[keys] = { dispatcher = dispatcher, options = options or {} }
|
||||
end,
|
||||
}
|
||||
|
||||
local window = { class = os.getenv("TEST_CLASS"), tags = {} }
|
||||
for tag in os.getenv("TEST_TAGS"):gmatch("[^,]+") do
|
||||
table.insert(window.tags, tag)
|
||||
end
|
||||
|
||||
hl = {
|
||||
dsp = {
|
||||
send_key_state = function(key)
|
||||
return ("send %s %s %s"):format(key.mods, key.key, key.state)
|
||||
end,
|
||||
},
|
||||
dispatch = function(action) print(action) end,
|
||||
timer = function(callback) callback() end,
|
||||
get_active_window = function() return window end,
|
||||
exec_cmd = function(command) print("exec " .. command) end,
|
||||
}
|
||||
|
||||
o.shell_succeeds = function(command) print("exec " .. command) end
|
||||
|
||||
require("default.hypr.bindings.input-method")
|
||||
require("default.hypr.bindings.jis")
|
||||
|
||||
local bind = binds[os.getenv("TEST_KEYS")]
|
||||
if bind.options.non_consuming then
|
||||
print("non_consuming")
|
||||
end
|
||||
bind.dispatcher()
|
||||
LUA
|
||||
}
|
||||
|
||||
assert_fires() {
|
||||
local description="$1" expected="$2"
|
||||
shift 2
|
||||
local actual
|
||||
|
||||
actual=$(fire "$@")
|
||||
[[ $actual == "$expected" ]] ||
|
||||
fail "$description" "expected: $expected"$'\n'"actual: $actual"
|
||||
pass "$description"
|
||||
}
|
||||
|
||||
zoom="CTRL + SHIFT + semicolon"
|
||||
assert_fires "the JIS + key zooms in with Ctrl + keypad plus" \
|
||||
$'send CTRL KP_Add down\nsend CTRL KP_Add up' "$zoom" "chromium"
|
||||
assert_fires "the JIS + key zooms in windows without a class" \
|
||||
$'send CTRL KP_Add down\nsend CTRL KP_Add up' "$zoom" ""
|
||||
assert_fires "the JIS + key zooms Obsidian in with Ctrl + ^" \
|
||||
$'send CTRL asciicircum down\nsend CTRL asciicircum up' "$zoom" "obsidian"
|
||||
assert_fires "the JIS + key zooms the Obsidian Flatpak in with Ctrl + ^" \
|
||||
$'send CTRL asciicircum down\nsend CTRL asciicircum up' "$zoom" "md.obsidian.Obsidian"
|
||||
|
||||
# The prefix bind has to pass Ctrl + Space on, or tmux and Herdr never see it.
|
||||
prefix="CTRL + SPACE"
|
||||
freed=$'[Hotkey/TriggerKeys]\n0=Zenkaku_Hankaku\n\n[Hotkey/ActivateKeys]\n0=Henkan'
|
||||
TEST_FCITX5_CONFIG=$freed assert_fires "the terminal prefix switches to direct input once Ctrl + Space no longer toggles" \
|
||||
$'non_consuming\nexec timeout 0.3 fcitx5-remote --check -c' "$prefix" "com.mitchellh.ghostty" "terminal*"
|
||||
TEST_FCITX5_CONFIG=$freed assert_fires "the prefix leaves input alone outside terminals" \
|
||||
"non_consuming" "$prefix" "chromium" "browser"
|
||||
|
||||
# While fcitx5 still toggles on Ctrl + Space, switching to direct input would
|
||||
# undo the toggle it just made.
|
||||
assert_fires "the prefix leaves input alone without an fcitx5 config" \
|
||||
"non_consuming" "$prefix" "com.mitchellh.ghostty" "terminal*"
|
||||
TEST_FCITX5_CONFIG=$'[Behavior]\nShareInputState=No' assert_fires "the prefix leaves input alone with the default trigger keys" \
|
||||
"non_consuming" "$prefix" "com.mitchellh.ghostty" "terminal*"
|
||||
TEST_FCITX5_CONFIG=$'[Hotkey/TriggerKeys]\n0=Zenkaku_Hankaku\n1=Control+space' assert_fires "the prefix leaves input alone while Ctrl + Space is a trigger key" \
|
||||
"non_consuming" "$prefix" "com.mitchellh.ghostty" "terminal*"
|
||||
|
||||
for layout in us kr; do
|
||||
TEST_LAYOUT="$layout" TEST_FCITX5_CONFIG=$freed assert_fires "the terminal prefix works with $layout keyboards too" \
|
||||
$'non_consuming\nexec timeout 0.3 fcitx5-remote --check -c' "$prefix" "foot" "terminal"
|
||||
done
|
||||
|
||||
TEST_KEYBOARD_ONLY=true TEST_FCITX5_CONFIG=$freed assert_fires "keyboard-only profiles skip the terminal controller call" \
|
||||
"non_consuming" "$prefix" "foot" "terminal"
|
||||
@@ -13,6 +13,15 @@ local vconsole = os.getenv("OMARCHY_VCONSOLE")
|
||||
local real_open = io.open
|
||||
|
||||
io.open = function(path, mode)
|
||||
if path == "/etc/omarchy/input-method" then
|
||||
local preference = os.getenv("TEST_INPUT_PREFERENCE")
|
||||
if not preference then return nil end
|
||||
local file = io.tmpfile()
|
||||
file:write(preference)
|
||||
file:seek("set")
|
||||
return file
|
||||
end
|
||||
|
||||
if path ~= "/etc/vconsole.conf" then
|
||||
return real_open(path, mode)
|
||||
end
|
||||
@@ -128,3 +137,7 @@ sddm_layouts=$(sed -n '/^local non_latin_layouts =/,+1p' "$sddm_lua" | grep -o '
|
||||
[[ $hooks_layouts == "$sddm_layouts" ]] ||
|
||||
fail "greeter non-latin layout list stays in sync" "$(diff <(echo "$hooks_layouts") <(echo "$sddm_layouts"))"
|
||||
pass "greeter non-latin layout list stays in sync with the initramfs hook"
|
||||
|
||||
TEST_INPUT_PREFERENCE=$'INPUT_METHOD=hangul\n XKB_LAYOUT="kr" # desktop only\n' assert_input "Korean selection overrides its installer US layout" "[kr] [] [$base_options]" $'XKBLAYOUT=us\n'
|
||||
TEST_INPUT_PREFERENCE=$'INPUT_METHOD=hangul\nXKB_LAYOUT=kr\n' assert_input "a later US variant overrides the Korean preference" "[us] [intl] [$base_options]" $'XKBLAYOUT=us\nXKBVARIANT=intl\n'
|
||||
TEST_INPUT_PREFERENCE=$'INPUT_METHOD=hangul\nXKB_LAYOUT=kr\n' assert_input "a later layout overrides the Korean preference" "[de] [nodeadkeys] [$base_options]" $'XKBLAYOUT=de\nXKBVARIANT=nodeadkeys\n'
|
||||
Executable
+74
@@ -0,0 +1,74 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
OMARCHY_PATH="$ROOT" /usr/bin/python - <<'PY'
|
||||
import importlib.util
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
spec = importlib.util.spec_from_file_location("indicator", os.environ["OMARCHY_PATH"] + "/default/input-methods/indicator.py")
|
||||
indicator = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(indicator)
|
||||
|
||||
with patch.object(indicator, "call", side_effect=[
|
||||
("Default",),
|
||||
("us", [("keyboard-us", ""), ("mozc", ""), ("mozc", "")]),
|
||||
("mozc", "Mozc", "", "fcitx_mozc", "あ", "ja", "mozc", True, "", {}),
|
||||
]):
|
||||
state = indicator.snapshot(None)
|
||||
assert state == {"methods": ["keyboard-us", "mozc"], "current": "mozc", "name": "Mozc", "label": "あ", "language": "ja"}
|
||||
print("ok - live state deduplicates modes and reads language independently of the engine label")
|
||||
|
||||
def check_cycle(current, methods, expected):
|
||||
state = {"current": current, "methods": methods}
|
||||
with patch.object(indicator, "snapshot", return_value=state), patch.object(indicator, "call") as call:
|
||||
indicator.cycle(None)
|
||||
if expected is None:
|
||||
call.assert_not_called()
|
||||
else:
|
||||
assert call.call_args.args[1] == "SetCurrentIM"
|
||||
assert call.call_args.args[2].unpack() == (expected,)
|
||||
|
||||
check_cycle("keyboard-us", ["keyboard-us", "mozc"], "mozc")
|
||||
check_cycle("mozc", ["keyboard-us", "mozc"], "keyboard-us")
|
||||
check_cycle("mozc", ["keyboard-us", "mozc", "hangul"], "hangul")
|
||||
check_cycle("hangul", ["keyboard-us", "mozc", "hangul"], "keyboard-us")
|
||||
check_cycle("keyboard-us", ["keyboard-us"], None)
|
||||
check_cycle("removed", ["keyboard-us", "mozc"], None)
|
||||
check_cycle("", [], None)
|
||||
print("ok - input cycling covers Latin, multiple engines, single modes, and stale contexts")
|
||||
|
||||
entries = [("keyboard-us", "English", "", "input-keyboard", "en", "en", True),
|
||||
("pinyin", "Pinyin", "", "fcitx-pinyin", "拼", "zh_CN", True)]
|
||||
live = {"methods": ["keyboard-us", "pinyin"], "current": "", "name": "", "label": "", "language": ""}
|
||||
selected = []
|
||||
def fake_call(bus, method, args=None):
|
||||
if method == "AvailableInputMethods":
|
||||
return (entries,)
|
||||
if method == "SetCurrentIM":
|
||||
selected.append(args.unpack()[0])
|
||||
live["current"] = selected[-1]
|
||||
return ()
|
||||
raise AssertionError(method)
|
||||
|
||||
with patch.object(indicator, "snapshot", side_effect=lambda bus: dict(live)), patch.object(indicator, "call", side_effect=fake_call):
|
||||
reader = indicator.Indicator(None)
|
||||
assert reader.refresh()["current"] == "keyboard-us"
|
||||
reader.select_next()
|
||||
assert reader.refresh()["current"] == "pinyin"
|
||||
assert reader.pending == "pinyin" and selected == []
|
||||
reader.select_next()
|
||||
assert reader.pending == "keyboard-us"
|
||||
reader.select_next()
|
||||
live["current"] = "keyboard-us"
|
||||
assert reader.refresh()["current"] == "pinyin"
|
||||
assert reader.pending == "" and selected == ["pinyin"]
|
||||
live["current"] = ""
|
||||
reader.select_next()
|
||||
live["methods"] = ["pinyin"]
|
||||
reader.refresh()
|
||||
assert reader.pending == ""
|
||||
print("ok - desktop clicks update the label, cycle pending choices, and apply once a text context exists")
|
||||
PY
|
||||
@@ -0,0 +1,277 @@
|
||||
import argparse
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
ROOT = Path(os.environ["OMARCHY_PATH"])
|
||||
spec = importlib.util.spec_from_file_location("input_setup", ROOT / "default/input-methods/configure.py")
|
||||
setup = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(setup)
|
||||
|
||||
|
||||
class InputMethodTest(unittest.TestCase):
|
||||
def test_pinyin_defaults_suppress_cloud_prompt(self):
|
||||
setup.defaults(self.config, fresh=True)
|
||||
pinyin = setup.values((self.config / "fcitx5/conf/pinyin.conf").read_text())
|
||||
self.assertEqual(pinyin["FirstRun"], "False")
|
||||
self.assertEqual(pinyin["CloudPinyinEnabled"], "False")
|
||||
|
||||
def test_pinyin_prompt_suppression_preserves_existing_preferences(self):
|
||||
pinyin = self.config / "fcitx5/conf/pinyin.conf"
|
||||
pinyin.parent.mkdir(parents=True)
|
||||
pinyin.write_text("FirstRun=True\nCloudPinyinEnabled=True\nPageSize=7\n")
|
||||
setup.defaults(self.config)
|
||||
self.assertEqual(pinyin.read_text(), "FirstRun=False\nCloudPinyinEnabled=True\nPageSize=7\n")
|
||||
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temporary.cleanup)
|
||||
self.home = Path(self.temporary.name)
|
||||
self.config = self.home / "config"
|
||||
self.vconsole = self.home / "vconsole"
|
||||
self.preference = self.home / "preference"
|
||||
self.vconsole.write_text("XKBLAYOUT=us\n")
|
||||
self.preference.write_text("INPUT_METHOD=none\n")
|
||||
env = {"XDG_CONFIG_HOME": str(self.config), "XDG_DATA_HOME": str(self.home / "data"), "OMARCHY_VCONSOLE": str(self.vconsole), "OMARCHY_INPUT_SELECTION": str(self.preference)}
|
||||
self.environment = patch.dict(os.environ, env)
|
||||
self.environment.start()
|
||||
self.addCleanup(self.environment.stop)
|
||||
self.process = patch.object(setup.subprocess, "run", return_value=subprocess.CompletedProcess([], 1, stdout="", stderr=""))
|
||||
self.process.start()
|
||||
self.addCleanup(self.process.stop)
|
||||
|
||||
def seed(self, method=None):
|
||||
setup.configure(argparse.Namespace(method=method, seed=True, defaults=False))
|
||||
|
||||
def test_every_installer_engine_is_seeded_and_inactive(self):
|
||||
for method in ("mozc", "hangul", "pinyin", "chewing"):
|
||||
with self.subTest(method=method):
|
||||
profile = self.config / "fcitx5/profile"
|
||||
profile.unlink(missing_ok=True)
|
||||
self.preference.write_text(f"INPUT_METHOD={method}\nXKB_LAYOUT={'kr' if method == 'hangul' else ''}\n")
|
||||
self.seed()
|
||||
data = setup.sections(profile.read_text())
|
||||
self.assertEqual(data["Groups/0"]["DefaultIM"], method)
|
||||
self.assertEqual(data["Groups/0/Items/1"]["Name"], method)
|
||||
self.assertEqual(data["Groups/0/Items/0"]["Name"], "keyboard-kr" if method == "hangul" else "keyboard-us")
|
||||
keys = setup.sections((self.config / "fcitx5/config").read_text())
|
||||
self.assertEqual(keys["Behavior"]["ActiveByDefault"], "False")
|
||||
self.assertNotIn("Control+space", keys["Hotkey/TriggerKeys"].values())
|
||||
self.assertIn("Control+Shift+space", keys["Hotkey/TriggerKeys"].values())
|
||||
|
||||
def test_vconsole_variants_quotes_and_japanese_without_preference(self):
|
||||
self.preference.unlink()
|
||||
self.vconsole.write_text('XKBLAYOUT="jp" # JIS\n')
|
||||
self.assertEqual(setup.selection(), ("mozc", "jp"))
|
||||
self.vconsole.write_text("XKBLAYOUT='de'\nXKBVARIANT=nodeadkeys\n")
|
||||
self.seed()
|
||||
self.assertIn("Default Layout=de-nodeadkeys", (self.config / "fcitx5/profile").read_text())
|
||||
self.vconsole.write_text("XKBLAYOUT=ru\nXKBVARIANT=phonetic\n")
|
||||
self.assertEqual(setup.selection(), ("none", "us"))
|
||||
|
||||
def test_rerun_preserves_custom_multilingual_profile(self):
|
||||
self.seed("mozc")
|
||||
profile = self.config / "fcitx5/profile"
|
||||
original = profile.read_bytes() + b"\n# custom configuration\n"
|
||||
profile.write_bytes(original)
|
||||
self.seed("hangul")
|
||||
self.assertEqual(profile.read_bytes(), original)
|
||||
|
||||
def test_stock_profile_is_repaired_but_custom_single_keyboard_is_not(self):
|
||||
self.seed()
|
||||
self.vconsole.write_text("XKBLAYOUT=fr\n")
|
||||
self.seed()
|
||||
profile = self.config / "fcitx5/profile"
|
||||
self.assertIn("keyboard-fr", profile.read_text())
|
||||
self.seed("mozc")
|
||||
self.assertNotIn("Name=mozc", profile.read_text())
|
||||
|
||||
def test_existing_shortcuts_and_addon_options_are_preserved(self):
|
||||
path = self.config / "fcitx5/config"
|
||||
setup.atomic_write(path, "# user's keys\n[Hotkey/TriggerKeys]\n0=Alt+space\n\n[Behavior]\nActiveByDefault=True\n")
|
||||
quickphrase = self.config / "fcitx5/conf/quickphrase.conf"
|
||||
setup.atomic_write(quickphrase, "[TriggerKey]\n\n")
|
||||
setup.defaults(self.config, fresh=True)
|
||||
config = setup.sections(path.read_text())
|
||||
self.assertEqual(config["Hotkey/TriggerKeys"], {"0": "Alt+space"})
|
||||
self.assertEqual(config["Behavior"], {"ActiveByDefault": "True"})
|
||||
self.assertEqual(quickphrase.read_text(), "[TriggerKey]\n\n")
|
||||
|
||||
def test_generated_stock_shortcuts_are_repaired(self):
|
||||
path = self.config / "fcitx5/config"
|
||||
setup.atomic_write(path, "[Hotkey/TriggerKeys]\n0=Control+space\n1=Zenkaku_Hankaku\n2=Hangul\n\n[Hotkey/EnumerateGroupForwardKeys]\n0=Super+space\n")
|
||||
setup.defaults(self.config, fresh=True)
|
||||
config = setup.sections(path.read_text())
|
||||
self.assertEqual(config["Hotkey/EnumerateGroupForwardKeys"], {})
|
||||
self.assertNotIn("Control+space", config["Hotkey/TriggerKeys"].values())
|
||||
|
||||
def test_existing_engine_inherited_hotkeys_are_preserved(self):
|
||||
path = self.config / "fcitx5/config"
|
||||
setup.atomic_write(path, "[Behavior]\nActiveByDefault=True\n")
|
||||
setup.defaults(self.config, fresh=False)
|
||||
self.assertEqual(path.read_text(), "[Behavior]\nActiveByDefault=True\n")
|
||||
|
||||
def test_korean_preference_yields_to_a_later_keyboard_change(self):
|
||||
self.preference.write_text("INPUT_METHOD=hangul\nXKB_LAYOUT=kr\n")
|
||||
self.assertEqual(setup.selection(), ("hangul", "kr"))
|
||||
for layout, variant, expected in [("de", "nodeadkeys", "de-nodeadkeys"), ("us", "intl", "us-intl")]:
|
||||
self.vconsole.write_text(f"XKBLAYOUT={layout}\nXKBVARIANT={variant}\n")
|
||||
self.assertEqual(setup.selection(), ("hangul", expected))
|
||||
|
||||
def test_live_append_keeps_every_method_and_layout(self):
|
||||
info = json.dumps({"data": ["de-nodeadkeys", [["keyboard-de-nodeadkeys", ""], ["mozc", "jp"]]]})
|
||||
updated = json.dumps({"data": ["de-nodeadkeys", [["keyboard-de-nodeadkeys", ""], ["mozc", "jp"], ["hangul", ""]]]})
|
||||
with patch.object(setup, "run", side_effect=["Work", info, "", "", "Work", updated]) as calls:
|
||||
setup.live_append("hangul")
|
||||
self.assertEqual(calls.call_args_list[2].args[0][-10:], ["ssa(ss)", "Work", "de-nodeadkeys", "3", "keyboard-de-nodeadkeys", "", "mozc", "jp", "hangul", ""])
|
||||
self.assertEqual(calls.call_args_list[3].args[0][-1], "Save")
|
||||
info = json.dumps({"data": ["us", [["keyboard-us", ""], ["hangul", ""]]]})
|
||||
with patch.object(setup, "run", side_effect=["Default", info]) as calls:
|
||||
setup.live_append("hangul")
|
||||
self.assertEqual(calls.call_count, 2)
|
||||
|
||||
def test_seed_in_a_live_desktop_is_a_harmless_retry(self):
|
||||
self.seed()
|
||||
profile = self.config / "fcitx5/profile"
|
||||
original = profile.read_bytes()
|
||||
with patch.object(setup.subprocess, "run", return_value=subprocess.CompletedProcess([], 0)):
|
||||
self.seed("mozc")
|
||||
self.assertEqual(profile.read_bytes(), original)
|
||||
|
||||
def test_failed_seed_rolls_back_so_it_can_be_retried(self):
|
||||
with patch.object(setup, "defaults", side_effect=OSError("disk full")):
|
||||
with self.assertRaises(OSError):
|
||||
self.seed("mozc")
|
||||
self.assertFalse((self.config / "fcitx5/profile").exists())
|
||||
self.seed("mozc")
|
||||
self.assertIn("Name=mozc", (self.config / "fcitx5/profile").read_text())
|
||||
|
||||
def test_failed_live_setup_restores_disk_and_restarts_the_daemon(self):
|
||||
self.seed()
|
||||
profile = self.config / "fcitx5/profile"
|
||||
original = profile.read_bytes()
|
||||
events = []
|
||||
def append(method):
|
||||
profile.write_text("failed in-memory state")
|
||||
raise RuntimeError("save failed")
|
||||
def command(args):
|
||||
events.append(args)
|
||||
if args == ["omarchy-restart-xcompose"] and len(events) > 2:
|
||||
self.assertEqual(profile.read_bytes(), original)
|
||||
return ""
|
||||
probes = 0
|
||||
def process(args, **kwargs):
|
||||
nonlocal probes
|
||||
if args[0] == "pgrep":
|
||||
probes += 1
|
||||
return subprocess.CompletedProcess([], 0 if probes == 1 else 1)
|
||||
return subprocess.CompletedProcess([], 0)
|
||||
with patch.object(setup.subprocess, "run", side_effect=process), patch.object(setup, "run", side_effect=command), patch.object(setup, "wait_ready"), patch.object(setup, "live_append", side_effect=append), patch.object(setup.time, "sleep"):
|
||||
with self.assertRaisesRegex(RuntimeError, "save failed"):
|
||||
setup.configure(argparse.Namespace(method="mozc", seed=False, defaults=False))
|
||||
self.assertEqual(profile.read_bytes(), original)
|
||||
self.assertIn(["systemctl", "--user", "stop", "omarchy-fcitx5.service"], events)
|
||||
self.assertEqual(events.count(["omarchy-restart-xcompose"]), 2)
|
||||
|
||||
def test_readiness_checks_the_dbus_array_payload_and_group(self):
|
||||
available = json.dumps({"data": [[["mozc", "Mozc", "", "", "", "ja", False]]]})
|
||||
with patch.object(setup, "run", return_value=available), patch.object(setup, "live_group", return_value=("Default", "us", [["keyboard-us", ""]])):
|
||||
setup.wait_ready("mozc")
|
||||
|
||||
def test_a_silently_dropped_engine_is_rejected_and_group_restored(self):
|
||||
old = ("Default", "us", [["keyboard-us", ""]])
|
||||
with patch.object(setup, "live_group", side_effect=[old, old]), patch.object(setup, "live_set") as setter:
|
||||
with self.assertRaisesRegex(RuntimeError, "retain"):
|
||||
setup.live_append("mozc")
|
||||
self.assertEqual(setter.call_args_list[-1].args, old)
|
||||
|
||||
def test_migration_without_a_bus_keeps_written_activation_and_defaults(self):
|
||||
self.seed()
|
||||
with patch.object(setup, "run", side_effect=subprocess.CalledProcessError(1, "busctl")):
|
||||
setup.configure(argparse.Namespace(method=None, seed=False, defaults=True))
|
||||
self.assertTrue((self.home / "data/dbus-1/services/org.fcitx.Fcitx5.service").exists())
|
||||
|
||||
def test_live_migration_without_a_bus_does_not_restart_or_rewrite_profile(self):
|
||||
self.seed()
|
||||
profile = self.config / "fcitx5/profile"
|
||||
original = profile.read_bytes()
|
||||
with patch.object(setup.subprocess, "run", return_value=subprocess.CompletedProcess([], 0)), patch.object(setup, "run", side_effect=subprocess.CalledProcessError(1, "busctl")) as calls:
|
||||
setup.configure(argparse.Namespace(method=None, seed=False, defaults=True))
|
||||
self.assertEqual(profile.read_bytes(), original)
|
||||
self.assertFalse(any(call.args[0] == ["omarchy-restart-xcompose"] for call in calls.call_args_list))
|
||||
|
||||
def test_migration_does_not_add_an_engine_the_old_daemon_cannot_load(self):
|
||||
self.seed()
|
||||
self.vconsole.write_text("XKBLAYOUT=jp\n")
|
||||
self.preference.write_text("INPUT_METHOD=mozc\n")
|
||||
old = ("Default", "us", [["keyboard-us", ""]])
|
||||
new = ("Default", "jp", [["keyboard-jp", ""]])
|
||||
with patch.object(setup.subprocess, "run", return_value=subprocess.CompletedProcess([], 0)), patch.object(setup, "live_group", side_effect=[old, new]), patch.object(setup, "available_methods", return_value=[]), patch.object(setup, "live_set") as setter:
|
||||
setup.configure(argparse.Namespace(method=None, seed=False, defaults=True))
|
||||
self.assertEqual(setter.call_args.args, new)
|
||||
|
||||
def test_custom_keyboard_overrides_are_not_stock(self):
|
||||
self.assertFalse(setup.stock_profile(setup.profile_text("de", "none").replace("keyboard-de", "keyboard-us")))
|
||||
self.assertFalse(setup.stock_profile(setup.profile_text("us", "none").replace("Layout=\n", "Layout=ru\n")))
|
||||
|
||||
def test_selected_live_engine_is_verified_after_the_context_reconnects(self):
|
||||
self.seed("mozc")
|
||||
profile = self.config / "fcitx5/profile"
|
||||
saves = 0
|
||||
def command(args):
|
||||
nonlocal saves
|
||||
if args == setup.CONTROLLER + ["Save"]:
|
||||
saves += 1
|
||||
if saves == 2:
|
||||
profile.write_text(profile.read_text().replace("DefaultIM=mozc", "DefaultIM=hangul"))
|
||||
return "Default" if args == ["fcitx5-remote", "-q"] else ""
|
||||
with patch.object(setup, "run", side_effect=command) as commands, patch.object(setup.time, "sleep"):
|
||||
setup.select_live_method("hangul", profile)
|
||||
self.assertEqual(saves, 2)
|
||||
self.assertEqual(sum(call.args[0] == ["fcitx5-remote", "-c"] for call in commands.call_args_list), 2)
|
||||
|
||||
def test_live_setup_does_not_validate_an_unused_install_layout(self):
|
||||
self.seed("mozc")
|
||||
self.vconsole.write_text("XKBLAYOUT=invalid value\n")
|
||||
with patch.object(setup.subprocess, "run", return_value=subprocess.CompletedProcess([], 0)), patch.object(setup, "run", return_value=""), patch.object(setup, "wait_ready"), patch.object(setup, "live_append"), patch.object(setup, "select_live_method"):
|
||||
setup.configure(argparse.Namespace(method="hangul", seed=False, defaults=False))
|
||||
|
||||
def test_live_configuration_requires_a_desktop(self):
|
||||
with self.assertRaisesRegex(RuntimeError, "Log in"):
|
||||
setup.configure(argparse.Namespace(method="mozc", seed=False, defaults=False))
|
||||
self.assertFalse((self.config / "fcitx5/profile").exists())
|
||||
|
||||
def test_atomic_updates_keep_symlinks_and_modes(self):
|
||||
target = self.home / "real-config"
|
||||
target.write_text("before")
|
||||
target.chmod(0o600)
|
||||
link = self.home / "link"
|
||||
link.symlink_to(target)
|
||||
setup.atomic_write(link, "after")
|
||||
self.assertTrue(link.is_symlink())
|
||||
self.assertEqual(target.read_text(), "after")
|
||||
self.assertEqual(target.stat().st_mode & 0o777, 0o600)
|
||||
|
||||
def test_input_preferences_are_data_and_invalid_values_fail(self):
|
||||
self.preference.write_text("INPUT_METHOD=$(touch /tmp/should-not-exist)\n")
|
||||
with self.assertRaises(ValueError):
|
||||
self.seed()
|
||||
|
||||
def test_font_fallback_does_not_replace_existing_preference(self):
|
||||
setup.font_default(self.config, "mozc")
|
||||
font = self.config / "fontconfig/conf.d/50-omarchy-input-method.conf"
|
||||
self.assertIn("Noto Sans CJK JP", font.read_text())
|
||||
self.assertIn("Noto Sans Mono CJK JP", font.read_text())
|
||||
original = font.read_bytes()
|
||||
setup.font_default(self.config, "hangul")
|
||||
self.assertEqual(font.read_bytes(), original)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
OMARCHY_PATH="$ROOT" python "$ROOT/test/shell.d/input-method-checks.py"
|
||||
@@ -64,6 +64,9 @@ rendered=$(keybindings)
|
||||
grep -q 'SUPER + F *→ Full screen' <<<"$rendered" ||
|
||||
fail "a chord with no alternative renders on its own" "$rendered"
|
||||
pass "the keybindings menu renders its entries"
|
||||
grep -q 'CTRL SHIFT + SPACE *→ Switch input language' <<<"$rendered" ||
|
||||
fail "the input switch shortcut appears in the cheatsheet" "$rendered"
|
||||
pass "the input switch shortcut appears in the cheatsheet"
|
||||
|
||||
(( $(grep -c '→ Close window$' <<<"$rendered") == 1 )) ||
|
||||
fail "an alternative chord joins the row of the first one" "$rendered"
|
||||
|
||||
@@ -7,6 +7,23 @@ source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
run_node_test <<'JS'
|
||||
const model = requireFromRoot('shell/plugins/bar/widgets/KeyboardLayoutModel.js')
|
||||
|
||||
const japanese = { methods: ['keyboard-us', 'mozc'], current: 'mozc', name: 'Mozc', language: 'ja' }
|
||||
assertEqual(model.inputLabel(japanese, 'EN'), 'あ', 'Japanese input has a distinct active label')
|
||||
assertEqual(model.inputLabel({ ...japanese, label: 'ア' }, 'EN'), 'ア', 'the live engine mode takes precedence over its default glyph')
|
||||
assertEqual(model.inputLabel({ ...japanese, current: 'keyboard-us' }, 'EN'), 'EN', 'Latin input retains the keyboard label')
|
||||
assertEqual(model.inputLabel({}, 'EN'), 'EN', 'a stopped input service retains the keyboard label')
|
||||
assertEqual(model.inputLabel({ current: 'hangul' }, 'EN'), '한', 'Korean input has a distinct label')
|
||||
assertEqual(model.inputLabel({ current: 'custom', language: 'vi' }, 'EN'), 'VI', 'other engines use their reported language')
|
||||
assertEqual(model.inputTooltip(japanese, 'English (US)', false).includes('Space twice'), true, 'Japanese explains character choices')
|
||||
assertEqual(model.inputTooltip(japanese, 'English (US)', false).includes('Ctrl + Shift + Space'), true, 'the input toggle shortcut is discoverable')
|
||||
assertEqual(model.inputTooltip(japanese, 'English (US)', true).includes('Right-click'), true, 'combined input and layout switching stays discoverable')
|
||||
assertEqual(model.inputTooltip({ methods: ['keyboard-fr'] }, 'French', false), 'French\nClick to set up an input language', 'one foreign mode offers input setup')
|
||||
assertEqual(model.showIndicator('EN', false, false), false, 'English alone stays hidden')
|
||||
assertEqual(model.showIndicator('ENG', false, false), false, 'English before the brief table loads stays hidden')
|
||||
assertEqual(model.showIndicator('FR', false, false), true, 'a single non-English layout stays available')
|
||||
assertEqual(model.showIndicator('EN', false, true), true, 'Latin with another input mode stays available')
|
||||
assertEqual(model.showIndicator('EN', true, false), true, 'multiple keyboard layouts stay available')
|
||||
|
||||
// Trimmed from xkbcli list, keeping the format of every section it prints,
|
||||
// including the options nested under an option group: those quote their
|
||||
// description and print an empty brief, one indent deeper than a layout's.
|
||||
|
||||
@@ -32,7 +32,7 @@ for status in 1 2 3; do
|
||||
else
|
||||
(( result != 0 )) && ! grep -q '^gum ' "$test_tmp/commands" || fail "inspection errors must not offer enablement"
|
||||
fi
|
||||
grep -q '^sudo -N -- .* __status ' "$test_tmp/commands" || fail "status must not publish reusable authorization"
|
||||
grep -q '^sudo -n -N -l -l -- .* __status ' "$test_tmp/commands" || fail "status must inspect policy without authentication"
|
||||
[[ $(tail -1 "$test_tmp/commands") == 'sudo -k' ]] || fail "public exit must revoke its authorization"
|
||||
done
|
||||
pass "public status distinguishes inactive from errors and revokes authorization on exit"
|
||||
@@ -164,3 +164,214 @@ cp "$ROOT/default/libalpm/hooks/05-omarchy-passwordless-revoke.hook" "$test_tmp/
|
||||
[[ ! -e $(rule_file 1000) ]]
|
||||
)
|
||||
pass "trailing-dollar accounts publish valid native policy and alias-shaped names never publish"
|
||||
|
||||
reset_grant
|
||||
(
|
||||
source "$library"
|
||||
valid_duration permanent
|
||||
! valid_duration forever || fail "invalid duration was accepted"
|
||||
enable_locked 1000 permanent
|
||||
read_grant 1000
|
||||
[[ $GRANT_NAME == audituser && -z $GRANT_DEADLINE ]]
|
||||
[[ $(stat -c '%a' "$(rule_file 1000 permanent)") == 440 ]]
|
||||
/usr/sbin/visudo -cf "$(rule_file 1000 permanent)" >/dev/null
|
||||
! grep -q '^systemd-run ' "$test_tmp/commands" || fail "permanent grant started a timer"
|
||||
TEST_EXPIRED=1 status_locked 1000
|
||||
TEST_EXPIRED=1 expire_locked 1000
|
||||
cleanup_all_locked
|
||||
/usr/bin/systemd-tmpfiles --root="$test_tmp" --remove --boot --inline 'r! /etc/sudoers.d/99-omarchy-nopasswd-*'
|
||||
[[ -f $(rule_file 1000 permanent) ]]
|
||||
cleanup_uid_locked 1000
|
||||
assert_status 3 status_locked 1000
|
||||
)
|
||||
pass "permanent access has no timer, survives expiry and temporary cleanup, and can be disabled"
|
||||
|
||||
reset_grant
|
||||
(
|
||||
source "$library"
|
||||
enable_locked 1000 15
|
||||
enable_locked 1000 permanent
|
||||
[[ ! -e $(rule_file 1000) && -e $(rule_file 1000 permanent) ]]
|
||||
TEST_EXPIRED=1 expire_locked 1000
|
||||
[[ -e $(rule_file 1000 permanent) ]]
|
||||
enable_locked 1000 60
|
||||
[[ -e $(rule_file 1000) && ! -e $(rule_file 1000 permanent) ]]
|
||||
TEST_EXPIRED=1 expire_locked 1000
|
||||
assert_status 3 status_locked 1000
|
||||
TEST_PUBLISH_FAIL=1 assert_status 1 enable_locked 1000 permanent
|
||||
[[ ! -e $(rule_file 1000 permanent) ]]
|
||||
)
|
||||
pass "switching duration replaces the prior policy and old callbacks preserve permanent access"
|
||||
|
||||
reset_grant
|
||||
(
|
||||
source "$library"
|
||||
permanent=$(rule_file 1000 permanent)
|
||||
printf 'audituser ALL=(ALL) NOPASSWD: /usr/bin/true\n' >"$permanent"
|
||||
assert_status 2 status_locked 1000
|
||||
assert_status 1 enable_locked 1000 permanent
|
||||
assert_status 1 cleanup_uid_locked 1000
|
||||
[[ -e $permanent ]]
|
||||
rm "$permanent"
|
||||
enable_locked 1000 permanent
|
||||
TEST_BAD_PATH="$permanent" assert_status 2 status_locked 1000
|
||||
)
|
||||
pass "permanent policy rejects unsafe ownership and preserves administrator edits"
|
||||
|
||||
for choice in '15 minutes' '1 Hour' '1 Day' Permanently; do
|
||||
case "$choice" in
|
||||
'15 minutes') expected=15 ;;
|
||||
'1 Hour') expected=60 ;;
|
||||
'1 Day') expected=1440 ;;
|
||||
Permanently) expected=permanent ;;
|
||||
esac
|
||||
: >"$test_tmp/commands"
|
||||
TEST_CHOICE="$choice" TEST_CONFIRM_STATUS=0 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" >"$test_tmp/public.log" 2>&1
|
||||
grep -q '^gum choose .*15 minutes 1 Hour 1 Day Permanently$' "$test_tmp/commands"
|
||||
grep -q "^sudo -N -- .* __enable .* $expected$" "$test_tmp/commands"
|
||||
[[ $(tail -1 "$test_tmp/commands") == 'sudo -k' ]]
|
||||
if [[ $expected == permanent ]]; then
|
||||
grep -q 'remain enabled across reboots until you disable it' "$test_tmp/public.log"
|
||||
! grep -q 'automatically disable' "$test_tmp/public.log" || fail "permanent grant claims automatic expiry"
|
||||
fi
|
||||
done
|
||||
pass "each duration choice dispatches its exact grant duration with permanent-specific copy"
|
||||
|
||||
: >"$test_tmp/commands"
|
||||
assert_status 130 env TEST_CHOICE_CANCEL=1 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" >"$test_tmp/public.log" 2>&1
|
||||
! grep -q '__enable\|^gum confirm ' "$test_tmp/commands" || fail "cancelled picker continued enablement"
|
||||
[[ $(tail -1 "$test_tmp/commands") == 'sudo -k' ]]
|
||||
: >"$test_tmp/commands"
|
||||
TEST_STATUS=0 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" >"$test_tmp/public.log" 2>&1
|
||||
! grep -q '^gum ' "$test_tmp/commands" || fail "disabling access offered a picker"
|
||||
grep -q '__disable ' "$test_tmp/commands"
|
||||
pass "cancelling the picker grants nothing and active access still toggles off"
|
||||
|
||||
for confirm_status in 0 1; do
|
||||
: >"$test_tmp/commands"
|
||||
TEST_STATUS=0 TEST_CONFIRM_STATUS=$confirm_status /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" permanent >"$test_tmp/public.log" 2>&1
|
||||
grep -q '^gum confirm Enable passwordless sudo permanently?' "$test_tmp/commands"
|
||||
if (( confirm_status == 0 )); then
|
||||
grep -q '__enable .* permanent$' "$test_tmp/commands"
|
||||
else
|
||||
! grep -q '__enable ' "$test_tmp/commands" || fail "declining permanent access still enabled it"
|
||||
fi
|
||||
done
|
||||
pass "changing active access to permanent always requires confirmation"
|
||||
|
||||
reset_grant
|
||||
(
|
||||
source "$library"
|
||||
enable_locked 1000 15
|
||||
TEST_REQUIRE_EXISTING_TARGET=1 enable_locked 1000 60
|
||||
cleanup_uid_locked 1000
|
||||
enable_locked 01000 permanent
|
||||
status_locked 1000
|
||||
[[ -e $(rule_file 1000 permanent) ]]
|
||||
[[ ! -e $test_tmp/etc/sudoers.d/99-omarchy-permanent-nopasswd-01000 ]]
|
||||
cleanup_uid_locked 1000
|
||||
assert_status 3 status_locked 01000
|
||||
)
|
||||
pass "renewal replaces the existing rule atomically and zero-padded UIDs share one policy"
|
||||
|
||||
|
||||
for policy in ' Options: !authenticate' ' Options: authenticate'; do
|
||||
: >"$test_tmp/commands"
|
||||
expected=1
|
||||
[[ $policy != *'!authenticate'* ]] || expected=0
|
||||
assert_status "$expected" env TEST_POLICY="$policy" /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" --active
|
||||
[[ $(wc -l <"$test_tmp/commands") == 1 ]]
|
||||
grep -q '^sudo -n -N -l -l -- .* __status ' "$test_tmp/commands"
|
||||
done
|
||||
assert_status 1 env TEST_POLICY_FAILURE=1 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" --active
|
||||
pass "menu probe reads policy tags without executing a privileged action or changing the timestamp"
|
||||
|
||||
for status in 0 3; do
|
||||
: >"$test_tmp/commands"
|
||||
TEST_STATUS=$status /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" --disable >"$test_tmp/public.log" 2>&1
|
||||
! grep -q '^gum\|__enable ' "$test_tmp/commands" || fail "explicit disable offered enablement"
|
||||
if (( status == 0 )); then
|
||||
grep -q '__disable ' "$test_tmp/commands"
|
||||
fi
|
||||
done
|
||||
pass "explicit disable never enables access even when a displayed grant has expired"
|
||||
|
||||
: >"$test_tmp/commands"
|
||||
TEST_STATUS=3 TEST_CHOICE='15 minutes' TEST_CONFIRM_STATUS=0 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" >"$test_tmp/public.log" 2>&1
|
||||
[[ $(grep -c '^sudo -N -- ' "$test_tmp/commands") == 1 ]] || fail "enable must authenticate exactly one sudo call"
|
||||
grep -q '^sudo -N -- .* __enable .* 15$' "$test_tmp/commands"
|
||||
! grep -q '^sudo -n -N -- .* __status ' "$test_tmp/commands" || fail "inactive flow attempted root status"
|
||||
[[ $(tail -1 "$test_tmp/commands") == 'sudo -k' ]]
|
||||
pass "enabling from inactive policy has exactly one password-capable sudo invocation"
|
||||
|
||||
: >"$test_tmp/commands"
|
||||
TEST_STATUS=0 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" --disable >"$test_tmp/public.log" 2>&1
|
||||
grep -q '^sudo -n -N -- .* __disable ' "$test_tmp/commands" || fail "disable must refuse interactive authentication"
|
||||
! grep -q '^sudo -N -- ' "$test_tmp/commands" || fail "disable attempted an interactive sudo command"
|
||||
pass "disabling active access is fully noninteractive"
|
||||
|
||||
for duration in permanent 15; do
|
||||
reset_grant
|
||||
(
|
||||
source "$library"
|
||||
if [[ $duration == permanent ]]; then
|
||||
enable_locked 1000 15
|
||||
else
|
||||
enable_locked 1000 permanent
|
||||
fi
|
||||
assert_status 137 env TEST_KILL_AFTER_PUBLISH=1 TEST_EUID=0 SUDO_UID=1000 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" __enable 1000 "$duration"
|
||||
[[ -f $(rule_file 1000) && -f $(rule_file 1000 permanent) ]]
|
||||
status_locked 1000
|
||||
TEST_EXPIRED=1 expire_locked 1000
|
||||
[[ -f $(rule_file 1000 permanent) ]]
|
||||
cleanup_uid_locked 1000
|
||||
assert_status 3 status_locked 1000
|
||||
)
|
||||
done
|
||||
pass "SIGKILL during either duration switch leaves access inspectable and revocable without expiring permanent policy"
|
||||
|
||||
reset_grant
|
||||
(
|
||||
source "$library"
|
||||
enable_locked 1000 permanent
|
||||
timed=$(rule_file 1000)
|
||||
printf 'otheruser ALL=(ALL) NOTAFTER=99991231235959Z NOPASSWD: ALL\n' >"$timed"
|
||||
assert_status 2 read_grant 1000
|
||||
assert_status 1 enable_locked 1000 15
|
||||
printf 'audituser ALL=(ALL) NOTAFTER=99991231235959Z NOPASSWD: ALL\n' >"$timed"
|
||||
TEST_BAD_PATH="$timed" assert_status 2 read_grant 1000
|
||||
rm "$timed"
|
||||
ln -s "$(rule_file 1000 permanent)" "$timed"
|
||||
assert_status 2 read_grant 1000
|
||||
rm "$timed"
|
||||
printf 'audituser ALL=(ALL) NOTAFTER=99991231235959Z NOPASSWD: ALL\n' >"$timed"
|
||||
enable_locked 1000 15
|
||||
[[ -f $timed && ! -e $(rule_file 1000 permanent) ]]
|
||||
)
|
||||
pass "paired policy recovery rejects mismatched or unsafe rules and allows a complete duration change"
|
||||
|
||||
: >"$test_tmp/commands"
|
||||
TEST_POLICY_FAILURE=1 TEST_STATUS=0 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" --disable >"$test_tmp/public.log" 2>&1
|
||||
grep -q '^sudo -n -N -- .* __disable ' "$test_tmp/commands"
|
||||
! grep -q '^gum\|^sudo -N -- ' "$test_tmp/commands" || fail "listpw=always disable prompted"
|
||||
TEST_POLICY_FAILURE=1 TEST_STATUS=0 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" --active
|
||||
assert_status 1 env TEST_POLICY_FAILURE=1 TEST_STATUS=1 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" --active
|
||||
: >"$test_tmp/commands"
|
||||
TEST_POLICY_FAILURE=1 TEST_STATUS=1 TEST_CONFIRM_STATUS=0 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" 15 >"$test_tmp/public.log" 2>&1
|
||||
[[ $(grep -c '^sudo -N -- ' "$test_tmp/commands") == 1 ]] || fail "listing failure must leave one enable authentication"
|
||||
grep -q '^sudo -N -- .* __enable .* 15$' "$test_tmp/commands"
|
||||
: >"$test_tmp/commands"
|
||||
assert_status 1 env TEST_POLICY_FAILURE=1 TEST_STATUS=2 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" 15 >"$test_tmp/public.log" 2>&1
|
||||
! grep -q '^gum\|__enable ' "$test_tmp/commands" || fail "unsafe grant was offered enablement"
|
||||
pass "password-required listings do not block enabling, disabling, or active detection"
|
||||
|
||||
: >"$test_tmp/commands"
|
||||
TEST_POLICY_FAILURE=1 TEST_STATUS=2 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" --active
|
||||
grep -q '^sudo -kn -- .* __status ' "$test_tmp/commands" || fail "fallback probe must ignore cached credentials"
|
||||
(
|
||||
source "$library"
|
||||
with_root_lock() { return 1; }
|
||||
verify_sudo_caller() { return 0; }
|
||||
assert_status 2 root_dispatch __status 1000
|
||||
)
|
||||
pass "status lock failures remain inspection errors and an authenticated unsafe grant keeps its warning"
|
||||
@@ -271,6 +271,10 @@ const notification = {
|
||||
expireTimeout: 1.5
|
||||
}
|
||||
const snapshot = notifications.snapshotOf(notification, 12345)
|
||||
assertDeepEqual(JSON.parse(notifications.snapshotOf({ actions: [
|
||||
{ identifier: 'default', text: 'Open' }, { identifier: 'yes', text: 'Yes' }, { identifier: 'no', text: 'No' }
|
||||
]}).actionsJson), [{ identifier: 'yes', text: 'Yes' }, { identifier: 'no', text: 'No' }], 'explicit choices appear independently of the default card action')
|
||||
assertEqual(notifications.popupEntry({ actionsJson: '[{"identifier":"yes","text":"Yes"}]' }, 1).actionsJson, '[]', 'restored notifications do not replay actions from a dead sender')
|
||||
assertDeepEqual(
|
||||
{
|
||||
id: snapshot.id,
|
||||
|
||||
@@ -6,11 +6,12 @@ work=$(mktemp -d)
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
mkdir -p "$work/bin" "$work/home"
|
||||
export TEST_CALLS="$work/calls"
|
||||
for command in omarchy-pkg-add omarchy-hook-install systemctl; do
|
||||
for command in omarchy-pkg-add omarchy-hook-install omarchy-input-method systemctl; do
|
||||
cat >"$work/bin/$command" <<'SH'
|
||||
#!/bin/bash
|
||||
printf '%s %s\n' "${0##*/}" "$*" >>"$TEST_CALLS"
|
||||
case "$*" in
|
||||
'configure --defaults') [[ ${TEST_INPUT_FAIL:-0} == 0 ]] ;;
|
||||
'--user enable owed.service') [[ ${TEST_TTY:-0} == 0 ]] ;;
|
||||
'--user is-active --quiet graphical-session.target') [[ ${TEST_TTY:-0} == 0 ]] ;;
|
||||
'--user start owed.service') [[ ${TEST_START_FAIL:-0} == 0 ]] ;;
|
||||
@@ -58,6 +59,12 @@ grep -E '^systemctl --user enable --now .*owed.service' "$TEST_CALLS" >/dev/null
|
||||
grep -Fx 'omarchy-hook-install theme-set /usr/share/owe/10-owe-sync' "$TEST_CALLS" >/dev/null
|
||||
pass "fresh installs enable OWE and install the theme refresh hook"
|
||||
|
||||
: >"$TEST_CALLS"
|
||||
HOME="$work/home" PATH="$work/bin:$PATH" TEST_INPUT_FAIL=1 bash "$ROOT/install/user/first-run/enable-user-units.sh"
|
||||
grep -Fx 'omarchy-input-method configure --defaults' "$TEST_CALLS" >/dev/null
|
||||
grep -E '^systemctl --user enable --now .*owed.service' "$TEST_CALLS" >/dev/null
|
||||
pass "failed input defaults do not block enabling first-run services"
|
||||
|
||||
cat >"$work/bin/ffmpegthumbnailer" <<'SH'
|
||||
#!/bin/bash
|
||||
printf 'thumbnail\n' >>"$TEST_CALLS"
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
run_node_test <<'JS'
|
||||
const fs = require('fs')
|
||||
const read = name => fs.readFileSync(path.join(root, name), 'utf8')
|
||||
const indicator = read('shell/plugins/bar/indicators/PasswordlessSudo.qml')
|
||||
const widget = read('shell/plugins/bar/widgets/Indicators.qml')
|
||||
const manifest = JSON.parse(read('shell/plugins/bar/widgets/Indicators.manifest.json'))
|
||||
assert(widget.match(/defaultIndicatorEntries: \[ "PasswordlessSudo", "Dictation"/), 'passwordless sudo is included in the default indicator tray')
|
||||
assert(manifest.barWidget.schema.find(field => field.key === 'items').options.some(option => option.value === 'PasswordlessSudo'), 'passwordless sudo is configurable alongside Night Light')
|
||||
assert(indicator.includes('useActiveColor: true') && indicator.includes('activeColor: Color.urgent'), 'active passwordless sudo uses the theme danger color')
|
||||
assertEqual(indicator.match(/activeText: "([^"]+)"/)[1], indicator.match(/inactiveText: "([^"]+)"/)[1], 'sudo keeps the same icon in both states')
|
||||
assert(!widget.includes('sudoHorizontal') && !widget.includes('sudoVertical'), 'sudo participates in the normal indicator blocks')
|
||||
assert(!indicator.includes('visible:'), 'sudo uses the shared indicator visibility and hover behavior')
|
||||
assert(indicator.includes('command: ["omarchy-sudo-passwordless", "--active"]'), 'indicator uses the noninteractive grant probe')
|
||||
assert(indicator.includes('root.granted = exitCode === 0 && exitStatus === 0'), 'failed or interrupted probes do not claim an active grant')
|
||||
assert(indicator.includes('interval: 5000') && indicator.includes('onTriggered: root.refresh()'), 'indicator refreshes after activation, revocation, and expiry')
|
||||
assert(indicator.includes('command: ["omarchy-sudo-passwordless", "--disable"]'), 'active indicator revokes access through a background process')
|
||||
assert(indicator.includes('if (root.granted) disableProc.running = true'), 'active click bypasses the terminal launcher')
|
||||
assert(indicator.includes('root.indicatorHost.refresh()'), 'disabling access refreshes all indicator instances immediately')
|
||||
const press = new Function('root', 'disableProc', indicator.match(/onPressed: function\(\) \{([\s\S]*?)\n \}/)[1])
|
||||
const launched = []
|
||||
const button = { granted: true, bar: { run: command => launched.push(command) } }
|
||||
const revoke = { running: false }
|
||||
press(button, revoke)
|
||||
assert(revoke.running && launched.length === 0, 'clicking active sudo starts background revocation without a terminal')
|
||||
button.granted = false
|
||||
press(button, revoke)
|
||||
assert(launched.length === 0, 'repeat clicks during revocation cannot open the enable flow')
|
||||
revoke.running = false
|
||||
press(button, revoke)
|
||||
assertDeepEqual(launched, ['omarchy-launch-floating-terminal-with-presentation omarchy-sudo-passwordless'], 'inactive click still opens interactive setup')
|
||||
JS
|
||||
@@ -14,10 +14,10 @@ rewrite() { sed -e "s|/etc/|$root/etc/|g" -e "s| /\\.cargo/| $root/.cargo/|g" "$
|
||||
rewrite "$ROOT/install/config/region.sh" "$sandbox/install/config/region.sh"
|
||||
rewrite "$ROOT/bin/omarchy-region" "$sandbox/bin/omarchy-region"
|
||||
|
||||
for command in omarchy-update-pacman omarchy-refresh-pacman locale-gen rime-ice-installer omarchy-setup-claude-api systemctl omarchy-system-logout pacman-key pacman omarchy-apply-pacman; do
|
||||
for command in omarchy-refresh-pacman locale-gen omarchy-setup-claude-api omarchy-system-logout pacman-key pacman omarchy-apply-pacman; do
|
||||
printf '#!/bin/bash\necho "%s $*" >>"%s"\n' "$command" "$calls" >"$test_tmp/bin/$command"
|
||||
done
|
||||
printf '#!/bin/bash\necho "omarchy-pkg-add $*" >>"%s"\n[[ -z ${STUB_PKG_FAIL:-} ]]\n' "$calls" >"$test_tmp/bin/omarchy-pkg-add"
|
||||
printf '#!/bin/bash\necho "omarchy-update-pacman $*" >>"%s"\n[[ -z ${STUB_UPDATE_FAIL:-} ]]\n' "$calls" >"$test_tmp/bin/omarchy-update-pacman"
|
||||
printf '#!/bin/bash\necho "${STUB_CHANNEL:-stable}"\n' >"$test_tmp/bin/omarchy-version-channel"
|
||||
printf '#!/bin/bash\n[[ $1 == env ]] && shift\nexec env "$@"\n' >"$test_tmp/bin/sudo"
|
||||
printf '#!/bin/bash\necho "gum $*" >>"%s"\nexit "${GUM_ANSWER:-1}"\n' "$calls" >"$test_tmp/bin/gum"
|
||||
@@ -32,17 +32,15 @@ printf 'LANG=en_US.UTF-8\n' >"$root/etc/locale.conf"
|
||||
omarchy-region cn >/dev/null
|
||||
[[ $(omarchy-region) == "China" && $(<"$root/etc/omarchy/region") == "cn" && $(<"$root/etc/locale.conf") == "LANG=zh_CN.UTF-8" ]] ||
|
||||
fail "switching to China records cn and sets the language"
|
||||
[[ $(switch_calls) == $'omarchy-update-pacman -Syu --noconfirm\nomarchy-pkg-add fcitx5-configtool fcitx5-rime librime opencc rime-ice-installer\nlocale-gen \nomarchy-apply-pacman --render stable RENDERED\npacman --config RENDERED/pacman.conf -Sy --noconfirm --needed archlinuxcn-keyring\npacman-key --populate archlinuxcn\nomarchy-refresh-pacman stable\nsystemctl --user try-restart omarchy-fcitx5.service\nrime-ice-installer --yes' ]] ||
|
||||
fail "China installs Rime, trusts ArchLinuxCN through the rendered cn config, refreshes, then sets Rime Ice up" "$(switch_calls)"
|
||||
[[ $(switch_calls) == $'omarchy-update-pacman -Syu --noconfirm\nlocale-gen \nomarchy-apply-pacman --render stable RENDERED\npacman --config RENDERED/pacman.conf -Sy --noconfirm --needed archlinuxcn-keyring\npacman-key --populate archlinuxcn\nomarchy-refresh-pacman stable' ]] ||
|
||||
fail "China upgrades, trusts ArchLinuxCN through the rendered cn config, then refreshes" "$(switch_calls)"
|
||||
[[ -z $(ls -A "$test_tmp/tmp") ]] || fail "the rendered config is removed"
|
||||
pass "switching to China installs Rime, trusts ArchLinuxCN before the refresh upgrades through it"
|
||||
pass "switching to China trusts ArchLinuxCN before the refresh upgrades through it"
|
||||
|
||||
: >"$calls"
|
||||
mkdir -p "$HOME/.local/share/fcitx5/rime" && touch "$HOME/.local/share/fcitx5/rime/rime_ice.schema.yaml"
|
||||
STUB_CHANNEL=edge omarchy-region cn >/dev/null
|
||||
grep -qx 'omarchy-refresh-pacman edge' "$calls" || fail "the refresh keeps the machine's channel" "$(<"$calls")"
|
||||
grep -q '^rime-ice-installer\|^systemctl' "$calls" && fail "an existing Rime Ice setup is not redone"
|
||||
pass "switching to China again keeps the channel and redoes no Rime Ice setup"
|
||||
pass "switching to China again keeps the channel"
|
||||
|
||||
: >"$calls"
|
||||
omarchy-region global >/dev/null
|
||||
@@ -50,8 +48,7 @@ omarchy-region global >/dev/null
|
||||
fail "switching to Global removes the region and restores English"
|
||||
[[ $(<"$calls") == $'omarchy-refresh-pacman stable\nomarchy-setup-claude-api --remove' ]] ||
|
||||
fail "Global refreshes pacman back to the channel defaults and removes the Claude API setup" "$(<"$calls")"
|
||||
[[ -f $HOME/.local/share/fcitx5/rime/rime_ice.schema.yaml ]] || fail "Global keeps the input method"
|
||||
pass "switching to Global restores the channel's pacman defaults and English, keeps Rime, removes the Claude API setup"
|
||||
pass "switching to Global restores the channel's pacman defaults and English, removes the Claude API setup"
|
||||
|
||||
if output=$(omarchy-region mars 2>&1); then
|
||||
fail "an unknown region is refused" "$output"
|
||||
@@ -63,8 +60,8 @@ fi
|
||||
pass "an unknown region or an unclear channel is refused"
|
||||
|
||||
: >"$calls"
|
||||
if output=$(STUB_PKG_FAIL=1 omarchy-region cn 2>&1); then
|
||||
fail "switching to China fails when its packages do not install" "$output"
|
||||
if output=$(STUB_UPDATE_FAIL=1 omarchy-region cn 2>&1); then
|
||||
fail "switching to China fails when the upgrade fails" "$output"
|
||||
fi
|
||||
[[ $(omarchy-region) == "Global" ]] && ! grep -q '^omarchy-refresh-pacman' "$calls" ||
|
||||
fail "a failed switch to China leaves the region Global and pacman untouched" "$(<"$calls")"
|
||||
|
||||
@@ -29,11 +29,7 @@ pass "an unknown region fails and names the file and value"
|
||||
stub_bin="$test_tmp/bin"
|
||||
calls="$test_tmp/calls"
|
||||
mkdir -p "$stub_bin"
|
||||
for command in omarchy-pkg-add rime-ice-installer locale-gen; do
|
||||
printf '#!/bin/bash\necho "%s $*" >>"%s"\n' "$command" "$calls" >"$stub_bin/$command"
|
||||
done
|
||||
printf '#!/bin/bash\necho "omarchy-pkg-add $*" >>"%s"\n[[ -z ${STUB_PKG_FAIL:-} ]]\n' "$calls" >"$stub_bin/omarchy-pkg-add"
|
||||
printf '#!/bin/bash\necho "$STUB_REGION"\n' >"$stub_bin/omarchy-region"
|
||||
printf '#!/bin/bash\necho "locale-gen $*" >>"%s"\n' "$calls" >"$stub_bin/locale-gen"
|
||||
chmod +x "$stub_bin"/*
|
||||
PATH="$stub_bin:$PATH"
|
||||
|
||||
@@ -48,14 +44,10 @@ printf 'LANG=en_US.UTF-8\nLC_TIME=en_DK.UTF-8\n' >"$root/etc/locale.conf"
|
||||
: >"$calls"
|
||||
OMARCHY_REGION=cn bash -e "$region_setup"
|
||||
[[ $(<"$root/etc/omarchy/region") == "cn" ]] || fail "install records the China region as cn"
|
||||
[[ $(<"$calls") == $'omarchy-pkg-add fcitx5-configtool fcitx5-rime librime opencc rime-ice-installer\nlocale-gen ' ]] ||
|
||||
fail "China installs the Rime input method packages and generates the Chinese locale" "$(<"$calls")"
|
||||
[[ $(<"$calls") == "locale-gen " ]] || fail "China generates the Chinese locale" "$(<"$calls")"
|
||||
[[ $(<"$root/etc/locale.gen") == $'zh_CN.UTF-8 UTF-8\nen_US.UTF-8 UTF-8' ]] || fail "China enables zh_CN in locale.gen"
|
||||
[[ $(<"$root/etc/locale.conf") == $'LANG=zh_CN.UTF-8\nLC_TIME=en_DK.UTF-8' ]] || fail "China sets LANG and keeps other locale settings" "$(<"$root/etc/locale.conf")"
|
||||
for package in fcitx5-configtool fcitx5-rime librime opencc rime-ice-installer; do
|
||||
grep -qx "$package" "$ROOT/install/omarchy-other.packages" || fail "the ISO mirror carries $package"
|
||||
done
|
||||
pass "install records the China region and installs the Rime packages the ISO carries"
|
||||
pass "install records the China region and sets the Chinese locale"
|
||||
|
||||
for link in "environment.conf etc/environment.d/90-omarchy-china.conf" "cargo.toml .cargo/config.toml" "gemrc etc/gemrc"; do
|
||||
read -r source target <<<"$link"
|
||||
@@ -66,7 +58,6 @@ pass "China links the developer mirror drop-ins"
|
||||
: >"$calls"
|
||||
OMARCHY_REGION=global bash -e "$region_setup"
|
||||
[[ ! -e $root/etc/omarchy/region ]] || fail "install records Global by leaving no region file"
|
||||
[[ ! -s $calls ]] || fail "Global installs no input method packages" "$(<"$calls")"
|
||||
[[ $(<"$root/etc/locale.conf") == $'LANG=en_US.UTF-8\nLC_TIME=en_DK.UTF-8' ]] || fail "Global turns a Chinese LANG back to English" "$(<"$root/etc/locale.conf")"
|
||||
printf 'LANG=de_DE.UTF-8\n' >"$root/etc/locale.conf"
|
||||
OMARCHY_REGION=global bash -e "$region_setup"
|
||||
@@ -91,12 +82,6 @@ fi
|
||||
rm "$root/etc/gemrc"
|
||||
pass "region setup never touches mirror config Omarchy did not create"
|
||||
|
||||
if output=$(STUB_PKG_FAIL=1 OMARCHY_REGION=cn bash -e "$region_setup" 2>&1); then
|
||||
fail "China fails when its packages do not install" "$output"
|
||||
fi
|
||||
[[ ! -e $root/etc/omarchy/region && ! -e $root/etc/gemrc && $(<"$root/etc/locale.conf") == "LANG=en_US.UTF-8" ]] ||
|
||||
fail "a failed package install leaves the machine Global"
|
||||
pass "China installs its packages before it writes any region state"
|
||||
echo cn >"$root/etc/omarchy/region"
|
||||
: >"$calls"
|
||||
env -u OMARCHY_REGION bash -e "$region_setup"
|
||||
@@ -113,22 +98,3 @@ if output=$(OMARCHY_REGION=mars bash -e "$region_setup" 2>&1); then
|
||||
fi
|
||||
[[ $output == "Unknown region: mars" ]] || fail "install names the unknown region" "$output"
|
||||
pass "install rejects an unknown region"
|
||||
|
||||
user_region="$ROOT/install/user/region.sh"
|
||||
|
||||
: >"$calls"
|
||||
STUB_REGION=China OMARCHY_SETUP_CONTEXT=iso-chroot bash -e "$user_region"
|
||||
[[ $(<"$calls") == "rime-ice-installer --yes --asset-dir /opt/packages/rime-ice" ]] ||
|
||||
fail "a China install sets up Rime Ice from the ISO's assets" "$(<"$calls")"
|
||||
pass "a China install sets up Rime Ice from the ISO's assets"
|
||||
|
||||
: >"$calls"
|
||||
output=$(STUB_REGION=China OMARCHY_SETUP_CONTEXT=provision-owner bash -e "$user_region")
|
||||
[[ ! -s $calls && $output == "Skipping Rime Ice setup in provision-owner: only the ISO bundles its assets" ]] ||
|
||||
fail "first-boot setup says why it skips Rime Ice" "$output $(<"$calls")"
|
||||
pass "first-boot setup says why it skips Rime Ice"
|
||||
|
||||
: >"$calls"
|
||||
output=$(STUB_REGION=Global OMARCHY_SETUP_CONTEXT=iso-chroot bash -e "$user_region")
|
||||
[[ ! -s $calls && -z $output ]] || fail "a Global install leaves the input method alone" "$output $(<"$calls")"
|
||||
pass "a Global install leaves the input method alone"
|
||||
@@ -66,6 +66,8 @@ trap 'if [[ ${FUNCNAME[0]:-} == "$PROMPT_FN" ]]; then printf "returned\n" >>"$MA
|
||||
printf 'region=%s\n' "${region:-}"
|
||||
printf 'keyboard=%s\n' "${keyboard:-}"
|
||||
printf 'keyboard_label=%s\n' "${keyboard_label:-}"
|
||||
printf 'keyboard_input_method=%s\n' "${keyboard_input_method:-}"
|
||||
printf 'keyboard_xkb_layout=%s\n' "${keyboard_xkb_layout:-}"
|
||||
printf 'username=%s\n' "${username:-}"
|
||||
printf 'password=%s\n' "${password:-}"
|
||||
printf 'password_confirmation=%s\n' "${password_confirmation:-}"
|
||||
@@ -167,6 +169,19 @@ assert_status 0 "keyboard prompt succeeds"
|
||||
grep -qF -- '--selected English (US)' "$GUM_ARGS" || fail "keyboard prompt preselects English (US)"
|
||||
pass "keyboard prompt maps the chosen label to its keymap"
|
||||
|
||||
for selection in 'Japanese|jp106|mozc|' 'Japanese (US keyboard)|us|mozc|' 'Korean|us|hangul|kr' 'Chinese (Simplified, Pinyin)|us|pinyin|' 'Chinese (Traditional, Zhuyin)|us|chewing|'; do
|
||||
IFS='|' read -r label keymap method layout <<< "$selection"
|
||||
run_prompt omarchy_prompt_keyboard "0:$label"
|
||||
assert_status 0 "$label can be selected at install"
|
||||
[[ $(field keyboard) == "$keymap" && $(field keyboard_input_method) == "$method" && $(field keyboard_xkb_layout) == "$layout" ]] ||
|
||||
fail "$label carries a real console keymap and a separate input preference"
|
||||
done
|
||||
pass "language keyboard choices carry their keymap, input method, and desktop override"
|
||||
|
||||
keyboard_input_method=hangul keyboard_xkb_layout=kr omarchy_persist_input_selection "$tmp_dir/target"
|
||||
[[ $(<"$tmp_dir/target/etc/omarchy/input-method") == $'INPUT_METHOD=hangul\nXKB_LAYOUT=kr' ]] || fail "first-boot setup persists the selection for user finalization"
|
||||
pass "first-boot setup persists the selection for user finalization"
|
||||
|
||||
run_prompt omarchy_prompt_keyboard "1:"
|
||||
assert_status "$OMARCHY_FORM_BACK" "keyboard prompt reports Esc as back"
|
||||
assert_returned "keyboard prompt survives Esc under set -e"
|
||||
|
||||
@@ -71,7 +71,7 @@ grep -Fx 'WantedBy=graphical-session.target' "$fcitx_service" >/dev/null ||
|
||||
grep -Fx 'ConditionEnvironment=WAYLAND_DISPLAY' "$fcitx_service" >/dev/null ||
|
||||
fail "an update over SSH has a live user manager and no display; starting fcitx5 there wedges the unit active-but-blind, and Wants= will not replace it at graphical login"
|
||||
|
||||
grep -F 'pkill -x fcitx5' "$ROOT/bin/omarchy-restart-xcompose" >/dev/null ||
|
||||
grep -F 'pkill -u "$UID" -x fcitx5' "$ROOT/bin/omarchy-restart-xcompose" >/dev/null ||
|
||||
fail "restart-xcompose cannot reload a fcitx5 running outside the unit, so it silently keeps serving the old table"
|
||||
|
||||
grep -F 'omarchy-fcitx5.service' "$first_run_units" >/dev/null ||
|
||||
|
||||
@@ -23,7 +23,7 @@ SH
|
||||
}
|
||||
|
||||
run_orphan_checker() {
|
||||
HOME="$test_home" PATH="$stub_bin:$PATH" "$ROOT/bin/omarchy-update-orphan-pkgs"
|
||||
HOME="$test_home" PATH="$stub_bin:$ROOT/bin:$PATH" "$ROOT/bin/omarchy-update-orphan-pkgs" "$@"
|
||||
}
|
||||
|
||||
write_stub pacman 'if [[ $1 == "-Qtdq" ]]; then printf "old-lib\nunused-tool\n"; exit 0; fi; exit 1'
|
||||
@@ -35,7 +35,29 @@ grep -q '^ old-lib$' "$test_tmp/noninteractive.out" || fail "orphan checker lis
|
||||
grep -q 'Re-run omarchy-update-orphan-pkgs in a terminal' "$test_tmp/noninteractive.out" || fail "orphan checker does not remove packages non-interactively"
|
||||
pass "orphan checker only reports orphans non-interactively"
|
||||
|
||||
export REMOVAL_LOG="$test_tmp/removal.log"
|
||||
write_stub pacman 'case $1 in
|
||||
-Qtdq|-Qq) printf "old-lib\nunused-tool\n" ;;
|
||||
-Rns) printf "%s\n" "$@" >"$REMOVAL_LOG" ;;
|
||||
*) exit 1 ;;
|
||||
esac'
|
||||
write_stub sudo '"$@"'
|
||||
run_orphan_checker -y >"$test_tmp/approved.out" 2>"$test_tmp/approved.err"
|
||||
diff <(printf '%s\n' -Rns --noconfirm old-lib unused-tool) "$REMOVAL_LOG" ||
|
||||
fail "approved cleanup removes the orphan packages without pacman confirmation"
|
||||
pass "approved cleanup removes orphans without Omarchy or pacman confirmation"
|
||||
|
||||
write_stub sudo 'exit 42'
|
||||
if run_orphan_checker -y >"$test_tmp/failure.out" 2>"$test_tmp/failure.err"; then
|
||||
fail "approved cleanup reports removal failures"
|
||||
fi
|
||||
pass "approved cleanup reports removal failures"
|
||||
|
||||
rm -f "$REMOVAL_LOG"
|
||||
write_stub pacman 'if [[ $1 == "-Qtdq" ]]; then exit 0; fi; exit 1'
|
||||
run_orphan_checker >"$test_tmp/none.out" 2>"$test_tmp/none.err"
|
||||
[[ ! -s $test_tmp/none.out ]] || fail "orphan checker stays quiet when no orphans exist"
|
||||
for mode in "" -y; do
|
||||
run_orphan_checker $mode >"$test_tmp/none.out" 2>"$test_tmp/none.err"
|
||||
[[ ! -s $test_tmp/none.out ]] || fail "orphan checker stays quiet when no orphans exist"
|
||||
done
|
||||
[[ ! -e $REMOVAL_LOG ]] || fail "orphan checker does not remove packages when no orphans exist"
|
||||
pass "orphan checker stays quiet without orphans"
|
||||
@@ -35,6 +35,9 @@ for step in "${steps[@]}"; do
|
||||
cat >"$stub_bin/$step" <<'STUB'
|
||||
#!/bin/bash
|
||||
printf '%s unattended=%s\n' "${0##*/}" "${OMARCHY_UPDATE_UNATTENDED:-}" >>"$STEP_LOG"
|
||||
if [[ ${0##*/} == "omarchy-update-orphan-pkgs" ]]; then
|
||||
printf '%s\n' "$@" >"$STEP_LOG.orphan-args"
|
||||
fi
|
||||
[[ ${FAILING_STEP:-} != "${0##*/}" ]] || exit 1
|
||||
STUB
|
||||
chmod +x "$stub_bin/$step"
|
||||
@@ -84,6 +87,8 @@ run_update -y || fail "an update where everything works reports a failure"
|
||||
diff <(expected_steps) <(steps_run) >"$test_tmp/order" ||
|
||||
fail "an update where everything works does not run every step in order" "$(cat "$test_tmp/order")"
|
||||
pass "an update where every step works runs all of them, in order"
|
||||
[[ $(cat "$test_tmp/steps.orphan-args") == "-y" ]] || fail "an unattended update approves orphan removal"
|
||||
pass "an unattended update approves orphan removal"
|
||||
|
||||
grep -q '^omarchy-update-system-pkgs unattended=1$' "$test_tmp/steps" ||
|
||||
fail "-y does not mark the update unattended"
|
||||
@@ -93,6 +98,8 @@ diff <(expected_steps confirmed) <(steps_run) >"$test_tmp/order" ||
|
||||
grep -q '^omarchy-update-system-pkgs unattended=$' "$test_tmp/steps" ||
|
||||
fail "an update a person confirmed is treated as unattended"
|
||||
pass "-y is what marks an update unattended, not the update itself"
|
||||
[[ $(cat "$test_tmp/steps.orphan-args") == "-y" ]] || fail "a confirmed update approves orphan removal"
|
||||
pass "a confirmed update approves orphan removal"
|
||||
|
||||
# Migrations ship with the packages the upgrade installs and are written against
|
||||
# them. Running them against what is still on disk is the failure this ordering
|
||||
|
||||
Executable
+127
@@ -0,0 +1,127 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
leaf="$ROOT/install/hardware/dell-xps13-ptl-speaker-firmware.sh"
|
||||
migration="$ROOT/migrations/1791265613.sh"
|
||||
|
||||
grep -q 'run_logged .*hardware/dell-xps13-ptl-speaker-firmware.sh' "$ROOT/install/hardware/all.sh" ||
|
||||
fail "hardware setup installs the Panther Lake XPS 13 firmware"
|
||||
pass "hardware setup installs the Panther Lake XPS 13 firmware"
|
||||
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
mkdir -p "$test_tmp/bin"
|
||||
|
||||
cat >"$test_tmp/bin/omarchy-hw-match" <<'SH'
|
||||
#!/bin/bash
|
||||
[[ $TEST_PRODUCT_NAME == *"$1"* ]]
|
||||
SH
|
||||
cat >"$test_tmp/bin/omarchy-hw-intel-ptl" <<'SH'
|
||||
#!/bin/bash
|
||||
[[ $TEST_INTEL_PTL == "1" ]]
|
||||
SH
|
||||
cat >"$test_tmp/bin/sudo" <<'SH'
|
||||
#!/bin/bash
|
||||
if [[ $1 == "pacman" ]]; then
|
||||
exec "$@"
|
||||
fi
|
||||
printf 'Unexpected privileged command: %s\n' "$*" >&2
|
||||
exit 1
|
||||
SH
|
||||
cat >"$test_tmp/bin/pacman" <<'SH'
|
||||
#!/bin/bash
|
||||
if [[ $1 == "-Q" ]]; then
|
||||
if [[ ${@: -1} == "linux-firmware-cirrus-dx13260" ]]; then
|
||||
[[ -e $TEST_ALIAS_FILE ]] || exit 1
|
||||
printf 'linux-firmware-cirrus-dx13260 20260810-1\n'
|
||||
else
|
||||
[[ -s $TEST_VERSION_FILE ]] || exit 1
|
||||
printf 'linux-firmware-cirrus %s\n' "$(<"$TEST_VERSION_FILE")"
|
||||
fi
|
||||
else
|
||||
printf 'pacman %s\n' "$*" >>"$TEST_LOG"
|
||||
[[ $* == "-S --noconfirm --needed -- linux-firmware-cirrus-dx13260" ]] || exit 1
|
||||
[[ ${TEST_INSTALL_FAILURE:-0} == "0" ]] || exit 1
|
||||
touch "$TEST_ALIAS_FILE"
|
||||
fi
|
||||
SH
|
||||
cat >"$test_tmp/bin/omarchy-state" <<'SH'
|
||||
#!/bin/bash
|
||||
printf 'state %s\n' "$*" >>"$TEST_LOG"
|
||||
SH
|
||||
chmod +x "$test_tmp/bin/"*
|
||||
|
||||
export PATH="$test_tmp/bin:$ROOT/bin:$PATH"
|
||||
export TEST_LOG="$test_tmp/calls" TEST_VERSION_FILE="$test_tmp/version" TEST_ALIAS_FILE="$test_tmp/alias-installed"
|
||||
export TEST_PRODUCT_NAME="XPS 13 DX13260" TEST_INTEL_PTL=1
|
||||
install_call="pacman -S --noconfirm --needed -- linux-firmware-cirrus-dx13260"
|
||||
export OMARCHY_PATH="$ROOT"
|
||||
|
||||
reset_fixture() {
|
||||
: >"$TEST_LOG"
|
||||
printf '%s' "$1" >"$TEST_VERSION_FILE"
|
||||
rm -f "$TEST_ALIAS_FILE"
|
||||
}
|
||||
|
||||
run_leaf() {
|
||||
bash -eE -c 'source "$1"' bash "$leaf"
|
||||
}
|
||||
|
||||
run_migration() {
|
||||
bash -euo pipefail "$migration" >/dev/null
|
||||
}
|
||||
|
||||
for model in "XPS 9350" "XPS 13 DX13261"; do
|
||||
reset_fixture "20260810-2"
|
||||
TEST_PRODUCT_NAME="$model" run_leaf
|
||||
TEST_PRODUCT_NAME="$model" run_migration
|
||||
[[ ! -s $TEST_LOG ]] || fail "other models receive no firmware repair"
|
||||
done
|
||||
reset_fixture "20260810-2"
|
||||
TEST_INTEL_PTL=0 run_leaf
|
||||
TEST_INTEL_PTL=0 run_migration
|
||||
[[ ! -s $TEST_LOG ]] || fail "the Wildcat Lake variant receives no firmware repair"
|
||||
pass "other models and the Wildcat Lake variant receive no firmware repair"
|
||||
|
||||
for version in "" "20260810-2" "20260810-3" "20260810-4" "20260910-2" "20260916-1" "1:20260810-2"; do
|
||||
reset_fixture "$version"
|
||||
run_leaf || fail "the alias package is installed during hardware setup"
|
||||
[[ $(<"$TEST_LOG") == "$install_call" && $(<"$TEST_VERSION_FILE") == "$version" && -e $TEST_ALIAS_FILE ]] ||
|
||||
fail "hardware setup installs the separate aliases without replacing stock firmware"
|
||||
done
|
||||
pass "hardware setup installs the package identity without replacing any stock firmware"
|
||||
pass "hardware setup leaves reboot bookkeeping to the migration"
|
||||
|
||||
reset_fixture "20260810-2"
|
||||
touch "$TEST_ALIAS_FILE"
|
||||
run_leaf
|
||||
[[ ! -s $TEST_LOG ]] || fail "installed aliases are idempotent on stock firmware"
|
||||
pass "installed aliases are idempotent on stock firmware"
|
||||
|
||||
run_migration
|
||||
[[ $(<"$TEST_LOG") == "state set reboot-required" ]] ||
|
||||
fail "the migration requests a reboot even if hardware setup already installed the aliases"
|
||||
pass "the migration requests a reboot even if hardware setup already installed the aliases"
|
||||
|
||||
reset_fixture "20260810-2"
|
||||
if TEST_INSTALL_FAILURE=1 run_migration; then
|
||||
fail "a failed firmware installation fails the migration"
|
||||
fi
|
||||
[[ $(<"$TEST_LOG") == "$install_call" && $(<"$TEST_VERSION_FILE") == "20260810-2" ]] ||
|
||||
fail "a failed installation leaves the migration retryable without claiming success"
|
||||
pass "a failed firmware installation fails the migration and remains retryable"
|
||||
|
||||
: >"$TEST_LOG"
|
||||
run_migration
|
||||
[[ $(<"$TEST_LOG") == "$install_call"$'\nstate set reboot-required' ]] ||
|
||||
fail "retrying the migration installs the firmware and requests a reboot"
|
||||
pass "retrying the migration installs the firmware and requests a reboot"
|
||||
|
||||
: >"$TEST_LOG"
|
||||
run_migration
|
||||
[[ $(<"$TEST_LOG") == "state set reboot-required" ]] ||
|
||||
fail "a second user before reboot is prompted without reinstalling"
|
||||
pass "a second user before reboot is prompted without reinstalling"
|
||||
Reference in new issue
Block a user