Merge current Quattro while preserving command checks
This commit is contained in:
commit
aa422fcd4e
63 files changed
+4644
-353
No files matched your search
@@ -20,9 +20,7 @@ Run `omarchy-restart-shell` after making changes to QML files.
|
||||
[`docs/omarchy-shell.md`](../../docs/omarchy-shell.md) and
|
||||
`shell/services/PluginRegistry.qml` for the current contract; fields such as
|
||||
`activation` are optional.
|
||||
- Entry-point QML files are `Item`s (not `ShellRoot`), and accept the
|
||||
shell-injected properties `omarchyPath`, `shell`, `manifest`, and
|
||||
`pluginRegistry` / `barWidgetRegistry` as appropriate.
|
||||
- Entry-point QML files are `Item`s (not `ShellRoot`), and accept the shell-injected properties `omarchyPath`, `shell`, `manifest`, and `pluginRegistry` / `barWidgetRegistry` as appropriate. First-party plugins receive the host objects. Third-party plugins receive capability-scoped facades: ordinary plugins may look up and control only their own service and lifecycle, built-in clones retain narrow source-specific configuration and UI compatibility, menu plugins receive an application-library facade, and plugins can read detached scalar bar state; full-bar plugins additionally receive detached bar configuration and widget-catalog snapshots, narrow proxies for the non-authentication services used by built-in bar widgets, and lifecycle control over configured non-authentication UI plugins. Authentication capabilities must be stamped from trusted first-party manifests, and third-party registry views and bar configuration must be detached snapshots rather than shared objects. These facades reduce accidental authority but are not a same-process QML sandbox: a visual bar widget can walk its parent hierarchy to ordinary host objects. Authentication services must therefore remain outside both `ShellRoot._services` and the host QObject tree. Do not expose authentication services through new third-party-facing properties.
|
||||
- Panel / overlay / menu plugins must expose `open(payloadJson)` and
|
||||
`close()` lifecycle methods for `shell summon` and `shell hide`.
|
||||
|
||||
|
||||
@@ -129,7 +129,8 @@ term_pt_for() {
|
||||
'BEGIN { printf "%d", int(s * p / b + 0.5) }'
|
||||
}
|
||||
|
||||
# Set the font point size in every terminal config that exists. Family is left
|
||||
# Set the font point size in terminal configs, creating Kitty overrides when
|
||||
# it inherits its size from the system config. Family is left
|
||||
# untouched — that is omarchy-font-set's job. Live-reload signals mirror
|
||||
# omarchy-font-set; foot has no reload signal, so running instances are nudged.
|
||||
set_terminal_size() {
|
||||
@@ -139,8 +140,13 @@ set_terminal_size() {
|
||||
sed -i -E "s/^size[[:space:]]*=.*/size = $pt/" ~/.config/alacritty/alacritty.toml
|
||||
fi
|
||||
|
||||
if [[ -f ~/.config/kitty/kitty.conf ]]; then
|
||||
sed -i -E "s/^font_size[[:space:]]+.*/font_size $pt.0/" ~/.config/kitty/kitty.conf
|
||||
if [[ -f ~/.config/kitty/kitty.conf ]] || omarchy-cmd-present kitty; then
|
||||
mkdir -p ~/.config/kitty
|
||||
if grep -qE '^[[:space:]]*font_size[[:space:]]+' ~/.config/kitty/kitty.conf 2>/dev/null; then
|
||||
sed --follow-symlinks -i -E "s/^[[:space:]]*font_size[[:space:]]+.*/font_size $pt.0/" ~/.config/kitty/kitty.conf
|
||||
else
|
||||
printf '\nfont_size %s.0\n' "$pt" >>~/.config/kitty/kitty.conf
|
||||
fi
|
||||
pkill -USR1 kitty 2>/dev/null || true
|
||||
fi
|
||||
|
||||
@@ -177,9 +183,13 @@ term_current_pt() {
|
||||
elif [[ -f ~/.config/alacritty/alacritty.toml ]]; then
|
||||
grep -oP '^size[[:space:]]*=[[:space:]]*\K[0-9.]+' ~/.config/alacritty/alacritty.toml | head -1
|
||||
elif [[ -f ~/.config/kitty/kitty.conf ]]; then
|
||||
grep -oP '^font_size[[:space:]]+\K[0-9.]+' ~/.config/kitty/kitty.conf | head -1
|
||||
local pt
|
||||
pt=$(grep -oP '^[[:space:]]*font_size[[:space:]]+\K[0-9.]+' ~/.config/kitty/kitty.conf | tail -1)
|
||||
echo "${pt:-$TERM_DEFAULT_PT}"
|
||||
elif [[ -f ~/.config/foot/foot.ini ]]; then
|
||||
grep -oP ':size=\K[0-9.]+' ~/.config/foot/foot.ini | head -1
|
||||
elif omarchy-cmd-present kitty; then
|
||||
echo "$TERM_DEFAULT_PT"
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
@@ -30,8 +30,14 @@ if [[ -f ~/.config/alacritty/alacritty.toml ]]; then
|
||||
sed -i "s/family = \".*\"/family = \"$font_name\"/g" ~/.config/alacritty/alacritty.toml
|
||||
fi
|
||||
|
||||
if [[ -f ~/.config/kitty/kitty.conf ]]; then
|
||||
sed -i "s/^font_family .*/font_family $font_name/g" ~/.config/kitty/kitty.conf
|
||||
if [[ -f ~/.config/kitty/kitty.conf ]] || omarchy-cmd-present kitty; then
|
||||
mkdir -p ~/.config/kitty
|
||||
if grep -qE '^[[:space:]]*font_family[[:space:]]+' ~/.config/kitty/kitty.conf 2>/dev/null; then
|
||||
kitty_font_name=$(printf '%s' "$font_name" | sed 's/[\\&/]/\\&/g')
|
||||
sed --follow-symlinks -i -E "s/^[[:space:]]*font_family[[:space:]]+.*/font_family $kitty_font_name/" ~/.config/kitty/kitty.conf
|
||||
else
|
||||
printf '\nfont_family %s\n' "$font_name" >>~/.config/kitty/kitty.conf
|
||||
fi
|
||||
pkill -USR1 kitty
|
||||
fi
|
||||
|
||||
|
||||
@@ -30,6 +30,35 @@ MKINITCPIO_CONF="/etc/mkinitcpio.conf.d/omarchy_resume.conf"
|
||||
SWAP_FILE="/swap/swapfile"
|
||||
RESUME_DROP_IN="/etc/limine-entry-tool.d/resume.conf"
|
||||
|
||||
install_root_file() {
|
||||
local source="$1"
|
||||
local destination="$2"
|
||||
local mode="$3"
|
||||
local stage
|
||||
|
||||
stage=$(sudo /usr/bin/mktemp -- "${destination%/*}/.${destination##*/}.omarchy.XXXXXX") || return 1
|
||||
safe_stage_path "$stage" "$destination" || return 1
|
||||
|
||||
if sudo /usr/bin/install -m "$mode" -o root -g root -T "$source" "$stage" &&
|
||||
sudo /usr/bin/mv -Tf -- "$stage" "$destination"; then
|
||||
return 0
|
||||
else
|
||||
safe_stage_path "$stage" "$destination" && sudo /usr/bin/rm -f -- "$stage"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
safe_stage_path() {
|
||||
local stage="$1"
|
||||
local destination="$2"
|
||||
local prefix suffix
|
||||
|
||||
prefix="${destination%/*}/.${destination##*/}.omarchy."
|
||||
[[ $stage == "$prefix"* ]] || return 1
|
||||
suffix=${stage#"$prefix"}
|
||||
[[ $suffix =~ ^[[:alnum:]]{6}$ ]]
|
||||
}
|
||||
|
||||
# Check if hibernation is already configured
|
||||
if [[ -f $MKINITCPIO_CONF ]] && grep -q "^HOOKS+=(resume)$" "$MKINITCPIO_CONF"; then
|
||||
# Fix empty resume_offset if btrfs map-swapfile failed during initial setup
|
||||
@@ -83,14 +112,20 @@ if ! swapon --show | grep -q "$SWAP_FILE"; then
|
||||
sudo swapon -p 0 "$SWAP_FILE"
|
||||
fi
|
||||
|
||||
# Ensure keyboard backlight doesn't prevent sleep
|
||||
# Install this before writing the resume marker so a failed install remains
|
||||
# retryable through the normal setup command.
|
||||
if ! install_root_file "$OMARCHY_PATH/default/systemd/system-sleep/keyboard-backlight" \
|
||||
/usr/lib/systemd/system-sleep/keyboard-backlight 0755; then
|
||||
echo "Could not install the keyboard-backlight system-sleep hook" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Add resume hook to mkinitcpio
|
||||
sudo mkdir -p /etc/mkinitcpio.conf.d
|
||||
echo "Adding resume hook to $MKINITCPIO_CONF"
|
||||
echo "HOOKS+=(resume)" | sudo tee "$MKINITCPIO_CONF" >/dev/null
|
||||
|
||||
# Ensure keyboard backlight doesn't prevent sleep
|
||||
sudo cp -p "$OMARCHY_PATH/default/systemd/system-sleep/keyboard-backlight" /usr/lib/systemd/system-sleep/
|
||||
|
||||
# Add resume= kernel parameters so the initramfs resume hook knows where to find the
|
||||
# hibernation image. Without these, resume happens late (after GPU drivers load) and fails.
|
||||
if [[ ! -f $RESUME_DROP_IN ]]; then
|
||||
|
||||
@@ -5,28 +5,155 @@
|
||||
|
||||
set -e
|
||||
|
||||
# No CLI is installed here on purpose. Hermes Desktop only runs against a
|
||||
# runtime built from its own commit, so it provisions one itself under
|
||||
# ~/.hermes on first launch, which takes a few minutes and shows its own
|
||||
# progress. Handing it the mise CLI instead fails: PyPI trails the tags, and
|
||||
# the version gap fails the app's readiness probe with a 401.
|
||||
if (( EUID == 0 )); then
|
||||
echo "Run this command as your desktop user, without sudo." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Installing Hermes Desktop..."
|
||||
omarchy-pkg-add hermes-desktop
|
||||
|
||||
if [[ ! -r /usr/share/hermes-desktop/install.sh || ! -r /usr/share/hermes-desktop/runtime.patch ]] ||
|
||||
! release_commit=$(jq -er 'select(.branch == "main") | .commit | select(test("^[0-9a-f]{40}$"))' /opt/hermes-desktop/resources/install-stamp.json 2>/dev/null); then
|
||||
echo "The installed Hermes package cannot prepare in-app updates. Run 'omarchy update', then try again." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# If Hermes was already installed for the terminal, the app supersedes it: one
|
||||
# machine, one Hermes. This drops that copy so the terminal, the default agent
|
||||
# and the app all end up on the app's installation.
|
||||
omarchy-install-hermes-cli || true
|
||||
|
||||
# Keep the runtime at the root even when invoked from a Hermes profile.
|
||||
HERMES_HOME=$(realpath -ms -- "${HERMES_HOME:-$HOME/.hermes}")
|
||||
home_parent=$(dirname -- "$HERMES_HOME")
|
||||
if [[ ${home_parent##*/} == [Pp][Rr][Oo][Ff][Ii][Ll][Ee][Ss] ]]; then
|
||||
HERMES_HOME=$(dirname -- "$home_parent")
|
||||
fi
|
||||
export HERMES_HOME
|
||||
|
||||
runtime="$HERMES_HOME/hermes-agent"
|
||||
native_app="$runtime/apps/desktop/release/linux-unpacked"
|
||||
|
||||
runtime_ready() {
|
||||
[[ -f $runtime/.hermes-bootstrap-complete && -f $runtime/venv/bin/hermes && -x $runtime/venv/bin/hermes && -f $runtime/venv/bin/python && -x $runtime/venv/bin/python ]] &&
|
||||
timeout 15 "$runtime/venv/bin/hermes" --version >/dev/null 2>&1
|
||||
}
|
||||
|
||||
check_main() {
|
||||
local main_commit
|
||||
main_commit=$(git -C "$runtime" rev-parse --verify refs/heads/main 2>/dev/null || true)
|
||||
if [[ -n $main_commit && $main_commit != "$release_commit" && $main_commit != "$(git -C "$runtime" rev-parse --verify refs/remotes/origin/main 2>/dev/null)" ]]; then
|
||||
echo "Hermes main has local commits. Keep that work and prepare the desktop with 'hermes desktop --build-only'." >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
if ! runtime_ready; then
|
||||
# The upstream installer can reset an existing checkout. Do not pin a newer
|
||||
# or modified runtime back to the package release while repairing setup.
|
||||
if [[ -e $runtime || -L $runtime ]]; then
|
||||
if [[ $(git -C "$runtime" rev-parse HEAD 2>/dev/null) != "$release_commit" ]] ||
|
||||
[[ -n $(git -C "$runtime" status --porcelain --untracked-files=all) ]]; then
|
||||
echo "Hermes setup is incomplete at $runtime. Repair that installation before trying again; existing files have been kept." >&2
|
||||
exit 1
|
||||
fi
|
||||
check_main
|
||||
fi
|
||||
|
||||
# Upstream replaces these commands, including foreign files and symlinks.
|
||||
# Keep their original bytes/links before handing the names to the desktop.
|
||||
command_backup=""
|
||||
for command in hermes hermes-agent hermes-acp; do
|
||||
command_path="$HOME/.local/bin/$command"
|
||||
if [[ -e $command_path || -L $command_path ]]; then
|
||||
if [[ ! -f $command_path && ! -L $command_path ]]; then
|
||||
echo "Cannot replace $command_path: move it aside before installing Hermes Desktop." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z $command_backup ]]; then
|
||||
command_backup=$(mktemp -d "$HOME/.local/bin/.hermes-before-desktop.XXXXXX")
|
||||
echo "Saving existing Hermes commands in $command_backup"
|
||||
fi
|
||||
cp -a -- "$command_path" "$command_backup/"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Setting up the Hermes runtime..."
|
||||
bash /usr/share/hermes-desktop/install.sh --skip-setup --branch main --commit "$release_commit" --force-commit --dir "$runtime" --hermes-home "$HERMES_HOME"
|
||||
if ! runtime_ready; then
|
||||
echo "Hermes runtime setup did not complete. Re-run this command after resolving the installer error." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
runtime_commit=$(git -C "$runtime" rev-parse HEAD)
|
||||
if [[ $runtime_commit == "$release_commit" ]]; then
|
||||
# The updater switches to main before checking for changes. Start main at
|
||||
# the packaged release, with enough history for its first fast-forward.
|
||||
check_main
|
||||
if [[ $(git -C "$runtime" rev-parse --is-shallow-repository) == "true" ]]; then
|
||||
git -C "$runtime" fetch --unshallow origin main
|
||||
fi
|
||||
git -C "$runtime" switch -C main "$release_commit"
|
||||
|
||||
if git -C "$runtime" apply --check /usr/share/hermes-desktop/runtime.patch >/dev/null 2>&1; then
|
||||
git -C "$runtime" apply /usr/share/hermes-desktop/runtime.patch
|
||||
elif ! git -C "$runtime" apply --reverse --check /usr/share/hermes-desktop/runtime.patch >/dev/null 2>&1; then
|
||||
echo "The Hermes Linux runtime patch conflicts with local changes. Existing files have been kept." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -e $native_app || -L $native_app ]]; then
|
||||
if [[ ! -f $native_app/Hermes || ! -x $native_app/Hermes || ! -f $native_app/resources/app.asar || ! -f $native_app/resources/install-stamp.json ]]; then
|
||||
echo "The Hermes desktop app at $native_app is incomplete. Repair it with 'hermes desktop --build-only' before trying again." >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
if [[ $runtime_commit != "$release_commit" ]]; then
|
||||
echo "The Hermes runtime has moved beyond the packaged desktop release. Run 'hermes desktop --build-only', then try again." >&2
|
||||
exit 1
|
||||
fi
|
||||
desktop_changes=$(git -C "$runtime" status --porcelain --untracked-files=all -- apps/desktop package.json package-lock.json)
|
||||
if [[ -n $desktop_changes ]]; then
|
||||
echo "Hermes desktop sources have local changes. Run 'hermes desktop --build-only', then try again; existing files have been kept." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p -- "${native_app%/*}"
|
||||
staging=$(mktemp -d "${native_app%/*}/.linux-unpacked.XXXXXX")
|
||||
trap 'rm -rf -- "$staging"' EXIT
|
||||
cp -a /opt/hermes-desktop/. "$staging/"
|
||||
chmod 0755 "$staging/chrome-sandbox"
|
||||
mv -T --no-clobber -- "$staging" "$native_app"
|
||||
if [[ -e $staging ]]; then
|
||||
echo "A Hermes desktop app appeared during setup. It has been kept; please try again." >&2
|
||||
exit 1
|
||||
fi
|
||||
trap - EXIT
|
||||
|
||||
# Record this matching prebuilt app using the CLI's own content hash, so
|
||||
# subsequent menu launches do not rebuild an app that is already current.
|
||||
env -u PYTHONPATH -u PYTHONHOME "$runtime/venv/bin/python" - "$runtime" <<'PY'
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, sys.argv[1])
|
||||
from hermes_cli.main import _write_desktop_build_stamp
|
||||
|
||||
_write_desktop_build_stamp(Path(sys.argv[1]), source_mode=False)
|
||||
PY
|
||||
fi
|
||||
|
||||
echo "Opening Hermes Desktop..."
|
||||
setsid uwsm-app -- /usr/bin/hermes-desktop >/dev/null 2>&1 &
|
||||
|
||||
# Only a running Hermes can be told which skin to show, and the first launch
|
||||
# takes minutes; a unit outlives this terminal and reports to the journal.
|
||||
# Only a running Hermes can be told which skin to show; a unit outlives this
|
||||
# terminal and reports to the journal.
|
||||
echo "Matching Hermes to the current theme once it is set up..."
|
||||
systemctl --user stop omarchy-hermes-theme.service 2>/dev/null || true
|
||||
systemd-run --user --quiet --collect --unit=omarchy-hermes-theme omarchy-theme-set-hermes --wait
|
||||
|
||||
echo ""
|
||||
echo "Hermes Desktop has been installed."
|
||||
echo "Its first launch installs the Hermes runtime, which takes a few minutes."
|
||||
@@ -12,8 +12,38 @@ package=$1
|
||||
command=${2:-$1}
|
||||
bin=${3:-$command}
|
||||
|
||||
# The command name becomes a file name under ~/.local/bin, so a slash in it
|
||||
# writes the wrapper somewhere else and the rm below deletes somewhere else. A
|
||||
# leading dot hides it or walks up, and a leading dash makes a name that reads
|
||||
# as an option to whatever picks it up. Checked before anything is removed or
|
||||
# written, and kept to those shapes so package names like npm:playwright still
|
||||
# stand in for the command name.
|
||||
case "$command" in
|
||||
*/* | .* | -* | *[[:cntrl:]]*)
|
||||
echo "omarchy-mise-install: '$command' is not usable as a command name" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
|
||||
# The heredoc below is unquoted, so whatever these hold is written into the
|
||||
# wrapper as shell source. Quote them the way omarchy-install-and-launch does, so
|
||||
# a package name carrying shell characters stays one argument instead of running.
|
||||
printf -v package_arg '%q' "$package"
|
||||
printf -v bin_arg '%q' "$bin"
|
||||
|
||||
# Keep values that are inert inside double quotes in the form existing migrations
|
||||
# recognize, including mise backend options that %q would unnecessarily escape.
|
||||
case "$package" in
|
||||
*'$'* | *'`'* | *'"'* | *'\'* | *'!'* | *[[:cntrl:]]*) ;;
|
||||
*) package_arg="\"$package\"" ;;
|
||||
esac
|
||||
case "$bin" in
|
||||
*'$'* | *'`'* | *'"'* | *'\'* | *'!'* | *[[:cntrl:]]*) ;;
|
||||
*) bin_arg="\"$bin\"" ;;
|
||||
esac
|
||||
|
||||
# These tools install and upgrade on first run, so mise's release cooldown would
|
||||
# hold a new version back for days after it ships. Exported rather than set on
|
||||
# the install line alone, so resolving the version to execute agrees with the
|
||||
@@ -22,8 +52,8 @@ rm -f "$HOME/.local/bin/$command"
|
||||
cat >"$HOME/.local/bin/$command" <<EOF
|
||||
#!/bin/bash
|
||||
export MISE_MINIMUM_RELEASE_AGE=0
|
||||
mise use -g --quiet "$package" || exit 1
|
||||
exec mise x "$package" -- "$bin" "\$@"
|
||||
mise use -g --quiet $package_arg || exit 1
|
||||
exec mise x $package_arg -- $bin_arg "\$@"
|
||||
EOF
|
||||
|
||||
chmod +x "$HOME/.local/bin/$command"
|
||||
@@ -24,6 +24,6 @@ if [[ -n $pkg_names ]]; then
|
||||
source omarchy-sudo-keepalive
|
||||
|
||||
echo "$pkg_names" | sed 's/^/aur\//' | tr '\n' ' ' | xargs yay -S --noconfirm
|
||||
sudo updatedb
|
||||
sudo updatedb --prune-bind-mounts=no --add-prunepaths=/.snapshots
|
||||
omarchy-show-done
|
||||
fi
|
||||
@@ -6,6 +6,52 @@
|
||||
# -u so an unset HOME is an error rather than a set of rm -rf paths rooted at /.
|
||||
set -euo pipefail
|
||||
|
||||
ensure_hermes_stopped() {
|
||||
python3 - "$HOME" <<'PY'
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
home = Path(sys.argv[1])
|
||||
roots = [str((home / relative).resolve()) for relative in ('.hermes', '.config/Hermes')]
|
||||
roots.append('/opt/hermes-desktop')
|
||||
|
||||
def belongs_to_hermes(target):
|
||||
target = target.removesuffix(' (deleted)')
|
||||
return any(target == root or target.startswith(root + '/') for root in roots)
|
||||
|
||||
holders = []
|
||||
for process in Path('/proc').iterdir():
|
||||
if not process.name.isdigit() or int(process.name) == os.getpid():
|
||||
continue
|
||||
try:
|
||||
if process.stat().st_uid != os.getuid():
|
||||
continue
|
||||
targets = [os.fsdecode(arg) for arg in (process / 'cmdline').read_bytes().split(b'\0')]
|
||||
entries = [process / 'exe', process / 'cwd']
|
||||
try:
|
||||
entries.extend((process / 'fd').iterdir())
|
||||
except PermissionError:
|
||||
pass
|
||||
for entry in entries:
|
||||
try:
|
||||
targets.append(os.readlink(entry))
|
||||
except OSError:
|
||||
pass
|
||||
if any(belongs_to_hermes(target) for target in targets):
|
||||
holders.append(process.name)
|
||||
except (FileNotFoundError, ProcessLookupError, PermissionError):
|
||||
continue
|
||||
|
||||
if holders:
|
||||
print('Close Hermes and processes using its files before removing it (PIDs: '
|
||||
+ ', '.join(holders) + '). Then try again.', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
PY
|
||||
}
|
||||
|
||||
# Removing an open SQLite WAL leaves a live writer on a deleted generation.
|
||||
ensure_hermes_stopped
|
||||
omarchy-pkg-drop hermes-desktop
|
||||
|
||||
# The installer leaves a unit waiting to hand the app the Omarchy theme.
|
||||
@@ -19,6 +65,7 @@ systemctl --user stop omarchy-hermes-theme.service 2>/dev/null || true
|
||||
# Tolerated here rather than fatal, so the ~/.hermes handling below still runs;
|
||||
# the failure is answered for at the end instead of being swallowed.
|
||||
cli_removed=true
|
||||
ensure_hermes_stopped
|
||||
omarchy-install-hermes-cli --remove || cli_removed=false
|
||||
|
||||
# The app writes this when the runtime it provisions under ~/.hermes has landed,
|
||||
@@ -78,6 +125,7 @@ if [[ -d $HOME/.hermes || -d $HOME/.config/Hermes ]] && [[ -t 0 ]]; then
|
||||
# turn that into an aborted removal; the size is worth no such thing.
|
||||
size=$(du -shc "$HOME/.hermes" "$HOME/.config/Hermes" 2>/dev/null | tail -1 | cut -f1 || true)
|
||||
if gum confirm --default=false "Also delete ~/.hermes and ~/.config/Hermes ($size: chats, memories, skills, connections and tokens)?"; then
|
||||
ensure_hermes_stopped
|
||||
rm -rf "$HOME/.hermes" "$HOME/.config/Hermes"
|
||||
data_removed=true
|
||||
fi
|
||||
|
||||
@@ -13,6 +13,19 @@ if [[ $1 && ! $1 =~ ^[0-9]+$ ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
arm_expiry() {
|
||||
if sudo systemd-run --on-active=${MINUTES}m --timer-property=AccuracySec=1s --unit="$TIMER_NAME" \
|
||||
rm -f -- "$NOPASSWD_FILE"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Failed to schedule passwordless sudo expiry. Revoking access now." >&2
|
||||
if ! sudo rm -f -- "$NOPASSWD_FILE"; then
|
||||
echo "CRITICAL: Could not remove $NOPASSWD_FILE. Remove it as root immediately." >&2
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
echo "Toggle passwordless sudo..."
|
||||
|
||||
# Safety: if the file exists but the timer doesn't (e.g. after reboot), clean up
|
||||
@@ -24,8 +37,7 @@ fi
|
||||
if sudo test -f "$NOPASSWD_FILE"; then
|
||||
if [[ $1 ]]; then
|
||||
sudo systemctl stop "${TIMER_NAME}.timer" 2>/dev/null
|
||||
sudo systemd-run --on-active=${MINUTES}m --timer-property=AccuracySec=1s --unit="$TIMER_NAME" \
|
||||
rm "$NOPASSWD_FILE"
|
||||
arm_expiry || exit 1
|
||||
echo "Passwordless sudo timer updated. It will now automatically disable in ${MINUTES} minutes."
|
||||
else
|
||||
sudo rm "$NOPASSWD_FILE"
|
||||
@@ -48,12 +60,11 @@ else
|
||||
if gum confirm "Enable passwordless sudo for ${MINUTES} minutes? This is a significant security risk!"; then
|
||||
echo "${USER} ALL=(ALL) NOPASSWD: ALL" | sudo tee "$NOPASSWD_FILE" > /dev/null
|
||||
sudo chmod 440 "$NOPASSWD_FILE"
|
||||
sudo systemd-run --on-active=${MINUTES}m --timer-property=AccuracySec=1s --unit="$TIMER_NAME" \
|
||||
rm "$NOPASSWD_FILE"
|
||||
arm_expiry || exit 1
|
||||
|
||||
echo ""
|
||||
echo "Passwordless sudo has been ENABLED. It will automatically disable in ${MINUTES} minutes."
|
||||
echo "Note: if you restart before then, run omarchy-sudo-passwordless again to disable it."
|
||||
echo "A restart removes the passwordless sudo rule as well."
|
||||
else
|
||||
echo "Aborted. No changes made."
|
||||
fi
|
||||
|
||||
@@ -3,6 +3,35 @@
|
||||
# omarchy:summary=Toggle dedicated vs integrated GPU mode via supergfxd (for hybrid gpu laptops, like Asus G14).
|
||||
# omarchy:requires-sudo=true
|
||||
|
||||
install_root_file() {
|
||||
local source="$1"
|
||||
local destination="$2"
|
||||
local mode="$3"
|
||||
local stage
|
||||
|
||||
stage=$(sudo /usr/bin/mktemp -- "${destination%/*}/.${destination##*/}.omarchy.XXXXXX") || return 1
|
||||
safe_stage_path "$stage" "$destination" || return 1
|
||||
|
||||
if sudo /usr/bin/install -m "$mode" -o root -g root -T "$source" "$stage" &&
|
||||
sudo /usr/bin/mv -Tf -- "$stage" "$destination"; then
|
||||
return 0
|
||||
else
|
||||
safe_stage_path "$stage" "$destination" && sudo /usr/bin/rm -f -- "$stage"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
safe_stage_path() {
|
||||
local stage="$1"
|
||||
local destination="$2"
|
||||
local prefix suffix
|
||||
|
||||
prefix="${destination%/*}/.${destination##*/}.omarchy."
|
||||
[[ $stage == "$prefix"* ]] || return 1
|
||||
suffix=${stage#"$prefix"}
|
||||
[[ $suffix =~ ^[[:alnum:]]{6}$ ]]
|
||||
}
|
||||
|
||||
if omarchy-cmd-missing supergfxctl; then
|
||||
omarchy-pkg-add supergfxctl
|
||||
|
||||
@@ -54,18 +83,31 @@ case "$gpu_mode" in
|
||||
;;
|
||||
"Hybrid")
|
||||
if gum confirm "Use only integrated GPU and reboot?"; then
|
||||
# Switch to integrated mode and ensure vfio is enabled (needed for sleep/wake trick)
|
||||
sudo sed -i "s/\"mode\": \".*\"/\"mode\": \"Integrated\"/" /etc/supergfxd.conf
|
||||
sudo sed -i 's/"vfio_enable": false/"vfio_enable": true/' /etc/supergfxd.conf
|
||||
|
||||
# Force igpu mode after system sleep (or dgpu could get activated)
|
||||
sudo mkdir -p /usr/lib/systemd/system-sleep
|
||||
sudo cp -p "$OMARCHY_PATH/default/systemd/system-sleep/force-igpu" /usr/lib/systemd/system-sleep/
|
||||
|
||||
# Delay supergfxd startup to avoid race condition with display manager
|
||||
# that can cause system freeze when booting in Integrated mode
|
||||
sudo mkdir -p /etc/systemd/system/supergfxd.service.d
|
||||
sudo cp -p "$OMARCHY_PATH/default/systemd/system/supergfxd.service.d/delay-start.conf" /etc/systemd/system/supergfxd.service.d/
|
||||
if ! install_root_file "$OMARCHY_PATH/default/systemd/system/supergfxd.service.d/delay-start.conf" \
|
||||
/etc/systemd/system/supergfxd.service.d/delay-start.conf 0644; then
|
||||
echo "Could not install the supergfxd startup-delay override" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Publish the self-guarding sleep hook before enabling Integrated mode. It
|
||||
# remains inert while the config says Hybrid, so any failed step is safe to
|
||||
# retry without leaving the GPU config partially switched.
|
||||
sudo mkdir -p /usr/lib/systemd/system-sleep
|
||||
if ! install_root_file "$OMARCHY_PATH/default/systemd/system-sleep/force-igpu" \
|
||||
/usr/lib/systemd/system-sleep/force-igpu 0755; then
|
||||
echo "Could not install the force-igpu system-sleep hook" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Switch both settings in one atomic config rewrite only after every
|
||||
# supporting file has been installed successfully.
|
||||
sudo sed -i \
|
||||
-e 's/"mode": ".*"/"mode": "Integrated"/' \
|
||||
-e 's/"vfio_enable": false/"vfio_enable": true/' \
|
||||
/etc/supergfxd.conf
|
||||
|
||||
omarchy-system-reboot
|
||||
fi
|
||||
|
||||
+14
-28
@@ -1,35 +1,21 @@
|
||||
# Remove the include below to disconnect Kitty from Omarchy's theming system.
|
||||
include ~/.local/state/omarchy/current/theme/kitty.conf
|
||||
|
||||
# Settings below override Omarchy's defaults in /etc/xdg/kitty/kitty.conf.
|
||||
# Learn more: https://sw.kovidgoyal.net/kitty/conf/
|
||||
|
||||
# Font
|
||||
font_family JetBrainsMono Nerd Font
|
||||
bold_italic_font auto
|
||||
font_size 9.0
|
||||
# font_family JetBrainsMono Nerd Font
|
||||
# font_size 12
|
||||
|
||||
# Window
|
||||
window_padding_width 14
|
||||
hide_window_decorations yes
|
||||
confirm_os_window_close 0
|
||||
# Window padding
|
||||
# window_padding_width 14
|
||||
|
||||
# Keybindings
|
||||
map ctrl+insert copy_to_clipboard
|
||||
map shift+insert paste_from_clipboard
|
||||
# Send Shift+Enter as CSI-u so TUIs can distinguish it from Enter.
|
||||
map shift+enter send_text all \e[13;2u
|
||||
# Kitty legacy encoding sends Alt+Shift+Enter the same as Alt+Enter; send CSI-u so tmux can match M-S-Enter.
|
||||
map alt+shift+enter send_text all \e[13;4u
|
||||
# Unmap a shortcut, passing it through to the terminal application
|
||||
# map ctrl+insert
|
||||
|
||||
# Allow remote access
|
||||
allow_remote_control yes
|
||||
listen_on unix:${XDG_RUNTIME_DIR}/omarchy-kitty-{kitty_pid}
|
||||
# Set or replace a shortcut
|
||||
# map ctrl+shift+c copy_to_clipboard
|
||||
|
||||
# Aesthetics
|
||||
cursor_shape block
|
||||
cursor_blink_interval 0
|
||||
shell_integration no-cursor
|
||||
enable_audio_bell no
|
||||
|
||||
# Minimal Tab bar styling
|
||||
tab_bar_edge bottom
|
||||
tab_bar_style powerline
|
||||
tab_powerline_style slanted
|
||||
tab_title_template {title}{' :{}:'.format(num_windows) if num_windows > 1 else ''}
|
||||
# Remove all inherited shortcuts, including Kitty's built-in shortcuts
|
||||
# clear_all_shortcuts yes
|
||||
@@ -12,6 +12,7 @@ fcitx5-configtool
|
||||
fcitx5-wayland-launcher
|
||||
foot-server
|
||||
footclient
|
||||
hermes
|
||||
java-java-openjdk
|
||||
jconsole-java-openjdk
|
||||
jshell-java-openjdk
|
||||
|
||||
@@ -1,29 +1,65 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
# Use the Vfio to Integrated trick to turn off NVIDIA dgpu when in integrated mode
|
||||
# without needing to restart the computer. This is needed because computers like the Asus G14
|
||||
# will wake after suspend in Hybrid mode, even if the system was in Integrated mode before
|
||||
# suspending.
|
||||
|
||||
restore_marker=/run/omarchy-force-igpu-integrated
|
||||
sleep_action=${SYSTEMD_SLEEP_ACTION:-$2}
|
||||
[[ -x /usr/bin/supergfxctl ]] || exit 0
|
||||
|
||||
switch_mode() {
|
||||
local expected="$1" current
|
||||
|
||||
if ! /usr/bin/timeout --kill-after=1s 3s /usr/bin/supergfxctl -m "$expected"; then
|
||||
echo "Could not request the GPU transition to $expected mode" >&2
|
||||
return 1
|
||||
fi
|
||||
for _ in {1..10}; do
|
||||
if current=$(/usr/bin/timeout --kill-after=1s 2s /usr/bin/supergfxctl -g 2>/dev/null) &&
|
||||
[[ $current == "$expected" ]]; then
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "Could not confirm the GPU transition to $expected mode" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
pre)
|
||||
# Remember the mode this sleep cycle started in. supergfxctl persists the
|
||||
# temporary hibernate switch to Vfio, so post must not consult that mutable
|
||||
# value when deciding whether to restore Integrated mode.
|
||||
if [[ -L $restore_marker ]]; then
|
||||
exit 1
|
||||
elif [[ ! -f $restore_marker ]]; then
|
||||
/usr/bin/grep -Eq '"mode"[[:space:]]*:[[:space:]]*"Integrated"' /etc/supergfxd.conf 2>/dev/null || exit 0
|
||||
/usr/bin/install -m 0600 -o root -g root -T /dev/null "$restore_marker"
|
||||
fi
|
||||
|
||||
# Before hibernating, switch to Vfio so the nvidia driver is detached from the dGPU.
|
||||
# Without this, hibernate resume fails because the nvidia driver can't freeze a
|
||||
# powered-off dGPU (returns -EIO), which aborts the entire resume.
|
||||
if [[ $2 == "hibernate" ]]; then
|
||||
/usr/bin/supergfxctl -m Vfio
|
||||
sleep 1
|
||||
if [[ $sleep_action == "hibernate" ]]; then
|
||||
switch_mode Vfio
|
||||
fi
|
||||
;;
|
||||
post)
|
||||
[[ -f $restore_marker && ! -L $restore_marker ]] || exit 0
|
||||
|
||||
# small delay so the device is fully re-enumerated
|
||||
sleep 4
|
||||
|
||||
# force-bind dGPU to vfio (fully detached from nvidia)
|
||||
/usr/bin/supergfxctl -m Vfio
|
||||
sleep 1
|
||||
switch_mode Vfio
|
||||
|
||||
# then go back to Integrated, which powers it off again
|
||||
/usr/bin/supergfxctl -m Integrated
|
||||
switch_mode Integrated
|
||||
/usr/bin/rm -f -- "$restore_marker"
|
||||
;;
|
||||
esac
|
||||
@@ -3,7 +3,9 @@
|
||||
# Turn off keyboard backlight before hibernate to prevent hang on power-off.
|
||||
# The ASUS keyboard controller can block S4 shutdown if LEDs are active.
|
||||
|
||||
if [[ $1 == "pre" && $2 == "hibernate" ]]; then
|
||||
sleep_action=${SYSTEMD_SLEEP_ACTION:-$2}
|
||||
|
||||
if [[ $1 == "pre" && $sleep_action == "hibernate" ]]; then
|
||||
device=""
|
||||
for candidate in /sys/class/leds/*kbd_backlight*; do
|
||||
if [[ -e "$candidate" ]]; then
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[Service]
|
||||
ExecStart=
|
||||
ExecStart=/usr/bin/updatedb --prune-bind-mounts=no --add-prunepaths=/.snapshots
|
||||
+18
-2
@@ -84,6 +84,7 @@ version ──► omarchy /usr/share/omarchy
|
||||
config/** ──► omarchy-settings /etc/skel/.config/** (seeds new users)
|
||||
/usr/share/omarchy/config/** (resync source)
|
||||
etc/fastfetch/config.jsonc ──► omarchy-settings /etc/fastfetch/config.jsonc
|
||||
etc/xdg/kitty/kitty.conf ──► omarchy-settings /etc/xdg/kitty/kitty.conf
|
||||
|
||||
applications/*.desktop ──► omarchy-settings /etc/skel/.local/share/applications/
|
||||
/usr/share/omarchy/applications/
|
||||
@@ -124,8 +125,7 @@ default/** ──► omarchy-settings /usr/share/omarchy
|
||||
├─ applications/mimeapps.list /usr/share/applications/mimeapps.list
|
||||
├─ systemd/user/*.service /usr/lib/systemd/user/
|
||||
├─ systemd/user/app.slice.d/10-oomd.conf /usr/lib/systemd/user/app.slice.d/
|
||||
├─ systemd/system-sleep/{force-igpu,
|
||||
│ keyboard-backlight,unmount-fuse} /usr/lib/systemd/system-sleep/
|
||||
├─ systemd/system-sleep/unmount-fuse /usr/lib/systemd/system-sleep/
|
||||
├─ systemd/zram-generator.conf.d/90-omarchy.conf /usr/lib/systemd/zram-generator.conf.d/
|
||||
├─ fonts/omarchy/omarchy.ttf /usr/share/fonts/omarchy/
|
||||
├─ sddm/omarchy/ /usr/share/sddm/themes/omarchy/
|
||||
@@ -139,6 +139,8 @@ logo.{txt,svg}, icon.{txt,png} ──► omarchy-settings /usr/share/omarchy
|
||||
/etc/skel/.config/omarchy/branding/{about,screensaver}.txt
|
||||
```
|
||||
|
||||
The hardware-conditional `force-igpu` and `keyboard-backlight` sources also live under `default/systemd/system-sleep/`, but their setup commands publish root-owned copies only on machines that need them; they are not installed by `omarchy-settings`.
|
||||
|
||||
### Why `etc-overrides/` exists
|
||||
|
||||
Some files under `/etc/` (`.bashrc` in `/etc/skel`, `nsswitch.conf`,
|
||||
@@ -152,6 +154,14 @@ without a file conflict. Instead their sources (under `etc/` in the repo;
|
||||
Tradeoff: user edits to those files get clobbered on every `omarchy-settings`
|
||||
upgrade. This is documented in the PKGBUILD.
|
||||
|
||||
## Locate indexing
|
||||
|
||||
`default/systemd/system/plocate-updatedb.service.d/10-omarchy.conf` ships through `omarchy-settings` to `/usr/lib/systemd/system/plocate-updatedb.service.d/10-omarchy.conf`. It replaces the existing service's `ExecStart` with `updatedb --prune-bind-mounts=no --add-prunepaths=/.snapshots`, keeping Btrfs subvolume mounts searchable and excluding Snapper snapshots. The upstream service retains its timer, resource limits, and sandbox; Omarchy's existing AC-power condition still applies.
|
||||
|
||||
`/etc/updatedb.conf` remains owned by plocate and is never rewritten by Omarchy. The command-line options override bind-mount pruning and add to the administrator's existing path exclusions. Installer and AUR package refreshes pass the same options directly because installation may run without systemd and an explicitly requested refresh should work on battery.
|
||||
|
||||
Arch's systemd package hook reloads units when the vendor drop-in is installed or upgraded. The settings package containing the drop-in must ship alongside the runtime package that removes the old configuration helper and migration. Pacman removes those retired files; no new state migration is needed. A running indexer finishes with its original options, and subsequent service starts use the drop-in. For an immediate local test after installing the packages, restart `plocate-updatedb.service` while connected to AC power.
|
||||
|
||||
## Env bootstrap (`default/bash/env-bootstrap`)
|
||||
|
||||
Single source of truth for `OMARCHY_PATH` and dev-link-aware `PATH`. It:
|
||||
@@ -351,3 +361,9 @@ return to the packaged default.
|
||||
| New stock theme | `themes/<name>/` (+ matching templates under `default/themed/` if they need theme colors) |
|
||||
| User-installed theme | `~/.config/omarchy/themes/<name>/` |
|
||||
| Generated current theme/background state | `~/.local/state/omarchy/current/` |
|
||||
|
||||
## Kitty defaults and user overrides
|
||||
|
||||
Kitty loads `/etc/xdg/kitty/kitty.conf` before `~/.config/kitty/kitty.conf`. The `omarchy-settings` package owns the system file; the user template contains only the active theme include and commented examples for personal overrides. Keeping the theme include in the user file lets users remove it without changing the packaged defaults. Individual inherited keybindings can be unmapped with an empty `map <shortcut>` directive, or all inherited bindings can be cleared with `clear_all_shortcuts yes`.
|
||||
|
||||
The system default uses `allow_remote_control socket-only` so Omarchy can query the active terminal directory over its Unix socket while Kitty rejects remote-control requests arriving through terminal output. Changing this setting requires restarting Kitty. The migration refreshes the exact previous stock config with a backup; customized configs retain their settings and ordering, with only explicit unrestricted `yes`, `y`, or `true` remote-control settings commented out.
|
||||
+4
-10
@@ -39,10 +39,9 @@ Only one full bar option is active at a time. The built-in `omarchy.bar` is
|
||||
used when `bar.id` is omitted or when a selected third-party bar cannot load.
|
||||
Panels, overlays, and menus are loaded when summoned. Plugins can set the top-level manifest key `keepLoaded: true` to survive between summons, and to keep a service mounted across plugin hot-reload (so `omarchy.lock` is not destroyed while Hyprland still holds the session lock). First-party services are loaded at startup.
|
||||
|
||||
Entry points are QML `Item`s. Panel, overlay, and menu entry points expose
|
||||
`open(payloadJson)` and `close()` for summon/hide; on load the host injects
|
||||
`omarchyPath`, `shell`, `manifest`, and the registries (`pluginRegistry` /
|
||||
`barWidgetRegistry`) as properties.
|
||||
Entry points are QML `Item`s. Panel, overlay, and menu entry points expose `open(payloadJson)` and `close()` for summon/hide; on load the host injects `omarchyPath`, `shell`, `manifest`, and the registries (`pluginRegistry` / `barWidgetRegistry`) as properties. Built-in plugins receive the trusted host objects. Third-party plugins receive capability-scoped facades instead: ordinary plugins may look up and control only their own service and lifecycle, built-in clones retain narrow source-specific configuration and UI compatibility, menu plugins receive an application-library facade, and plugins can read detached scalar bar state. A full-bar plugin additionally receives detached bar configuration and widget-catalog snapshots, narrow proxies for the non-authentication services used by built-in bar widgets, and lifecycle control over configured non-authentication UI plugins. Authentication capabilities are stamped from trusted first-party manifests, authentication services are kept out of the host's public service map and QML object tree, and third-party registry/configuration snapshots can be changed only locally without mutating host state. The facades are API boundaries, not same-process QML sandboxes: a visual widget shares the host bar's scene and can walk its parent hierarchy to ordinary host objects. Sensitive state must not rely on the facade alone for isolation.
|
||||
|
||||
A third-party replacement bar can render registered widget components, but widgets it hosts receive a service-less entry facade. Allowing the bar to manufacture an own-service facade for an arbitrary widget would also let it retrieve that plugin's live service object. Service-backed third-party widgets therefore retain their full integration only under the trusted built-in bar; a replacement bar may still provide their target-scoped lifecycle and settings operations.
|
||||
|
||||
Full schema: [`shell/services/PluginRegistry.qml`](../shell/services/PluginRegistry.qml).
|
||||
|
||||
@@ -79,12 +78,7 @@ one replaces the active bar, and it is therefore never offered under Disable.
|
||||
Bar widgets may set `barWidget.defaultSection` to `left`, `center`, or `right`;
|
||||
widgets that omit it default to `center`.
|
||||
|
||||
Plugins run as **unsandboxed code** inside `omarchy-shell`. Adding warns you
|
||||
before cloning, plugins land disabled so you can review the code before
|
||||
`omarchy plugin enable`, and updates show a diff before touching anything.
|
||||
Commands confirm in a terminal even when given arguments; without one they
|
||||
refuse rather than guess. Add `--yes` to skip every prompt (the path for
|
||||
scripts and agents).
|
||||
Plugins run as **unsandboxed code** inside `omarchy-shell`. Adding warns you before cloning, plugins land disabled so you can review the code before `omarchy plugin enable`, and updates show a diff before touching anything. Commands confirm in a terminal even when given arguments; without one they refuse rather than guess. Add `--yes` to skip every prompt (the path for scripts and agents). The scoped interfaces remove direct access to authentication services and avoid handing generic cross-plugin service factories to replacement bars, but visual plugins can still traverse ordinary objects in their shared QML scene. Plugin code also has the same user-level file and process access as the shell.
|
||||
|
||||
You can still install by hand: drop a plugin into
|
||||
`~/.config/omarchy/plugins/<id>/`, run `omarchy-shell shell rescanPlugins`, then
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# omarchy-sudo-passwordless writes /etc/sudoers.d/99-omarchy-nopasswd-<user> and
|
||||
# arms a transient systemd-run timer to remove it again. Transient units do not
|
||||
# survive a reboot, so remove any remaining grant during early boot. Boot-only
|
||||
# (r!) ensures a later systemd-tmpfiles --remove cannot cut a live grant short.
|
||||
r! /etc/sudoers.d/99-omarchy-nopasswd-*
|
||||
@@ -0,0 +1,35 @@
|
||||
# Omarchy defaults. Put personal overrides in ~/.config/kitty/kitty.conf.
|
||||
|
||||
# Font
|
||||
font_family JetBrainsMono Nerd Font
|
||||
bold_italic_font auto
|
||||
font_size 9.0
|
||||
|
||||
# Window
|
||||
window_padding_width 14
|
||||
hide_window_decorations yes
|
||||
confirm_os_window_close 0
|
||||
|
||||
# Keybindings
|
||||
map ctrl+insert copy_to_clipboard
|
||||
map shift+insert paste_from_clipboard
|
||||
# Send Shift+Enter as CSI-u so TUIs can distinguish it from Enter.
|
||||
map shift+enter send_text all \e[13;2u
|
||||
# Kitty legacy encoding sends Alt+Shift+Enter the same as Alt+Enter; send CSI-u so tmux can match M-S-Enter.
|
||||
map alt+shift+enter send_text all \e[13;4u
|
||||
|
||||
# Allow local cwd lookup, but reject remote control through terminal output.
|
||||
allow_remote_control socket-only
|
||||
listen_on unix:${XDG_RUNTIME_DIR}/omarchy-kitty-{kitty_pid}
|
||||
|
||||
# Aesthetics
|
||||
cursor_shape block
|
||||
cursor_blink_interval 0
|
||||
shell_integration no-cursor
|
||||
enable_audio_bell no
|
||||
|
||||
# Minimal Tab bar styling
|
||||
tab_bar_edge bottom
|
||||
tab_bar_style powerline
|
||||
tab_powerline_style slanted
|
||||
tab_title_template {title}{' :{}:'.format(num_windows) if num_windows > 1 else ''}
|
||||
@@ -7,6 +7,5 @@ run_logged "$OMARCHY_INSTALL/config/ssh-command-path.sh"
|
||||
run_logged "$OMARCHY_INSTALL/config/ssh-keepalive.sh"
|
||||
run_logged "$OMARCHY_INSTALL/config/docker.sh"
|
||||
run_logged "$OMARCHY_INSTALL/config/snapper.sh"
|
||||
run_logged "$OMARCHY_INSTALL/config/locate.sh"
|
||||
run_logged "$OMARCHY_INSTALL/config/enable-services.sh"
|
||||
run_logged "$OMARCHY_INSTALL/config/firewall.sh"
|
||||
@@ -1,32 +0,0 @@
|
||||
UPDATEDB_CONF_PATH="${OMARCHY_UPDATEDB_CONF_PATH:-/etc/updatedb.conf}"
|
||||
|
||||
echo "Configuring locate to skip Btrfs snapshots and index Btrfs subvolumes"
|
||||
|
||||
[[ -f $UPDATEDB_CONF_PATH ]] || exit 0
|
||||
|
||||
# updatedb refuses to run at all on a config that defines a variable twice, so
|
||||
# every setting here is rewritten where it already stands and only appended
|
||||
# when the file has no line for it.
|
||||
|
||||
# Btrfs subvolume mounts (like /home) look like bind mounts, so pruning
|
||||
# bind mounts leaves them out of the index entirely.
|
||||
if grep -qE '^[[:space:]]*PRUNE_BIND_MOUNTS[[:space:]]*=' "$UPDATEDB_CONF_PATH"; then
|
||||
sed -i -E 's|^[[:space:]]*PRUNE_BIND_MOUNTS[[:space:]]*=.*|PRUNE_BIND_MOUNTS = "no"|' "$UPDATEDB_CONF_PATH"
|
||||
else
|
||||
printf '%s\n' 'PRUNE_BIND_MOUNTS = "no"' >>"$UPDATEDB_CONF_PATH"
|
||||
fi
|
||||
|
||||
# Snapper snapshots are nested subvolumes reached by plain directory
|
||||
# traversal, so without this updatedb indexes the system once per snapshot.
|
||||
if grep -qE '^[[:space:]]*PRUNEPATHS[[:space:]]*=' "$UPDATEDB_CONF_PATH"; then
|
||||
# updatedb only accepts quoted values and allows a comment after them. Read
|
||||
# back what the machine already prunes and write the whole setting out again
|
||||
# rather than splicing into a line of unknown shape.
|
||||
pruned=$(sed -nE 's|^[[:space:]]*PRUNEPATHS[[:space:]]*=[[:space:]]*"([^"]*)".*|\1|p' "$UPDATEDB_CONF_PATH" | tail -n 1)
|
||||
|
||||
if [[ " $pruned " != *" /.snapshots "* ]]; then
|
||||
sed -i -E "s|^[[:space:]]*PRUNEPATHS[[:space:]]*=.*|PRUNEPATHS = \"/.snapshots${pruned:+ $pruned}\"|" "$UPDATEDB_CONF_PATH"
|
||||
fi
|
||||
else
|
||||
printf '%s\n' 'PRUNEPATHS = "/.snapshots"' >>"$UPDATEDB_CONF_PATH"
|
||||
fi
|
||||
@@ -1,2 +1,3 @@
|
||||
# Update localdb so locate can find the installed system files immediately.
|
||||
updatedb
|
||||
# Match the scheduled service while installation runs without a system manager.
|
||||
updatedb --prune-bind-mounts=no --add-prunepaths=/.snapshots
|
||||
@@ -4,7 +4,7 @@ The Omarchy desktop runs as a single long-lived Quickshell process called `omarc
|
||||
|
||||
That's not just an implementation detail. It means you can turn pieces of the desktop off, swap them out, or write your own without touching a line of Omarchy's source.
|
||||
|
||||
The first-party plugins ship with Omarchy and live in `$OMARCHY_PATH/shell/plugins/`. Anything you add yourself — your own experiments, or something you found on GitHub — lives in `~/.config/omarchy/plugins/`. Both are discovered the same way at startup; the only difference is where they sit on disk.
|
||||
The first-party plugins ship with Omarchy and live in `$OMARCHY_PATH/shell/plugins/`. Anything you add yourself — your own experiments, or something you found on GitHub — lives in `~/.config/omarchy/plugins/`. Both are discovered the same way at startup, but built-ins receive trusted shell interfaces while third-party plugins receive a limited interface scoped to their own service and lifecycle. Clones of built-ins keep only the source-specific configuration and UI calls needed for the original behavior.
|
||||
|
||||
## Seeing what you have
|
||||
|
||||
@@ -37,7 +37,9 @@ A third-party plugin is just a git repo with a `manifest.json` at its root.
|
||||
omarchy plugin add https://github.com/acme/omarchy-weather.git --enable
|
||||
```
|
||||
|
||||
Before it does anything, it tells you plainly that plugins run as arbitrary, unsandboxed code inside your long-lived shell process, shows you the URL, and asks you to confirm. Take that seriously. A plugin isn't a config file — it's code that runs for as long as your session does, with everything your user account can reach. Only add repos you're willing to run, and read them before you enable them.
|
||||
Before it does anything, it tells you plainly that plugins run as arbitrary, unsandboxed code inside your long-lived shell process, shows you the URL, and asks you to confirm. Take that seriously. The third-party plugin interface does not directly expose authentication services, and a replacement bar receives only limited capabilities for configured non-authentication UI. Visual plugins still share the shell's QML scene and can walk ordinary parent objects, while all plugin code runs with everything your user account can reach. Authentication state is protected separately by keeping those services outside the reachable host object graph. Only add repos you're willing to run, and read them before you enable them.
|
||||
|
||||
A replacement bar can render installed widgets, but service-backed third-party widgets may have reduced functionality there because the bar is not allowed to request another plugin's live service object. Switch back to the built-in `omarchy.bar` if such a widget needs its companion service.
|
||||
|
||||
Then it clones the repo into a staging directory, validates the manifest, refuses the install if another plugin already claims that id, and moves it into `~/.config/omarchy/plugins/<id>/`. Without `--enable` it asks whether you want it on now, and you can say no and go read the code first. It never runs anything from the plugin, never executes an install hook, and never asks for sudo — it clones files, checks the manifest, and flips a bit over IPC.
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ 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 minutes and then puts it back automatically. Run it again before the timer runs out to end it early, and pass your own number of minutes 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_ turns that off for 15 minutes and then puts it back automatically. Run it again before the timer runs out to end it early, and pass your own number of minutes with `omarchy-sudo-passwordless 30` if 15 isn't enough. A restart removes the passwordless sudo rule as well.
|
||||
|
||||
Be clear-eyed about this one: while it's on, anything running as your user can do anything as root without being asked. That's the whole point, and it's also the whole risk.
|
||||
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
echo "Configure locate to skip Btrfs snapshots and index Btrfs subvolumes"
|
||||
|
||||
OMARCHY_PATH="${OMARCHY_PATH:-/usr/share/omarchy}"
|
||||
locate_config_script="$OMARCHY_PATH/install/config/locate.sh"
|
||||
UPDATEDB_CONF_PATH="${OMARCHY_UPDATEDB_CONF_PATH:-/etc/updatedb.conf}"
|
||||
|
||||
as_root() {
|
||||
if (( EUID == 0 )); then
|
||||
"$@"
|
||||
else
|
||||
sudo "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
[[ -f $UPDATEDB_CONF_PATH ]] || exit 0
|
||||
[[ -f $locate_config_script ]] || exit 0
|
||||
|
||||
if grep -q '^PRUNE_BIND_MOUNTS = "no"' "$UPDATEDB_CONF_PATH" &&
|
||||
grep -E '^PRUNEPATHS' "$UPDATEDB_CONF_PATH" | grep -E '(^|[[:space:]"])/\.snapshots([[:space:]"]|$)' >/dev/null; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
as_root env OMARCHY_UPDATEDB_CONF_PATH="$UPDATEDB_CONF_PATH" bash -euo pipefail "$locate_config_script"
|
||||
|
||||
# Rebuild the index with the new exclusions; pruning /.snapshots turns
|
||||
# multi-hour runs on snapshot-heavy systems back into one-minute runs. Restart
|
||||
# rather than start: the machines this targets are the ones with an updatedb
|
||||
# already grinding through every snapshot, and a run that started before the
|
||||
# rewrite keeps using the config it read at startup.
|
||||
as_root systemctl restart --no-block plocate-updatedb.service >/dev/null 2>&1 || true
|
||||
@@ -0,0 +1,303 @@
|
||||
echo "Repair user-owned system-sleep hooks and hybrid GPU service configuration"
|
||||
|
||||
system_sleep_dir=/usr/lib/systemd/system-sleep
|
||||
supergfxd_drop_in=/etc/systemd/system/supergfxd.service.d/delay-start.conf
|
||||
quarantine_root=/var/lib/omarchy/migrations/1788662350-system-sleep
|
||||
reload_needed_marker=/var/lib/omarchy/migrations/1788662350-systemd-reload-needed
|
||||
keyboard_source="$OMARCHY_PATH/default/systemd/system-sleep/keyboard-backlight"
|
||||
force_igpu_source="$OMARCHY_PATH/default/systemd/system-sleep/force-igpu"
|
||||
supergfxd_source="$OMARCHY_PATH/default/systemd/system/supergfxd.service.d/delay-start.conf"
|
||||
legacy_keyboard_sha256=f313a81e47401f0d38b8602e5997f52c5286d5e97f74027564ddd515b3d16511
|
||||
legacy_force_igpu_sha256=d604e7c4903829563e45fc52188fc5602c3f1bc66e247f0a2cc0a974ed6e57db
|
||||
|
||||
as_root() {
|
||||
if (( EUID == 0 )); then
|
||||
"$@"
|
||||
else
|
||||
sudo "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
path_is_root_controlled() {
|
||||
local path="$1"
|
||||
local current=/ component candidate file_mode link metadata part status uid gid mode
|
||||
local missing_depth=0 symlink_count=0
|
||||
local -a pending resolved link_components
|
||||
|
||||
[[ $path == /* ]] || return 1
|
||||
IFS=/ read -r -a pending <<<"$path"
|
||||
# A non-root group is harmless when neither it nor everyone else can write.
|
||||
# Resolve symlinks component by component so an indirect link cannot hide an
|
||||
# intermediate directory controlled by an unprivileged user.
|
||||
metadata=$(path_metadata /) || return 1
|
||||
read -r file_mode uid gid mode <<<"$metadata"
|
||||
(( uid == 0 && (8#$mode & 8#022) == 0 )) || return 1
|
||||
|
||||
while ((${#pending[@]})); do
|
||||
component=${pending[0]}
|
||||
pending=("${pending[@]:1}")
|
||||
[[ -n $component ]] || continue
|
||||
[[ $component == "." ]] && continue
|
||||
|
||||
if [[ $component == ".." ]]; then
|
||||
if ((${#resolved[@]})); then
|
||||
unset 'resolved[-1]'
|
||||
fi
|
||||
|
||||
current=/
|
||||
for part in "${resolved[@]}"; do
|
||||
if [[ $current == "/" ]]; then
|
||||
current="/$part"
|
||||
else
|
||||
current="$current/$part"
|
||||
fi
|
||||
done
|
||||
if (( missing_depth > 0 && ${#resolved[@]} < missing_depth )); then
|
||||
missing_depth=0
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ $current == "/" ]]; then
|
||||
candidate="/$component"
|
||||
else
|
||||
candidate="$current/$component"
|
||||
fi
|
||||
|
||||
if (( missing_depth > 0 )); then
|
||||
# The first missing component makes descendants inactive today, but keep
|
||||
# consuming the lexical suffix. A later .. can escape back into an
|
||||
# existing user-controlled path that would become active if an
|
||||
# administrator creates the missing directory.
|
||||
resolved+=("$component")
|
||||
current=$candidate
|
||||
continue
|
||||
elif metadata=$(path_metadata "$candidate"); then
|
||||
read -r file_mode uid gid mode <<<"$metadata"
|
||||
else
|
||||
status=$?
|
||||
if (( status == 2 )); then
|
||||
resolved+=("$component")
|
||||
current=$candidate
|
||||
missing_depth=${#resolved[@]}
|
||||
continue
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
if (( (16#$file_mode & 16#f000) == 16#a000 )); then
|
||||
((++symlink_count <= 40)) || return 1
|
||||
link=$(readlink_with_privilege "$candidate") || return 1
|
||||
IFS=/ read -r -a link_components <<<"$link"
|
||||
pending=("${link_components[@]}" "${pending[@]}")
|
||||
if [[ $link == /* ]]; then
|
||||
resolved=()
|
||||
current=/
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
|
||||
(( uid == 0 && (8#$mode & 8#022) == 0 )) || return 1
|
||||
resolved+=("$component")
|
||||
current=$candidate
|
||||
done
|
||||
}
|
||||
|
||||
path_metadata() {
|
||||
local path="$1"
|
||||
local metadata parent
|
||||
|
||||
if /usr/bin/stat -c '%f %u %g %a' -- "$path" 2>/dev/null; then
|
||||
return 0
|
||||
elif [[ ! -e $path && ! -L $path ]]; then
|
||||
parent=${path%/*}
|
||||
[[ -n $parent ]] || parent=/
|
||||
# Avoid asking for sudo for ordinary ENOENT. If the parent is searchable,
|
||||
# the absence is conclusive; an inaccessible root-only chain still needs a
|
||||
# privileged metadata check so safe administrator symlinks are preserved.
|
||||
[[ -x $parent ]] && return 2
|
||||
if metadata=$(as_root /usr/bin/stat -c '%f %u %g %a' -- "$path" 2>/dev/null); then
|
||||
printf '%s\n' "$metadata"
|
||||
return 0
|
||||
elif as_root /usr/bin/test -x "$parent"; then
|
||||
# The privileged probe could search the protected parent, so stat's
|
||||
# failure identifies a target that does not exist yet.
|
||||
return 2
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
as_root /usr/bin/stat -c '%f %u %g %a' -- "$path"
|
||||
fi
|
||||
}
|
||||
|
||||
readlink_with_privilege() {
|
||||
local path="$1"
|
||||
|
||||
if /usr/bin/readlink -- "$path" 2>/dev/null; then
|
||||
return 0
|
||||
else
|
||||
as_root /usr/bin/readlink -- "$path"
|
||||
fi
|
||||
}
|
||||
|
||||
privileged_entry_is_safe() {
|
||||
local path="$1"
|
||||
|
||||
path_is_root_controlled "$path"
|
||||
}
|
||||
|
||||
file_matches_source() {
|
||||
local source="$1"
|
||||
local destination="$2"
|
||||
|
||||
[[ -f $destination && ! -L $destination ]] || return 1
|
||||
|
||||
if [[ -r $destination ]]; then
|
||||
/usr/bin/cmp -s -- "$source" "$destination"
|
||||
else
|
||||
as_root /usr/bin/cmp -s -- "$source" "$destination"
|
||||
fi
|
||||
}
|
||||
|
||||
file_matches_sha256() {
|
||||
local destination="$1"
|
||||
local expected="$2"
|
||||
local digest
|
||||
|
||||
[[ -f $destination && ! -L $destination ]] || return 1
|
||||
if [[ -r $destination ]]; then
|
||||
digest=$(/usr/bin/sha256sum -- "$destination") || return 1
|
||||
else
|
||||
digest=$(as_root /usr/bin/sha256sum -- "$destination") || return 1
|
||||
fi
|
||||
[[ ${digest%% *} == "$expected" ]]
|
||||
}
|
||||
|
||||
safe_stage_path() {
|
||||
local stage="$1"
|
||||
local destination="$2"
|
||||
local prefix suffix
|
||||
|
||||
prefix="${destination%/*}/.${destination##*/}.omarchy."
|
||||
[[ $stage == "$prefix"* ]] || return 1
|
||||
suffix=${stage#"$prefix"}
|
||||
[[ $suffix =~ ^[[:alnum:]]{6}$ ]]
|
||||
}
|
||||
|
||||
install_root_file() {
|
||||
local source="$1"
|
||||
local destination="$2"
|
||||
local mode="$3"
|
||||
local stage
|
||||
|
||||
stage=$(as_root /usr/bin/mktemp -- "${destination%/*}/.${destination##*/}.omarchy.XXXXXX") || return 1
|
||||
safe_stage_path "$stage" "$destination" || return 1
|
||||
|
||||
if as_root /usr/bin/install -m "$mode" -o root -g root -T "$source" "$stage" &&
|
||||
as_root /usr/bin/mv -Tf -- "$stage" "$destination"; then
|
||||
return 0
|
||||
else
|
||||
safe_stage_path "$stage" "$destination" && as_root /usr/bin/rm -f -- "$stage"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
preserve_unsafe_customization() {
|
||||
local path="$1"
|
||||
local label="$2"
|
||||
local backup_dir backup
|
||||
|
||||
if ! as_root /usr/bin/install -d -m 0700 -o root -g root "$quarantine_root"; then
|
||||
echo "Could not create the root-only system-sleep quarantine at $quarantine_root" >&2
|
||||
return 1
|
||||
fi
|
||||
if ! backup_dir=$(as_root /usr/bin/mktemp -d -- "$quarantine_root/${label}.XXXXXX"); then
|
||||
echo "Could not reserve a quarantine path for $path" >&2
|
||||
return 1
|
||||
fi
|
||||
backup="$backup_dir/original"
|
||||
|
||||
if as_root /usr/bin/cp -a --no-dereference -T -- "$path" "$backup"; then
|
||||
printf '%s\n' "$backup"
|
||||
else
|
||||
as_root /usr/bin/rm -rf -- "$backup_dir"
|
||||
echo "Could not preserve unsafe custom content from $path before repairing it" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
repair_unsafe_privileged_entry() {
|
||||
local source="$1"
|
||||
local destination="$2"
|
||||
local mode="$3"
|
||||
local label="$4"
|
||||
local legacy_sha256="${5:-}"
|
||||
local backup current_mode
|
||||
|
||||
[[ -e $destination || -L $destination ]] || return 0
|
||||
[[ -f $destination || -L $destination ]] || return 0
|
||||
|
||||
if file_matches_source "$source" "$destination"; then
|
||||
current_mode=$(/usr/bin/stat -c '%a' -- "$destination" 2>/dev/null) ||
|
||||
current_mode=$(as_root /usr/bin/stat -c '%a' -- "$destination") || return 1
|
||||
if privileged_entry_is_safe "$destination" && [[ $current_mode == "${mode#0}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
elif [[ -n $legacy_sha256 ]] && file_matches_sha256 "$destination" "$legacy_sha256"; then
|
||||
:
|
||||
else
|
||||
privileged_entry_is_safe "$destination" && return 0
|
||||
backup=$(preserve_unsafe_customization "$destination" "$label") || return 1
|
||||
fi
|
||||
|
||||
if install_root_file "$source" "$destination" "$mode"; then
|
||||
if [[ -n ${backup:-} ]]; then
|
||||
echo "Preserved unsafe custom content from $destination at $backup for administrator review" >&2
|
||||
fi
|
||||
else
|
||||
if [[ -n ${backup:-} ]]; then
|
||||
echo "Preserved unsafe custom content from $destination at $backup, but could not repair the active path" >&2
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Replace rather than chown an unsafe destination: its current owner may have
|
||||
# already changed the contents or kept a writable file descriptor open. The
|
||||
# root-owned staging inode makes the final rename an atomic trust transition.
|
||||
repair_unsafe_privileged_entry "$keyboard_source" \
|
||||
"$system_sleep_dir/keyboard-backlight" 0755 keyboard-backlight "$legacy_keyboard_sha256"
|
||||
|
||||
force_igpu="$system_sleep_dir/force-igpu"
|
||||
repair_unsafe_privileged_entry "$force_igpu_source" "$force_igpu" 0755 force-igpu "$legacy_force_igpu_sha256"
|
||||
|
||||
systemd_reload_needed=false
|
||||
if [[ -e $reload_needed_marker || -L $reload_needed_marker ]]; then
|
||||
systemd_reload_needed=true
|
||||
fi
|
||||
|
||||
if [[ -e $supergfxd_drop_in || -L $supergfxd_drop_in ]]; then
|
||||
if ! privileged_entry_is_safe "$supergfxd_drop_in"; then
|
||||
# Replacing the drop-in and reloading systemd are one repair. Record the
|
||||
# second half before changing the file so failure or interruption cannot
|
||||
# be forgotten when a retry sees only the trusted replacement on disk.
|
||||
if ! as_root /usr/bin/install -Dm0644 -o root -g root /dev/null "$reload_needed_marker"; then
|
||||
echo "Could not persist the pending systemd reload for the repaired supergfxd configuration" >&2
|
||||
exit 1
|
||||
fi
|
||||
systemd_reload_needed=true
|
||||
repair_unsafe_privileged_entry "$supergfxd_source" "$supergfxd_drop_in" 0644 delay-start.conf
|
||||
fi
|
||||
fi
|
||||
|
||||
if $systemd_reload_needed; then
|
||||
if ! as_root /usr/bin/systemctl daemon-reload; then
|
||||
echo "Could not reload systemd after repairing the supergfxd configuration; the migration will retry" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! as_root /usr/bin/rm -f -- "$reload_needed_marker"; then
|
||||
echo "Could not clear the pending systemd reload marker; the migration will retry" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
@@ -0,0 +1,33 @@
|
||||
echo "Update Kitty configuration"
|
||||
|
||||
kitty_config="$HOME/.config/kitty/kitty.conf"
|
||||
# config/kitty/kitty.conf as shipped after 008f3a22 (Kitty cwd lookup).
|
||||
stock_sha="856cd466bf568d091cb775c5b90d1852178090a419f492fde02a4eaef6407bf9"
|
||||
unrestricted='^[[:space:]]*allow_remote_control[[:space:]]+(yes|y|true)[[:space:]]*$'
|
||||
|
||||
if [[ -f $kitty_config ]]; then
|
||||
changed=false
|
||||
|
||||
if [[ $(sha256sum "$kitty_config" | cut -d ' ' -f 1) == $stock_sha ]]; then
|
||||
omarchy-refresh-config kitty/kitty.conf
|
||||
changed=true
|
||||
elif grep -qE "$unrestricted" "$kitty_config"; then
|
||||
# Preserve customizations and ordering. An otherwise stock line can be an
|
||||
# intentional override of an earlier include or mapping.
|
||||
backup=$(mktemp "$kitty_config.bak.XXXXXX")
|
||||
cp -p "$kitty_config" "$backup"
|
||||
sed --follow-symlinks -i -E "s/$unrestricted/# &/" "$kitty_config"
|
||||
printf '\n%s\n' \
|
||||
"Unrestricted remote control disabled." \
|
||||
"Your other Kitty settings were preserved."
|
||||
printf '\nBackup saved to:\n %s\n' "$backup"
|
||||
changed=true
|
||||
fi
|
||||
|
||||
if [[ $changed == "true" ]]; then
|
||||
# Kitty reads allow_remote_control at startup; config reload is insufficient.
|
||||
gum style --border rounded --border-foreground 3 --padding "1 2" --margin "1 0" \
|
||||
"Restart Kitty" "" \
|
||||
"Close and reopen all Kitty windows to apply this change."
|
||||
fi
|
||||
fi
|
||||
+5
-4
@@ -93,6 +93,10 @@ holds the session lock. The kept instance is not replaced, so code
|
||||
changes to a `keepLoaded` service itself only take effect on a shell
|
||||
restart. First-party services are loaded at startup.
|
||||
|
||||
Entry points may declare `omarchyPath`, `shell`, `manifest`, `pluginRegistry`, and `barWidgetRegistry` properties for host injection. Built-in plugins receive the trusted host objects. Third-party plugins receive capability-scoped facades: ordinary plugins can look up and control only their own service and lifecycle, built-in clones retain narrow source-specific configuration and UI compatibility, menu plugins receive an application-library facade, and plugins can read detached scalar bar state. A full-bar plugin additionally receives detached bar configuration and widget-catalog snapshots, narrow proxies for the non-authentication services used by built-in bar widgets, and lifecycle control over configured non-authentication UI plugins. Authentication capabilities are stamped from trusted first-party manifests, authentication services are retained outside the host's public service map and QML object tree, and changing a third-party registry or configuration snapshot cannot mutate host state. Facades do not isolate visual widgets from the parent hierarchy of the shared QML scene, so sensitive state must remain outside that reachable graph.
|
||||
|
||||
Widgets rendered by a third-party replacement bar receive a service-less entry facade with target-scoped lifecycle and settings operations. Their live service objects are available only when the trusted built-in bar hosts them; otherwise the replacement bar could request and retain any configured widget's service.
|
||||
|
||||
The full schema lives in `services/PluginRegistry.qml`.
|
||||
|
||||
## Installing a third-party plugin
|
||||
@@ -108,10 +112,7 @@ omarchy plugin update # updates every git-managed plugin
|
||||
omarchy plugin remove acme.weather
|
||||
```
|
||||
|
||||
> ⚠️ **Plugins run as unsandboxed code inside `omarchy-shell`.** Adding warns
|
||||
> you before cloning, plugins land disabled so you can review the code before
|
||||
> enabling, and updates show a diff of the changes before touching anything.
|
||||
> Only add repos whose code you are willing to run.
|
||||
> ⚠️ **Plugins run as unsandboxed code inside `omarchy-shell`.** Adding warns you before cloning, plugins land disabled so you can review the code before enabling, and updates show a diff of the changes before touching anything. The scoped QML interfaces remove direct authentication-service and generic replacement-bar service lookups, but visual plugins still share and can traverse the ordinary host scene. Only add repos whose code you are willing to run.
|
||||
|
||||
Each command is **interactive** when run bare in a terminal (gum pickers,
|
||||
confirmation, a diff to review) and fully **non-interactive** when given
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import QtQuick
|
||||
|
||||
// Bar surface exposed to an installed third-party widget. Scalar presentation
|
||||
// state is mirrored by Bar.qml and operations are delegated through scoped
|
||||
// callbacks. The facade avoids direct host-Bar injection; it cannot isolate a
|
||||
// visual child from the parent hierarchy of the QML scene that renders it.
|
||||
QtObject {
|
||||
id: api
|
||||
|
||||
required property string pluginId
|
||||
required property string moduleName
|
||||
property var shell: null
|
||||
|
||||
property color foreground: "transparent"
|
||||
property color barForeground: "transparent"
|
||||
property color background: "transparent"
|
||||
property color urgent: "transparent"
|
||||
property string fontFamily: ""
|
||||
property string position: "top"
|
||||
property bool vertical: false
|
||||
property int barSize: 0
|
||||
property bool transparent: false
|
||||
property bool foregroundAnimationEnabled: true
|
||||
property bool centerSectionRevealHeld: false
|
||||
property bool _centerHoverRevealSuppressed: false
|
||||
readonly property bool centerHoverRevealSuppressed: _centerHoverRevealSuppressed
|
||||
property var activePopout: null
|
||||
property var clickTargets: []
|
||||
property var layoutConfig: ({})
|
||||
readonly property var foreignPopoutMarker: ({ foreign: true })
|
||||
|
||||
property var _showTooltip: null
|
||||
property var _hideTooltip: null
|
||||
property var _registerClickTarget: null
|
||||
property var _unregisterClickTarget: null
|
||||
property var _requestPopout: null
|
||||
property var _releasePopout: null
|
||||
property var _switchPanelFrom: null
|
||||
property var _targetBelongsToWindow: null
|
||||
property var _moduleWidgets: null
|
||||
property var _run: null
|
||||
property var _setCenterHoverRevealSuppressed: null
|
||||
|
||||
function setCenterHoverRevealSuppressed(value) {
|
||||
if (_setCenterHoverRevealSuppressed) _setCenterHoverRevealSuppressed(!!value)
|
||||
}
|
||||
|
||||
function showTooltip(target, text) {
|
||||
if (_showTooltip) _showTooltip(target, String(text || ""))
|
||||
}
|
||||
|
||||
function hideTooltip(target) {
|
||||
if (_hideTooltip) _hideTooltip(target)
|
||||
}
|
||||
|
||||
function registerClickTarget(target) {
|
||||
if (_registerClickTarget) _registerClickTarget(target)
|
||||
}
|
||||
|
||||
function unregisterClickTarget(target) {
|
||||
if (_unregisterClickTarget) _unregisterClickTarget(target)
|
||||
}
|
||||
|
||||
function requestPopout(owner) {
|
||||
if (_requestPopout) _requestPopout(owner)
|
||||
}
|
||||
|
||||
function releasePopout(owner) {
|
||||
if (_releasePopout) _releasePopout(owner)
|
||||
}
|
||||
|
||||
function switchPanelFrom(owner, direction) {
|
||||
return _switchPanelFrom ? _switchPanelFrom(owner, direction) : false
|
||||
}
|
||||
|
||||
function targetBelongsToWindow(target, window) {
|
||||
return _targetBelongsToWindow ? _targetBelongsToWindow(target, window) : false
|
||||
}
|
||||
|
||||
function moduleWidgets(id) {
|
||||
return _moduleWidgets ? _moduleWidgets(String(id || "")) : []
|
||||
}
|
||||
|
||||
function run(command) {
|
||||
if (_run) _run(String(command || ""))
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ PanelSectionHeader 1.0 PanelSectionHeader.qml
|
||||
PanelSeparator 1.0 PanelSeparator.qml
|
||||
PanelSlider 1.0 PanelSlider.qml
|
||||
PanelToolTip 1.0 PanelToolTip.qml
|
||||
PluginBarApi 1.0 PluginBarApi.qml
|
||||
PointerMoveGate 1.0 PointerMoveGate.qml
|
||||
ScreenMoveRemap 1.0 ScreenMoveRemap.qml
|
||||
PopupCard 1.0 PopupCard.qml
|
||||
|
||||
+238
-4
@@ -12,20 +12,29 @@ Item {
|
||||
id: root
|
||||
|
||||
// The omarchy-shell host injects omarchyPath from OMARCHY_PATH.
|
||||
required property string omarchyPath
|
||||
property string omarchyPath: Quickshell.env("OMARCHY_PATH")
|
||||
// Injected by the host shell so bar slots can resolve enabled widgets.
|
||||
required property var barWidgetRegistry
|
||||
property var barWidgetRegistry: fallbackBarWidgetRegistry
|
||||
// Read-only registry view for third-party full bars; the built-in bar does
|
||||
// not otherwise need it, but declaring it keeps clone construction atomic.
|
||||
property var pluginRegistry: null
|
||||
// Injected by the host shell every time shell.json is reloaded. Holds the
|
||||
// `bar:` subtree: position, centerAnchor, layout. The host owns file IO;
|
||||
// the bar just renders whatever it's handed. The bar font follows the
|
||||
// OS-level fontconfig monospace binding — it is not stored in shell.json.
|
||||
required property var barConfig
|
||||
property var barConfig: ({})
|
||||
// Injected by the host shell. Used for shell-wide actions such as opening
|
||||
// settings and persisting inline widget state.
|
||||
property var shell: null
|
||||
// Manifest for the active bar option. Present for custom bars and useful for
|
||||
// diagnostics; the built-in bar does not otherwise need it.
|
||||
property var manifest: null
|
||||
QtObject {
|
||||
id: fallbackBarWidgetRegistry
|
||||
property var widgets: ({})
|
||||
property int revision: 0
|
||||
function metadataFor(id) { return null }
|
||||
}
|
||||
// Mirrors the on-disk `bar-off` flag so the user can hide the bar without
|
||||
// killing the entire shell. Hidden panels stay mapped but park off-screen
|
||||
// without an exclusion zone; updated by the FileView watcher further down.
|
||||
@@ -100,6 +109,223 @@ Item {
|
||||
property var barMoveScreen: null
|
||||
property var clickTargets: []
|
||||
property var moduleSlots: []
|
||||
property var pluginBarApis: ({})
|
||||
property var pluginObjectOwners: []
|
||||
|
||||
Component {
|
||||
id: pluginBarApiComponent
|
||||
PluginBarApi { }
|
||||
}
|
||||
|
||||
function publicLayoutConfig() {
|
||||
return JSON.parse(JSON.stringify(root.layoutConfig || {}))
|
||||
}
|
||||
|
||||
function bindPluginBarApi(api) {
|
||||
if (!api) return
|
||||
api.foreground = Qt.binding(function() { return root.foreground })
|
||||
api.barForeground = Qt.binding(function() { return root.barForeground })
|
||||
api.background = Qt.binding(function() { return root.background })
|
||||
api.urgent = Qt.binding(function() { return root.urgent })
|
||||
api.fontFamily = Qt.binding(function() { return root.fontFamily })
|
||||
api.position = Qt.binding(function() { return root.position })
|
||||
api.vertical = Qt.binding(function() { return root.vertical })
|
||||
api.barSize = Qt.binding(function() { return root.barSize })
|
||||
api.transparent = Qt.binding(function() { return root.transparent })
|
||||
api.foregroundAnimationEnabled = Qt.binding(function() { return root.foregroundAnimationEnabled })
|
||||
api.centerSectionRevealHeld = Qt.binding(function() { return root.centerSectionRevealHeld })
|
||||
api._centerHoverRevealSuppressed = Qt.binding(function() { return root.centerHoverRevealSuppressed })
|
||||
root.syncPluginBarApiObjects(api)
|
||||
}
|
||||
|
||||
function syncPluginBarApiObjects(api) {
|
||||
if (!api) return
|
||||
api.activePopout = root.pluginOwnsBarObject(api.pluginId, root.activePopout)
|
||||
? root.activePopout : (root.activePopout ? api.foreignPopoutMarker : null)
|
||||
api.clickTargets = root.pluginClickTargets(api.pluginId)
|
||||
api.layoutConfig = root.publicLayoutConfig()
|
||||
}
|
||||
|
||||
function pluginObjectRecord(target) {
|
||||
for (var i = 0; i < pluginObjectOwners.length; i++) {
|
||||
var record = pluginObjectOwners[i]
|
||||
if (record && record.target === target) return record
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function markPluginObject(pluginId, target, role) {
|
||||
var key = String(pluginId || "")
|
||||
if (!key || !target) return false
|
||||
var record = root.pluginObjectRecord(target)
|
||||
if (record && record.pluginId !== key) return false
|
||||
var next = []
|
||||
for (var i = 0; i < pluginObjectOwners.length; i++) {
|
||||
var existing = pluginObjectOwners[i]
|
||||
if (!existing || existing.target !== target) next.push(existing)
|
||||
}
|
||||
var updated = record || { target: target, pluginId: key, clickTarget: false, popout: false }
|
||||
updated[role] = true
|
||||
next.push(updated)
|
||||
pluginObjectOwners = next
|
||||
return true
|
||||
}
|
||||
|
||||
function unmarkPluginObject(pluginId, target, role) {
|
||||
var key = String(pluginId || "")
|
||||
var next = []
|
||||
for (var i = 0; i < pluginObjectOwners.length; i++) {
|
||||
var record = pluginObjectOwners[i]
|
||||
if (!record || record.target !== target || record.pluginId !== key) {
|
||||
next.push(record)
|
||||
continue
|
||||
}
|
||||
record[role] = false
|
||||
if (record.clickTarget || record.popout) next.push(record)
|
||||
}
|
||||
pluginObjectOwners = next
|
||||
}
|
||||
|
||||
function pluginOwnsBarObject(pluginId, target) {
|
||||
var record = target ? root.pluginObjectRecord(target) : null
|
||||
return !!record && record.pluginId === String(pluginId || "")
|
||||
}
|
||||
|
||||
function pluginClickTargets(pluginId) {
|
||||
var out = []
|
||||
for (var i = 0; i < root.clickTargets.length; i++) {
|
||||
var target = root.clickTargets[i]
|
||||
if (root.pluginOwnsBarObject(pluginId, target)) out.push(target)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function syncAllPluginBarApiObjects() {
|
||||
for (var id in pluginBarApis) root.syncPluginBarApiObjects(pluginBarApis[id])
|
||||
}
|
||||
|
||||
function registerPluginClickTarget(pluginId, target) {
|
||||
if (!root.markPluginObject(pluginId, target, "clickTarget")) return
|
||||
root.registerClickTarget(target)
|
||||
}
|
||||
|
||||
function unregisterPluginClickTarget(pluginId, target) {
|
||||
if (!root.pluginOwnsBarObject(pluginId, target)) return
|
||||
root.unregisterClickTarget(target)
|
||||
root.unmarkPluginObject(pluginId, target, "clickTarget")
|
||||
}
|
||||
|
||||
function requestPluginPopout(pluginId, owner) {
|
||||
if (!root.markPluginObject(pluginId, owner, "popout")) return
|
||||
root.requestPopout(owner)
|
||||
}
|
||||
|
||||
function releasePluginPopout(pluginId, owner) {
|
||||
if (!root.pluginOwnsBarObject(pluginId, owner)) return
|
||||
root.releasePopout(owner)
|
||||
root.unmarkPluginObject(pluginId, owner, "popout")
|
||||
}
|
||||
|
||||
function pluginBarApiFor(pluginId, moduleName, registered) {
|
||||
var key = String(pluginId || "")
|
||||
if (!key) return null
|
||||
|
||||
var pluginShell = null
|
||||
if (registered && root.shell && typeof root.shell.pluginShellForId === "function") {
|
||||
// Only the trusted built-in bar receives ShellRoot and can request a
|
||||
// service-capable facade for the widget it is instantiating.
|
||||
pluginShell = root.shell.pluginShellForId(moduleName)
|
||||
} else if (root.shell && typeof root.shell.pluginShellForBarEntry === "function") {
|
||||
// Replacement bars receive a service-less entry facade. Giving an
|
||||
// untrusted bar a generic facade factory would let it retrieve another
|
||||
// third-party plugin's live service object.
|
||||
pluginShell = root.shell.pluginShellForBarEntry(key, moduleName)
|
||||
}
|
||||
|
||||
if (pluginBarApis[key]) {
|
||||
pluginBarApis[key].shell = pluginShell
|
||||
return pluginBarApis[key]
|
||||
}
|
||||
|
||||
var api = pluginBarApiComponent.createObject(null, {
|
||||
pluginId: key,
|
||||
moduleName: String(moduleName || ""),
|
||||
shell: pluginShell,
|
||||
_showTooltip: function(target, text) { root.showTooltip(target, text) },
|
||||
_hideTooltip: function(target) { root.hideTooltip(target) },
|
||||
_registerClickTarget: function(target) { root.registerPluginClickTarget(key, target) },
|
||||
_unregisterClickTarget: function(target) { root.unregisterPluginClickTarget(key, target) },
|
||||
_requestPopout: function(owner) { root.requestPluginPopout(key, owner) },
|
||||
_releasePopout: function(owner) { root.releasePluginPopout(key, owner) },
|
||||
_switchPanelFrom: function(owner, direction) { return root.switchPanelFrom(owner, direction) },
|
||||
_targetBelongsToWindow: function(target, window) { return root.targetBelongsToWindow(target, window) },
|
||||
_moduleWidgets: function(requestedId) {
|
||||
return String(requestedId || "") === String(moduleName || "")
|
||||
? root.moduleWidgets(moduleName) : []
|
||||
},
|
||||
_run: function(command) { root.run(command) },
|
||||
_setCenterHoverRevealSuppressed: function(value) {
|
||||
root.centerHoverRevealSuppressed = !!value
|
||||
}
|
||||
})
|
||||
if (!api) return null
|
||||
root.bindPluginBarApi(api)
|
||||
|
||||
var next = ({})
|
||||
for (var id in pluginBarApis) next[id] = pluginBarApis[id]
|
||||
next[key] = api
|
||||
pluginBarApis = next
|
||||
return api
|
||||
}
|
||||
|
||||
function pluginBarApiUsed(pluginId) {
|
||||
for (var i = 0; i < moduleSlots.length; i++) {
|
||||
var slot = moduleSlots[i]
|
||||
if (slot && slot.pluginApiId === pluginId) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function releasePluginObjects(pluginId) {
|
||||
var owned = pluginObjectOwners.slice()
|
||||
for (var i = 0; i < owned.length; i++) {
|
||||
var record = owned[i]
|
||||
if (!record || record.pluginId !== pluginId) continue
|
||||
if (record.clickTarget) root.unregisterClickTarget(record.target)
|
||||
if (record.popout && root.activePopout === record.target) root.releasePopout(record.target)
|
||||
}
|
||||
pluginObjectOwners = pluginObjectOwners.filter(function(record) {
|
||||
return record && record.pluginId !== pluginId
|
||||
})
|
||||
}
|
||||
|
||||
function prunePluginBarApis() {
|
||||
var next = ({})
|
||||
for (var id in pluginBarApis) {
|
||||
var api = pluginBarApis[id]
|
||||
if (root.pluginBarApiUsed(id)) {
|
||||
next[id] = api
|
||||
continue
|
||||
}
|
||||
root.releasePluginObjects(id)
|
||||
if (api && typeof api.destroy === "function") api.destroy()
|
||||
}
|
||||
pluginBarApis = next
|
||||
}
|
||||
|
||||
onActivePopoutChanged: syncAllPluginBarApiObjects()
|
||||
onClickTargetsChanged: syncAllPluginBarApiObjects()
|
||||
onLayoutConfigChanged: syncAllPluginBarApiObjects()
|
||||
onModuleSlotsChanged: Qt.callLater(prunePluginBarApis)
|
||||
|
||||
Component.onDestruction: {
|
||||
for (var id in pluginBarApis) {
|
||||
root.releasePluginObjects(id)
|
||||
if (pluginBarApis[id] && typeof pluginBarApis[id].destroy === "function")
|
||||
pluginBarApis[id].destroy()
|
||||
}
|
||||
pluginBarApis = ({})
|
||||
}
|
||||
|
||||
function registerClickTarget(target) {
|
||||
if (!target || clickTargets.indexOf(target) !== -1) return
|
||||
@@ -599,6 +825,10 @@ Item {
|
||||
if (barHoverCount === 0) centerSectionRevealTimer.restart()
|
||||
}
|
||||
|
||||
function setCenterHoverRevealSuppressed(value) {
|
||||
centerHoverRevealSuppressed = !!value
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: centerSectionRevealTimer
|
||||
interval: 120
|
||||
@@ -1548,6 +1778,9 @@ Item {
|
||||
readonly property string moduleName: root.entryId(entry)
|
||||
readonly property var moduleSettings: root.entrySettings(entry)
|
||||
readonly property string customType: root.customModuleType(entry)
|
||||
readonly property var registryMetadata: root.barWidgetRegistry.metadataFor(root.canonicalWidgetId(moduleName))
|
||||
readonly property bool firstParty: registryMetadata && registryMetadata.firstParty === true
|
||||
readonly property string pluginApiId: registered ? root.canonicalWidgetId(moduleName) : "bar-entry:" + moduleName
|
||||
// Re-evaluate when the registry mutates (Component reference changes,
|
||||
// plugin enabled/disabled, etc.). Reading the `widgets` property creates
|
||||
// the binding dependency — the wrapped function call alone wouldn't.
|
||||
@@ -1766,7 +1999,8 @@ Item {
|
||||
function injectProps() {
|
||||
var target = activeItem
|
||||
if (!target) return
|
||||
if ("bar" in target) target.bar = root
|
||||
if ("bar" in target) target.bar = firstParty
|
||||
? root : root.pluginBarApiFor(pluginApiId, moduleName, registered)
|
||||
if ("moduleName" in target) target.moduleName = moduleName
|
||||
if ("settings" in target) target.settings = moduleSettings
|
||||
}
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
"version": "1.0.0",
|
||||
"author": "Omarchy",
|
||||
"description": "Quickshell session lock with separate password and fingerprint PAM flows.",
|
||||
"omarchy": {
|
||||
"capabilities": [
|
||||
"authentication"
|
||||
]
|
||||
},
|
||||
"kinds": [
|
||||
"service"
|
||||
],
|
||||
|
||||
@@ -119,7 +119,9 @@ Panel {
|
||||
// Summoning by hotkey moves no pointer, so a hover the bar was still
|
||||
// holding must not keep the center indicators revealed behind the panel.
|
||||
function setCenterHoverRevealSuppressed(value) {
|
||||
if (root.bar && "centerHoverRevealSuppressed" in root.bar)
|
||||
if (root.bar && typeof root.bar.setCenterHoverRevealSuppressed === "function")
|
||||
root.bar.setCenterHoverRevealSuppressed(value)
|
||||
else if (root.bar && "centerHoverRevealSuppressed" in root.bar)
|
||||
root.bar.centerHoverRevealSuppressed = value
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,9 @@ Panel {
|
||||
}
|
||||
|
||||
function setCenterHoverRevealSuppressed(value) {
|
||||
if (root.bar && "centerHoverRevealSuppressed" in root.bar)
|
||||
if (root.bar && typeof root.bar.setCenterHoverRevealSuppressed === "function")
|
||||
root.bar.setCenterHoverRevealSuppressed(value)
|
||||
else if (root.bar && "centerHoverRevealSuppressed" in root.bar)
|
||||
root.bar.centerHoverRevealSuppressed = value
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
"version": "1.0.0",
|
||||
"author": "Omarchy",
|
||||
"description": "Theme-aware authentication dialog for privileged actions.",
|
||||
"omarchy": {
|
||||
"capabilities": [
|
||||
"authentication"
|
||||
]
|
||||
},
|
||||
"kinds": [
|
||||
"service"
|
||||
],
|
||||
|
||||
@@ -16,7 +16,8 @@ Item {
|
||||
readonly property string stayAwakeStatePath: stayAwakeStateDir + "/stay-awake"
|
||||
readonly property int defaultScreensaverSeconds: 150
|
||||
readonly property int defaultLockSeconds: 300
|
||||
readonly property var idleConfig: shell && shell.shellConfig && shell.shellConfig.idle ? shell.shellConfig.idle : ({})
|
||||
readonly property var idleConfig: shell && shell.shellConfig && shell.shellConfig.idle
|
||||
? shell.shellConfig.idle : (shell && shell.idleConfig ? shell.idleConfig : ({}))
|
||||
readonly property int screensaverTimeoutSeconds: secondsFromConfig(idleConfig.screensaver, defaultScreensaverSeconds)
|
||||
readonly property int lockTimeoutSeconds: secondsFromConfig(idleConfig.lock, defaultLockSeconds)
|
||||
readonly property int firstIdleTimeoutSeconds: Math.min(screensaverTimeoutSeconds, lockTimeoutSeconds)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// Intentionally not `.pragma library`: QML JavaScript imports get a private
|
||||
// module instance per importing component. shell.qml's instance retains the
|
||||
// authentication services; a third-party plugin importing this file receives
|
||||
// a separate empty store rather than a shared path to credential-bearing QML.
|
||||
|
||||
var services = ({})
|
||||
var trustedIds = ({})
|
||||
|
||||
function has(id) {
|
||||
return services[String(id || "")] !== undefined
|
||||
}
|
||||
|
||||
function put(id, service) {
|
||||
var key = String(id || "")
|
||||
if (!key || !service) return
|
||||
trustedIds[key] = true
|
||||
if (services[key] && services[key] !== service && typeof services[key].destroy === "function")
|
||||
services[key].destroy()
|
||||
services[key] = service
|
||||
}
|
||||
|
||||
function isTrusted(id) {
|
||||
return trustedIds[String(id || "")] === true
|
||||
}
|
||||
|
||||
function ids() {
|
||||
return Object.keys(services)
|
||||
}
|
||||
|
||||
function updateManifest(id, manifest) {
|
||||
var service = services[String(id || "")]
|
||||
if (service && "manifest" in service) service.manifest = manifest
|
||||
}
|
||||
|
||||
function destroy(id) {
|
||||
var key = String(id || "")
|
||||
var service = services[key]
|
||||
if (service && typeof service.destroy === "function") service.destroy()
|
||||
delete services[key]
|
||||
}
|
||||
|
||||
function destroyAll() {
|
||||
var keys = ids()
|
||||
for (var i = 0; i < keys.length; i++) destroy(keys[i])
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import QtQuick
|
||||
|
||||
// Detached application-library capability for third-party menus. Callbacks
|
||||
// expose the supported app-list operations without retaining AppLibrary or its
|
||||
// ShellRoot parent in the plugin-visible object graph.
|
||||
QtObject {
|
||||
required property string ownerPluginId
|
||||
|
||||
signal appsChanged()
|
||||
|
||||
property var _entryName: null
|
||||
property var _entrySubtext: null
|
||||
property var _sortedEntries: null
|
||||
property var _iconSource: null
|
||||
property var _refreshIcons: null
|
||||
property var _launch: null
|
||||
property var _remove: null
|
||||
|
||||
function entryName(entry) {
|
||||
return _entryName ? _entryName(entry) : ""
|
||||
}
|
||||
|
||||
function entrySubtext(entry) {
|
||||
return _entrySubtext ? _entrySubtext(entry) : ""
|
||||
}
|
||||
|
||||
function sortedEntries(query) {
|
||||
return _sortedEntries ? _sortedEntries(String(query || "")) : []
|
||||
}
|
||||
|
||||
function iconSource(icon) {
|
||||
return _iconSource ? _iconSource(icon) : ""
|
||||
}
|
||||
|
||||
function refreshIcons() {
|
||||
if (_refreshIcons) _refreshIcons()
|
||||
}
|
||||
|
||||
function launch(desktopId, name) {
|
||||
if (_launch) _launch(String(desktopId || ""), String(name || ""))
|
||||
}
|
||||
|
||||
function remove(desktopId, name) {
|
||||
if (_remove) _remove(String(desktopId || ""), String(name || ""))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import QtQuick
|
||||
|
||||
// Scalar-only view of the active bar for plugins that position independent
|
||||
// windows. The active Bar QObject is never retained here.
|
||||
QtObject {
|
||||
required property string ownerPluginId
|
||||
|
||||
property bool barHidden: false
|
||||
property int barSize: 0
|
||||
property string fontFamily: ""
|
||||
property string position: "top"
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import QtQuick
|
||||
|
||||
// Detached widget-catalogue snapshot for third-party full-bar implementations.
|
||||
// Plugins can render the referenced components, but mutating this local view
|
||||
// cannot replace a registration in the host registry.
|
||||
QtObject {
|
||||
id: api
|
||||
|
||||
property var widgets: ({})
|
||||
property int revision: 0
|
||||
|
||||
function metadataFor(id) {
|
||||
var entry = widgets[String(id || "")]
|
||||
return entry ? entry.metadata : null
|
||||
}
|
||||
|
||||
function availableIds() {
|
||||
return Object.keys(widgets)
|
||||
}
|
||||
|
||||
function has(id) {
|
||||
return widgets[String(id || "")] !== undefined
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import QtQuick
|
||||
|
||||
// Narrow proxy for the non-authentication first-party services used by the
|
||||
// built-in bar. It intentionally has no generic property or method forwarding.
|
||||
QtObject {
|
||||
required property string ownerPluginId
|
||||
required property string serviceId
|
||||
|
||||
property bool stayAwake: false
|
||||
property bool enabled: false
|
||||
property bool doNotDisturb: false
|
||||
property var activePlayer: null
|
||||
property var sourcePlayers: []
|
||||
|
||||
property var _setIdleEnabled: null
|
||||
property var _setNightlight: null
|
||||
property var _setDoNotDisturb: null
|
||||
property var _runAction: null
|
||||
property var _playerKey: null
|
||||
property var _selectPlayer: null
|
||||
|
||||
function setIdleEnabled(value) {
|
||||
if (serviceId === "omarchy.idle" && _setIdleEnabled) _setIdleEnabled(!!value)
|
||||
}
|
||||
|
||||
function setNightlight(value) {
|
||||
if (serviceId === "omarchy.nightlight" && _setNightlight) _setNightlight(!!value)
|
||||
}
|
||||
|
||||
function setDoNotDisturb(value) {
|
||||
if (serviceId === "omarchy.notifications" && _setDoNotDisturb) _setDoNotDisturb(!!value)
|
||||
}
|
||||
|
||||
function runAction(action, showFeedback, playerId) {
|
||||
if (serviceId === "omarchy.media" && _runAction)
|
||||
_runAction(String(action || ""), !!showFeedback, String(playerId || ""))
|
||||
}
|
||||
|
||||
function playerKey(player) {
|
||||
return serviceId === "omarchy.media" && _playerKey ? _playerKey(player) : ""
|
||||
}
|
||||
|
||||
function selectPlayer(playerId) {
|
||||
if (serviceId === "omarchy.media" && _selectPlayer) _selectPlayer(String(playerId || ""))
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ QtObject {
|
||||
property var shellConfigProvider: null
|
||||
property var shellConfigMutator: null
|
||||
|
||||
// { pluginId: manifest } — manifests have __sourceDir and __isFirstParty stamped in.
|
||||
// { pluginId: manifest } — manifests have source/trust metadata stamped in.
|
||||
property var installedPlugins: ({})
|
||||
property int registryRevision: 0
|
||||
property bool scanning: false
|
||||
@@ -78,8 +78,7 @@ QtObject {
|
||||
}
|
||||
}
|
||||
// Every entry point must be a relative path inside the plugin's source
|
||||
// directory. Reject the whole manifest if anything looks like an attempt
|
||||
// to escape the plugin's sandbox.
|
||||
// directory. Reject the whole manifest if an entry point escapes it.
|
||||
for (var key in manifest.entryPoints) {
|
||||
if (!isSafeEntryPoint(manifest.entryPoints[key])) {
|
||||
console.warn("PluginRegistry: unsafe entryPoint '" + key + "'='"
|
||||
@@ -90,6 +89,32 @@ QtObject {
|
||||
return manifest
|
||||
}
|
||||
|
||||
function trustedCapabilities(manifest) {
|
||||
if (!manifest || !manifest.__isFirstParty) return []
|
||||
var metadata = Util.isPlainObject(manifest.omarchy) ? manifest.omarchy : null
|
||||
var declared = metadata && Array.isArray(metadata.capabilities) ? metadata.capabilities : []
|
||||
var out = []
|
||||
for (var i = 0; i < declared.length; i++) {
|
||||
var capability = String(declared[i] || "")
|
||||
if (capability && out.indexOf(capability) === -1) out.push(capability)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function stampHostCapabilities(firstParty, thirdParty) {
|
||||
for (var firstPartyId in firstParty)
|
||||
firstParty[firstPartyId].__hostCapabilities = trustedCapabilities(firstParty[firstPartyId])
|
||||
|
||||
for (var thirdPartyId in thirdParty) {
|
||||
var manifest = thirdParty[thirdPartyId]
|
||||
var metadata = manifest && Util.isPlainObject(manifest.omarchy) ? manifest.omarchy : null
|
||||
var clonedFrom = metadata ? String(metadata.clonedFrom || "") : ""
|
||||
var source = clonedFrom ? firstParty[clonedFrom] : null
|
||||
manifest.__hostCapabilities = source && Array.isArray(source.__hostCapabilities)
|
||||
? source.__hostCapabilities.slice() : []
|
||||
}
|
||||
}
|
||||
|
||||
function entryPointUrl(manifest, kind) {
|
||||
if (!Util.isPlainObject(manifest)) return ""
|
||||
var ep = manifest.entryPoints ? manifest.entryPoints[kind] : null
|
||||
@@ -594,6 +619,8 @@ QtObject {
|
||||
}
|
||||
flush()
|
||||
|
||||
stampHostCapabilities(firstParty, thirdParty)
|
||||
|
||||
var merged = {}
|
||||
for (var fk in firstParty) merged[fk] = firstParty[fk]
|
||||
// Third-party plugins never shadow first-party ids. The whole
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import QtQuick
|
||||
|
||||
// Read-only, self-scoped registry view for an installed third-party plugin.
|
||||
// The host updates manifest/enabled when it rescans; no host registry object is
|
||||
// retained here, so `parent` and property traversal cannot reach ShellRoot.
|
||||
QtObject {
|
||||
id: api
|
||||
|
||||
required property string pluginId
|
||||
property var manifest: null
|
||||
property bool enabled: false
|
||||
property var _entryPointUrl: null
|
||||
|
||||
readonly property var installedPlugins: {
|
||||
var out = ({})
|
||||
if (manifest) out[pluginId] = manifest
|
||||
return out
|
||||
}
|
||||
|
||||
function isEnabled(id) {
|
||||
return String(id || "") === pluginId && enabled
|
||||
}
|
||||
|
||||
function resolveEnabledId(id) {
|
||||
return String(id || "") === pluginId ? pluginId : ""
|
||||
}
|
||||
|
||||
function entryPointUrl(candidate, kind) {
|
||||
if (!candidate || String(candidate.id || "") !== pluginId) return ""
|
||||
return _entryPointUrl ? _entryPointUrl(String(kind || "")) : ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import QtQuick
|
||||
|
||||
// Capability-scoped shell surface for installed third-party plugins.
|
||||
//
|
||||
// The callbacks are closed over one plugin id by shell.qml. A plugin can call
|
||||
// them directly, but it cannot widen their scope: ordinary plugins are limited
|
||||
// to their own id, and full-bar callbacks independently enforce their explicit
|
||||
// non-authentication UI scope. This object avoids directly injecting the host
|
||||
// shell, but it is not a QML sandbox: visual plugins share the host object tree.
|
||||
QtObject {
|
||||
id: api
|
||||
|
||||
required property string pluginId
|
||||
|
||||
property var appLibrary: null
|
||||
property var bar: null
|
||||
property var barConfig: ({})
|
||||
property var idleConfig: ({})
|
||||
|
||||
property var _serviceLookup: null
|
||||
property var _firstPartyServiceLookup: null
|
||||
property var _barEntryShellLookup: null
|
||||
property var _summon: null
|
||||
property var _hide: null
|
||||
property var _toggle: null
|
||||
property var _isOpen: null
|
||||
property var _updateSettings: null
|
||||
property var _mutateBarConfig: null
|
||||
|
||||
function serviceFor(id) {
|
||||
return _serviceLookup ? _serviceLookup(String(id || "")) : null
|
||||
}
|
||||
|
||||
// Only full-bar facades receive narrow proxies for the specific
|
||||
// non-authentication services used by the built-in bar widgets.
|
||||
function firstPartyServiceFor(id) {
|
||||
return _firstPartyServiceLookup
|
||||
? _firstPartyServiceLookup(String(id || "")) : null
|
||||
}
|
||||
|
||||
function pluginShellForBarEntry(ownerId, moduleName) {
|
||||
return _barEntryShellLookup
|
||||
? _barEntryShellLookup(String(ownerId || ""), String(moduleName || "")) : null
|
||||
}
|
||||
|
||||
function summon(id, payloadJson) {
|
||||
return _summon ? _summon(String(id || ""), String(payloadJson || "")) : false
|
||||
}
|
||||
|
||||
function hide(id) {
|
||||
return _hide ? _hide(String(id || "")) : false
|
||||
}
|
||||
|
||||
function toggle(id, payloadJson) {
|
||||
return _toggle ? _toggle(String(id || ""), String(payloadJson || "")) : false
|
||||
}
|
||||
|
||||
function isPluginOpen(id) {
|
||||
return _isOpen ? _isOpen(String(id || "")) : false
|
||||
}
|
||||
|
||||
function updateEntryInline(id, settings) {
|
||||
return _updateSettings ? _updateSettings(String(id || ""), settings) : false
|
||||
}
|
||||
|
||||
function mutateShellConfig(mutator) {
|
||||
return _mutateBarConfig && typeof mutator === "function"
|
||||
? _mutateBarConfig(mutator) : false
|
||||
}
|
||||
}
|
||||
+704
-29
@@ -7,6 +7,7 @@ import qs.Commons
|
||||
|
||||
import "plugins/bar"
|
||||
import "services"
|
||||
import "services/AuthServiceStore.js" as AuthServiceStore
|
||||
|
||||
ShellRoot {
|
||||
id: shell
|
||||
@@ -113,7 +114,10 @@ ShellRoot {
|
||||
}
|
||||
|
||||
readonly property var barConfig: shellConfig && Util.isPlainObject(shellConfig.bar) ? shellConfig.bar : builtinShellConfig.bar
|
||||
onBarConfigChanged: if (bar && "barConfig" in bar) bar.barConfig = shell.barConfig
|
||||
onBarConfigChanged: {
|
||||
if (bar && "barConfig" in bar)
|
||||
bar.barConfig = shell.barConfigFor(shell.activeBarManifest)
|
||||
}
|
||||
FileView {
|
||||
id: defaultsFile
|
||||
path: shell.defaultsPath
|
||||
@@ -214,11 +218,11 @@ ShellRoot {
|
||||
function configureBar(target, manifest) {
|
||||
if (!target) return
|
||||
if ("omarchyPath" in target) target.omarchyPath = shell.omarchyPath
|
||||
if ("shell" in target) target.shell = shell
|
||||
if ("manifest" in target) target.manifest = manifest
|
||||
if ("barWidgetRegistry" in target) target.barWidgetRegistry = shell.barWidgetRegistry
|
||||
if ("pluginRegistry" in target) target.pluginRegistry = shell.pluginRegistry
|
||||
if ("barConfig" in target) target.barConfig = shell.barConfig
|
||||
if ("shell" in target) target.shell = shell.pluginShellFor(manifest)
|
||||
if ("manifest" in target) target.manifest = shell.publicPluginManifest(manifest)
|
||||
if ("barWidgetRegistry" in target) target.barWidgetRegistry = shell.pluginBarWidgetRegistryFor(manifest)
|
||||
if ("pluginRegistry" in target) target.pluginRegistry = shell.pluginRegistryFor(manifest)
|
||||
if ("barConfig" in target) target.barConfig = shell.barConfigFor(manifest)
|
||||
shell.bar = target
|
||||
}
|
||||
|
||||
@@ -253,8 +257,7 @@ ShellRoot {
|
||||
onActiveChanged: if (!active) shell.bar = null
|
||||
onStatusChanged: {
|
||||
if (status === Loader.Error) {
|
||||
var detail = errorString && errorString() ? errorString() : ""
|
||||
console.warn("bar option " + shell.activeBarId + " failed to load, falling back to " + shell.defaultBarId + ":", detail)
|
||||
console.warn("bar option " + shell.activeBarId + " failed to load, falling back to " + shell.defaultBarId)
|
||||
shell.failedBarId = shell.activeBarId
|
||||
}
|
||||
}
|
||||
@@ -271,6 +274,599 @@ ShellRoot {
|
||||
}
|
||||
|
||||
property var _services: ({})
|
||||
property var _pluginShellApis: ({})
|
||||
property var _pluginShellApiDescriptors: ({})
|
||||
property var _pluginBarEntryShellApis: ({})
|
||||
property var _pluginRegistryApis: ({})
|
||||
property var _pluginBarWidgetRegistryApis: ({})
|
||||
property var _pluginAppLibraryApis: ({})
|
||||
property var _pluginBarStateApis: ({})
|
||||
property var _pluginFirstPartyServiceApis: ({})
|
||||
|
||||
Component {
|
||||
id: pluginShellApiComponent
|
||||
PluginShellApi { }
|
||||
}
|
||||
|
||||
Component {
|
||||
id: pluginRegistryApiComponent
|
||||
PluginRegistryApi { }
|
||||
}
|
||||
|
||||
Component {
|
||||
id: pluginBarWidgetRegistryApiComponent
|
||||
PluginBarWidgetRegistryApi { }
|
||||
}
|
||||
|
||||
Component {
|
||||
id: pluginAppLibraryApiComponent
|
||||
PluginAppLibraryApi { }
|
||||
}
|
||||
|
||||
Component {
|
||||
id: pluginBarStateApiComponent
|
||||
PluginBarStateApi { }
|
||||
}
|
||||
|
||||
Component {
|
||||
id: pluginFirstPartyServiceApiComponent
|
||||
PluginFirstPartyServiceApi { }
|
||||
}
|
||||
|
||||
function publicPluginManifest(manifest) {
|
||||
if (!manifest) return null
|
||||
if (manifest.__isFirstParty) return manifest
|
||||
var copy = JSON.parse(JSON.stringify(manifest))
|
||||
delete copy.__sourceDir
|
||||
delete copy.__isFirstParty
|
||||
delete copy.__hostCapabilities
|
||||
return copy
|
||||
}
|
||||
|
||||
function publicBarConfig() {
|
||||
return JSON.parse(JSON.stringify(shell.barConfig || {}))
|
||||
}
|
||||
|
||||
function barConfigFor(manifest) {
|
||||
return !manifest || manifest.__isFirstParty
|
||||
? shell.barConfig : shell.publicBarConfig()
|
||||
}
|
||||
|
||||
function publicBarWidgetSnapshot() {
|
||||
var source = shell.barWidgetRegistry.widgets || {}
|
||||
var snapshot = {}
|
||||
for (var id in source) {
|
||||
var entry = source[id]
|
||||
if (!entry) continue
|
||||
snapshot[id] = {
|
||||
component: entry.component,
|
||||
metadata: JSON.parse(JSON.stringify(entry.metadata || {}))
|
||||
}
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
function manifestHasKind(manifest, kind) {
|
||||
return !!manifest && Array.isArray(manifest.kinds)
|
||||
&& manifest.kinds.indexOf(kind) !== -1
|
||||
}
|
||||
|
||||
function pluginHasBarCapabilities(manifest) {
|
||||
return shell.manifestHasKind(manifest, "bar")
|
||||
}
|
||||
|
||||
function publicIdleConfigFor(manifest) {
|
||||
var metadata = manifest && Util.isPlainObject(manifest.omarchy) ? manifest.omarchy : null
|
||||
if (!metadata || String(metadata.clonedFrom || "") !== "omarchy.idle") return ({})
|
||||
var idle = shell.shellConfig && Util.isPlainObject(shell.shellConfig.idle)
|
||||
? shell.shellConfig.idle : ({})
|
||||
return JSON.parse(JSON.stringify(idle))
|
||||
}
|
||||
|
||||
function pluginCloneMaySummon(manifest, requestedId) {
|
||||
var metadata = manifest && Util.isPlainObject(manifest.omarchy) ? manifest.omarchy : null
|
||||
var sourceId = metadata ? String(metadata.clonedFrom || "") : ""
|
||||
var allowed = {
|
||||
"omarchy.audio": ["omarchy.osd"],
|
||||
"omarchy.media": ["omarchy.osd"],
|
||||
"omarchy.monitor": ["omarchy.osd"],
|
||||
"omarchy.network": ["omarchy.speedtest", "omarchy.wifiqr"]
|
||||
}
|
||||
var targets = allowed[sourceId] || []
|
||||
return targets.indexOf(String(requestedId || "")) !== -1
|
||||
}
|
||||
|
||||
function pluginOwnsTarget(pluginId, requestedId) {
|
||||
var caller = String(pluginId || "")
|
||||
if (!caller) return false
|
||||
return shell.pluginRegistry.resolveEnabledId(String(requestedId || "")) === caller
|
||||
}
|
||||
|
||||
function pluginServiceFor(pluginId, requestedId) {
|
||||
if (!shell.pluginOwnsTarget(pluginId, requestedId)) return null
|
||||
return shell.serviceFor(shell.pluginRegistry.resolveEnabledId(requestedId))
|
||||
}
|
||||
|
||||
function barEntryConfigured(pluginId) {
|
||||
var location = shell.pluginRegistry.findEntryLocation(shell.shellConfig, pluginId)
|
||||
return location && location.kind === "bar"
|
||||
}
|
||||
|
||||
function barPluginMayControl(manifest, requestedId) {
|
||||
if (!shell.pluginHasBarCapabilities(manifest)) return false
|
||||
var id = shell.pluginRegistry.resolveEnabledId(String(requestedId || ""))
|
||||
var target = shell.pluginRegistry.installedPlugins[id]
|
||||
if (!target || shell.isAuthenticationService(target, id)) return false
|
||||
if (shell.barEntryConfigured(id)) return true
|
||||
var uiKinds = ["bar-widget", "panel", "overlay", "menu"]
|
||||
for (var i = 0; i < uiKinds.length; i++)
|
||||
if (shell.manifestHasKind(target, uiKinds[i])) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function mutatePluginBarConfig(mutator) {
|
||||
if (typeof mutator !== "function") return false
|
||||
shell.mutateShellConfig(function(config) {
|
||||
var scoped = { bar: JSON.parse(JSON.stringify(config.bar || {})) }
|
||||
mutator(scoped)
|
||||
if (Util.isPlainObject(scoped.bar)) config.bar = JSON.parse(JSON.stringify(scoped.bar))
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
function pluginAppLibraryFor(cacheKey, pluginId) {
|
||||
if (_pluginAppLibraryApis[cacheKey]) return _pluginAppLibraryApis[cacheKey]
|
||||
var api = pluginAppLibraryApiComponent.createObject(null, {
|
||||
ownerPluginId: pluginId,
|
||||
_entryName: function(entry) { return shell.appLibrary.entryName(entry) },
|
||||
_entrySubtext: function(entry) { return shell.appLibrary.entrySubtext(entry) },
|
||||
_sortedEntries: function(query) { return shell.appLibrary.sortedEntries(query) },
|
||||
_iconSource: function(icon) { return shell.appLibrary.iconSource(icon) },
|
||||
_refreshIcons: function() { shell.appLibrary.refreshIcons() },
|
||||
_launch: function(desktopId, name) { shell.appLibrary.launch(desktopId, name) },
|
||||
_remove: function(desktopId, name) { shell.appLibrary.remove(desktopId, name) }
|
||||
})
|
||||
if (!api) return null
|
||||
var next = ({})
|
||||
for (var id in _pluginAppLibraryApis) next[id] = _pluginAppLibraryApis[id]
|
||||
next[cacheKey] = api
|
||||
_pluginAppLibraryApis = next
|
||||
return api
|
||||
}
|
||||
|
||||
function pluginBarStateFor(cacheKey, pluginId) {
|
||||
if (_pluginBarStateApis[cacheKey]) return _pluginBarStateApis[cacheKey]
|
||||
var api = pluginBarStateApiComponent.createObject(null, { ownerPluginId: pluginId })
|
||||
if (!api) return null
|
||||
api.barHidden = Qt.binding(function() { return shell.bar ? shell.bar.barHidden === true : false })
|
||||
api.barSize = Qt.binding(function() { return shell.bar ? Math.max(0, shell.bar.barSize || 0) : 0 })
|
||||
api.fontFamily = Qt.binding(function() { return shell.bar ? String(shell.bar.fontFamily || "") : "" })
|
||||
api.position = Qt.binding(function() { return shell.bar ? String(shell.bar.position || "top") : "top" })
|
||||
var next = ({})
|
||||
for (var id in _pluginBarStateApis) next[id] = _pluginBarStateApis[id]
|
||||
next[cacheKey] = api
|
||||
_pluginBarStateApis = next
|
||||
return api
|
||||
}
|
||||
|
||||
function pluginFirstPartyServiceFor(cacheKey, pluginId, requestedId) {
|
||||
var id = String(requestedId || "")
|
||||
var allowed = ["omarchy.idle", "omarchy.media", "omarchy.nightlight", "omarchy.notifications"]
|
||||
if (allowed.indexOf(id) === -1) return null
|
||||
var proxyKey = cacheKey + "::" + id
|
||||
if (_pluginFirstPartyServiceApis[proxyKey]) return _pluginFirstPartyServiceApis[proxyKey]
|
||||
|
||||
function service() {
|
||||
return shell.serviceFor(shell.pluginRegistry.resolveEnabledId(id))
|
||||
}
|
||||
var api = pluginFirstPartyServiceApiComponent.createObject(null, {
|
||||
ownerPluginId: pluginId,
|
||||
serviceId: id,
|
||||
_setIdleEnabled: function(value) {
|
||||
var target = service()
|
||||
if (target && typeof target.setIdleEnabled === "function") target.setIdleEnabled(value)
|
||||
},
|
||||
_setNightlight: function(value) {
|
||||
var target = service()
|
||||
if (target && typeof target.setNightlight === "function") target.setNightlight(value)
|
||||
},
|
||||
_setDoNotDisturb: function(value) {
|
||||
var target = service()
|
||||
if (target && typeof target.setDoNotDisturb === "function") target.setDoNotDisturb(value)
|
||||
},
|
||||
_runAction: function(action, showFeedback, targetKey) {
|
||||
var target = service()
|
||||
if (target && typeof target.runAction === "function") target.runAction(action, showFeedback, targetKey)
|
||||
},
|
||||
_playerKey: function(player) {
|
||||
var target = service()
|
||||
return target && typeof target.playerKey === "function" ? target.playerKey(player) : ""
|
||||
},
|
||||
_selectPlayer: function(playerKey) {
|
||||
var target = service()
|
||||
if (target && typeof target.selectPlayer === "function") target.selectPlayer(playerKey)
|
||||
}
|
||||
})
|
||||
if (!api) return null
|
||||
api.stayAwake = Qt.binding(function() {
|
||||
var target = service()
|
||||
return target ? target.stayAwake === true : false
|
||||
})
|
||||
api.enabled = Qt.binding(function() {
|
||||
var target = service()
|
||||
return target ? target.enabled === true : false
|
||||
})
|
||||
api.doNotDisturb = Qt.binding(function() {
|
||||
var target = service()
|
||||
return target ? target.doNotDisturb === true : false
|
||||
})
|
||||
api.activePlayer = Qt.binding(function() {
|
||||
var target = service()
|
||||
return target ? target.activePlayer : null
|
||||
})
|
||||
api.sourcePlayers = Qt.binding(function() {
|
||||
var target = service()
|
||||
return target && Array.isArray(target.sourcePlayers) ? target.sourcePlayers : []
|
||||
})
|
||||
var next = ({})
|
||||
for (var existing in _pluginFirstPartyServiceApis) next[existing] = _pluginFirstPartyServiceApis[existing]
|
||||
next[proxyKey] = api
|
||||
_pluginFirstPartyServiceApis = next
|
||||
return api
|
||||
}
|
||||
|
||||
function pluginShellCapabilityProfile(manifest, allowOwnService, barCapabilities) {
|
||||
return [
|
||||
allowOwnService ? "own-service" : "no-own-service",
|
||||
barCapabilities ? "bar" : "no-bar",
|
||||
shell.manifestHasKind(manifest, "menu") ? "menu" : "no-menu"
|
||||
].join("|")
|
||||
}
|
||||
|
||||
function cacheWithoutKey(cache, key, destroyValue) {
|
||||
var next = ({})
|
||||
for (var existing in cache) {
|
||||
if (existing === key) {
|
||||
var value = cache[existing]
|
||||
if (destroyValue && value && typeof value.destroy === "function") value.destroy()
|
||||
} else {
|
||||
next[existing] = cache[existing]
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function cacheWithoutPrefix(cache, prefix) {
|
||||
var next = ({})
|
||||
for (var existing in cache) {
|
||||
if (existing.indexOf(prefix) === 0) {
|
||||
var value = cache[existing]
|
||||
if (value && typeof value.destroy === "function") value.destroy()
|
||||
} else {
|
||||
next[existing] = cache[existing]
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function revokePluginShellApi(cacheKey) {
|
||||
var key = String(cacheKey || "")
|
||||
if (!key) return
|
||||
_pluginAppLibraryApis = shell.cacheWithoutKey(_pluginAppLibraryApis, key, true)
|
||||
_pluginFirstPartyServiceApis = shell.cacheWithoutPrefix(_pluginFirstPartyServiceApis, key + "::")
|
||||
_pluginBarEntryShellApis = shell.cacheWithoutPrefix(_pluginBarEntryShellApis, key + ":")
|
||||
_pluginShellApis = shell.cacheWithoutKey(_pluginShellApis, key, true)
|
||||
_pluginShellApiDescriptors = shell.cacheWithoutKey(_pluginShellApiDescriptors, key, false)
|
||||
}
|
||||
|
||||
function createScopedPluginShell(manifest, cacheKey, allowOwnService, barCapabilities) {
|
||||
var key = String(manifest && manifest.id || "")
|
||||
if (!key) return null
|
||||
var profile = shell.pluginShellCapabilityProfile(manifest, allowOwnService, barCapabilities)
|
||||
var cached = _pluginShellApis[cacheKey]
|
||||
var descriptor = _pluginShellApiDescriptors[cacheKey]
|
||||
if (cached && descriptor && descriptor.pluginId === key
|
||||
&& descriptor.profile === profile) return cached
|
||||
if (cached || descriptor) shell.revokePluginShellApi(cacheKey)
|
||||
|
||||
function currentManifest() {
|
||||
return shell.pluginRegistry.installedPlugins[key] || null
|
||||
}
|
||||
|
||||
function hasCurrentBarCapabilities() {
|
||||
return barCapabilities && shell.pluginHasBarCapabilities(currentManifest())
|
||||
}
|
||||
|
||||
// Construct the narrow service proxies before any plugin binding can call
|
||||
// firstPartyServiceFor(). Creating a QObject while evaluating that binding
|
||||
// makes QML re-enter the binding and report a loop on the caller's service
|
||||
// property, even though the resulting proxy is otherwise acyclic.
|
||||
var firstPartyServices = ({})
|
||||
if (barCapabilities) {
|
||||
var serviceIds = ["omarchy.idle", "omarchy.media", "omarchy.nightlight", "omarchy.notifications"]
|
||||
for (var i = 0; i < serviceIds.length; i++) {
|
||||
var serviceId = serviceIds[i]
|
||||
firstPartyServices[serviceId] = shell.pluginFirstPartyServiceFor(cacheKey, key, serviceId)
|
||||
}
|
||||
}
|
||||
|
||||
var api = pluginShellApiComponent.createObject(null, {
|
||||
pluginId: key,
|
||||
appLibrary: shell.manifestHasKind(manifest, "menu")
|
||||
? shell.pluginAppLibraryFor(cacheKey, key) : null,
|
||||
bar: shell.pluginBarStateFor(cacheKey, key),
|
||||
barConfig: shell.publicBarConfig(),
|
||||
idleConfig: shell.publicIdleConfigFor(manifest),
|
||||
_serviceLookup: function(requestedId) {
|
||||
return allowOwnService ? shell.pluginServiceFor(key, requestedId) : null
|
||||
},
|
||||
_firstPartyServiceLookup: function(requestedId) {
|
||||
if (allowOwnService && shell.pluginOwnsTarget(key, requestedId))
|
||||
return shell.pluginServiceFor(key, requestedId)
|
||||
return hasCurrentBarCapabilities() ? (firstPartyServices[requestedId] || null) : null
|
||||
},
|
||||
_barEntryShellLookup: function(ownerId, moduleName) {
|
||||
return hasCurrentBarCapabilities()
|
||||
? shell.pluginShellForBarEntry(cacheKey + ":" + ownerId, moduleName) : null
|
||||
},
|
||||
_summon: function(requestedId, payloadJson) {
|
||||
if (!shell.pluginOwnsTarget(key, requestedId)
|
||||
&& !shell.barPluginMayControl(currentManifest(), requestedId)
|
||||
&& !shell.pluginCloneMaySummon(currentManifest(), requestedId)) return false
|
||||
return shell.summon(shell.pluginRegistry.resolveEnabledId(requestedId), payloadJson)
|
||||
},
|
||||
_hide: function(requestedId) {
|
||||
if (!shell.pluginOwnsTarget(key, requestedId)
|
||||
&& !shell.barPluginMayControl(currentManifest(), requestedId)) return false
|
||||
return shell.hide(shell.pluginRegistry.resolveEnabledId(requestedId))
|
||||
},
|
||||
_toggle: function(requestedId, payloadJson) {
|
||||
if (!shell.pluginOwnsTarget(key, requestedId)
|
||||
&& !shell.barPluginMayControl(currentManifest(), requestedId)) return false
|
||||
return shell.toggle(shell.pluginRegistry.resolveEnabledId(requestedId), payloadJson)
|
||||
},
|
||||
_isOpen: function(requestedId) {
|
||||
if (!shell.pluginOwnsTarget(key, requestedId)
|
||||
&& !shell.barPluginMayControl(currentManifest(), requestedId)) return false
|
||||
return shell.isPluginOpen(shell.pluginRegistry.resolveEnabledId(requestedId))
|
||||
},
|
||||
_updateSettings: function(requestedId, settings) {
|
||||
if (shell.pluginOwnsTarget(key, requestedId)) return shell.updateEntryInline(key, settings)
|
||||
if (hasCurrentBarCapabilities() && shell.barEntryConfigured(requestedId))
|
||||
return shell.updateEntryInline(requestedId, settings)
|
||||
return false
|
||||
},
|
||||
_mutateBarConfig: function(mutator) {
|
||||
return hasCurrentBarCapabilities() ? shell.mutatePluginBarConfig(mutator) : false
|
||||
}
|
||||
})
|
||||
if (!api) return null
|
||||
|
||||
var next = ({})
|
||||
for (var id in _pluginShellApis) next[id] = _pluginShellApis[id]
|
||||
next[cacheKey] = api
|
||||
_pluginShellApis = next
|
||||
var descriptorNext = ({})
|
||||
for (var descriptorKey in _pluginShellApiDescriptors)
|
||||
descriptorNext[descriptorKey] = _pluginShellApiDescriptors[descriptorKey]
|
||||
descriptorNext[cacheKey] = {
|
||||
pluginId: key,
|
||||
allowOwnService: allowOwnService === true,
|
||||
profile: profile
|
||||
}
|
||||
_pluginShellApiDescriptors = descriptorNext
|
||||
return api
|
||||
}
|
||||
|
||||
function scopedPluginShellForId(pluginId) {
|
||||
var key = String(pluginId || "")
|
||||
var manifest = shell.pluginRegistry.installedPlugins[key]
|
||||
if (!manifest) return null
|
||||
if (!manifest.__isFirstParty) return shell.pluginShellFor(manifest)
|
||||
return shell.createScopedPluginShell(manifest, "hosted:" + key, false, false)
|
||||
}
|
||||
|
||||
function pluginShellForId(pluginId) {
|
||||
return shell.scopedPluginShellForId(pluginId)
|
||||
}
|
||||
|
||||
function pluginShellForBarEntry(ownerId, moduleName) {
|
||||
var owner = String(ownerId || "")
|
||||
var target = String(moduleName || "")
|
||||
if (!owner || !target) return null
|
||||
if (!shell.barEntryConfigured(target)) return null
|
||||
var cacheKey = owner + "::" + target
|
||||
if (_pluginBarEntryShellApis[cacheKey]) return _pluginBarEntryShellApis[cacheKey]
|
||||
|
||||
function owns(requestedId) {
|
||||
return shell.pluginRegistry.resolveEnabledId(String(requestedId || ""))
|
||||
=== shell.pluginRegistry.resolveEnabledId(target)
|
||||
}
|
||||
|
||||
function currentManifest() {
|
||||
var id = shell.pluginRegistry.resolveEnabledId(target)
|
||||
return shell.pluginRegistry.installedPlugins[id] || null
|
||||
}
|
||||
|
||||
var api = pluginShellApiComponent.createObject(null, {
|
||||
pluginId: target,
|
||||
barConfig: shell.publicBarConfig(),
|
||||
_summon: function(requestedId, payloadJson) {
|
||||
if (!owns(requestedId)
|
||||
&& !shell.pluginCloneMaySummon(currentManifest(), requestedId)) return false
|
||||
return shell.summon(shell.pluginRegistry.resolveEnabledId(requestedId), payloadJson)
|
||||
},
|
||||
_hide: function(requestedId) {
|
||||
return owns(requestedId)
|
||||
? shell.hide(shell.pluginRegistry.resolveEnabledId(target)) : false
|
||||
},
|
||||
_toggle: function(requestedId, payloadJson) {
|
||||
return owns(requestedId)
|
||||
? shell.toggle(shell.pluginRegistry.resolveEnabledId(target), payloadJson) : false
|
||||
},
|
||||
_isOpen: function(requestedId) {
|
||||
return owns(requestedId)
|
||||
? shell.isPluginOpen(shell.pluginRegistry.resolveEnabledId(target)) : false
|
||||
},
|
||||
_updateSettings: function(requestedId, settings) {
|
||||
return String(requestedId || "") === target
|
||||
? shell.updateEntryInline(target, settings) : false
|
||||
}
|
||||
})
|
||||
if (!api) return null
|
||||
var next = ({})
|
||||
for (var id in _pluginBarEntryShellApis) next[id] = _pluginBarEntryShellApis[id]
|
||||
next[cacheKey] = api
|
||||
_pluginBarEntryShellApis = next
|
||||
return api
|
||||
}
|
||||
|
||||
function pluginShellFor(manifest) {
|
||||
if (!manifest || manifest.__isFirstParty) return shell
|
||||
var key = String(manifest.id || "")
|
||||
if (!key) return null
|
||||
return shell.createScopedPluginShell(manifest, key, true, shell.pluginHasBarCapabilities(manifest))
|
||||
}
|
||||
|
||||
function pluginRegistryFor(manifest) {
|
||||
if (!manifest || manifest.__isFirstParty) return shell.pluginRegistry
|
||||
var key = String(manifest.id || "")
|
||||
if (!key) return null
|
||||
if (_pluginRegistryApis[key]) return _pluginRegistryApis[key]
|
||||
|
||||
var api = pluginRegistryApiComponent.createObject(null, {
|
||||
pluginId: key,
|
||||
manifest: shell.publicPluginManifest(manifest),
|
||||
enabled: shell.pluginRegistry.isEnabled(key),
|
||||
_entryPointUrl: function(kind) {
|
||||
var current = shell.pluginRegistry.installedPlugins[key]
|
||||
return current ? shell.pluginRegistry.entryPointUrl(current, kind) : ""
|
||||
}
|
||||
})
|
||||
if (!api) return null
|
||||
|
||||
var next = ({})
|
||||
for (var id in _pluginRegistryApis) next[id] = _pluginRegistryApis[id]
|
||||
next[key] = api
|
||||
_pluginRegistryApis = next
|
||||
return api
|
||||
}
|
||||
|
||||
function pluginBarWidgetRegistryFor(manifest) {
|
||||
if (!manifest || manifest.__isFirstParty) return shell.barWidgetRegistry
|
||||
var key = String(manifest.id || "")
|
||||
if (!key) return null
|
||||
if (_pluginBarWidgetRegistryApis[key]) return _pluginBarWidgetRegistryApis[key]
|
||||
|
||||
var api = pluginBarWidgetRegistryApiComponent.createObject(null, {
|
||||
widgets: shell.publicBarWidgetSnapshot(),
|
||||
revision: shell.barWidgetRegistry.revision
|
||||
})
|
||||
if (!api) return null
|
||||
|
||||
var next = ({})
|
||||
for (var id in _pluginBarWidgetRegistryApis) next[id] = _pluginBarWidgetRegistryApis[id]
|
||||
next[key] = api
|
||||
_pluginBarWidgetRegistryApis = next
|
||||
return api
|
||||
}
|
||||
|
||||
function pluginApiActive(api, plugins) {
|
||||
var id = api ? String(api.pluginId || api.ownerPluginId || "") : ""
|
||||
var manifest = id ? plugins[id] : null
|
||||
return !!manifest && shell.pluginRegistry.isEnabled(id)
|
||||
}
|
||||
|
||||
function prunePluginApis() {
|
||||
var plugins = shell.pluginRegistry.installedPlugins
|
||||
var shellKeys = Object.keys(_pluginShellApis)
|
||||
for (var si = 0; si < shellKeys.length; si++) {
|
||||
var shellKey = shellKeys[si]
|
||||
var shellApi = _pluginShellApis[shellKey]
|
||||
var descriptor = _pluginShellApiDescriptors[shellKey]
|
||||
var manifest = descriptor ? plugins[descriptor.pluginId] : null
|
||||
var barCapabilities = descriptor && descriptor.allowOwnService
|
||||
&& shell.pluginHasBarCapabilities(manifest)
|
||||
var expectedProfile = descriptor
|
||||
? shell.pluginShellCapabilityProfile(manifest, descriptor.allowOwnService, barCapabilities) : ""
|
||||
var active = descriptor && manifest && shell.pluginRegistry.isEnabled(descriptor.pluginId)
|
||||
if (!active || descriptor.profile !== expectedProfile)
|
||||
shell.revokePluginShellApi(shellKey)
|
||||
}
|
||||
|
||||
var registryNext = ({})
|
||||
for (var registryKey in _pluginRegistryApis) {
|
||||
var registryApi = _pluginRegistryApis[registryKey]
|
||||
if (shell.pluginApiActive(registryApi, plugins)) registryNext[registryKey] = registryApi
|
||||
else if (registryApi && typeof registryApi.destroy === "function") registryApi.destroy()
|
||||
}
|
||||
_pluginRegistryApis = registryNext
|
||||
|
||||
var widgetNext = ({})
|
||||
for (var widgetKey in _pluginBarWidgetRegistryApis) {
|
||||
var widgetApi = _pluginBarWidgetRegistryApis[widgetKey]
|
||||
if (plugins[widgetKey] && shell.pluginRegistry.isEnabled(widgetKey)) widgetNext[widgetKey] = widgetApi
|
||||
else if (widgetApi && typeof widgetApi.destroy === "function") widgetApi.destroy()
|
||||
}
|
||||
_pluginBarWidgetRegistryApis = widgetNext
|
||||
|
||||
var appNext = ({})
|
||||
for (var appKey in _pluginAppLibraryApis) {
|
||||
var appApi = _pluginAppLibraryApis[appKey]
|
||||
if (shell.pluginApiActive(appApi, plugins)) appNext[appKey] = appApi
|
||||
else if (appApi && typeof appApi.destroy === "function") appApi.destroy()
|
||||
}
|
||||
_pluginAppLibraryApis = appNext
|
||||
|
||||
var barStateNext = ({})
|
||||
for (var barStateKey in _pluginBarStateApis) {
|
||||
var barStateApi = _pluginBarStateApis[barStateKey]
|
||||
if (shell.pluginApiActive(barStateApi, plugins)) barStateNext[barStateKey] = barStateApi
|
||||
else if (barStateApi && typeof barStateApi.destroy === "function") barStateApi.destroy()
|
||||
}
|
||||
_pluginBarStateApis = barStateNext
|
||||
|
||||
var serviceNext = ({})
|
||||
for (var serviceKey in _pluginFirstPartyServiceApis) {
|
||||
var serviceApi = _pluginFirstPartyServiceApis[serviceKey]
|
||||
if (shell.pluginApiActive(serviceApi, plugins)) serviceNext[serviceKey] = serviceApi
|
||||
else if (serviceApi && typeof serviceApi.destroy === "function") serviceApi.destroy()
|
||||
}
|
||||
_pluginFirstPartyServiceApis = serviceNext
|
||||
|
||||
var entryNext = ({})
|
||||
for (var entryKey in _pluginBarEntryShellApis) {
|
||||
var entryApi = _pluginBarEntryShellApis[entryKey]
|
||||
if (entryApi && shell.barEntryConfigured(entryApi.pluginId)) entryNext[entryKey] = entryApi
|
||||
else if (entryApi && typeof entryApi.destroy === "function") entryApi.destroy()
|
||||
}
|
||||
_pluginBarEntryShellApis = entryNext
|
||||
}
|
||||
|
||||
function syncPluginApis() {
|
||||
shell.prunePluginApis()
|
||||
var plugins = shell.pluginRegistry.installedPlugins
|
||||
for (var id in _pluginRegistryApis) {
|
||||
var registryApi = _pluginRegistryApis[id]
|
||||
var manifest = plugins[id]
|
||||
registryApi.manifest = shell.publicPluginManifest(manifest)
|
||||
registryApi.enabled = !!manifest && shell.pluginRegistry.isEnabled(id)
|
||||
}
|
||||
for (var widgetId in _pluginBarWidgetRegistryApis) {
|
||||
var widgetApi = _pluginBarWidgetRegistryApis[widgetId]
|
||||
widgetApi.widgets = shell.publicBarWidgetSnapshot()
|
||||
widgetApi.revision = shell.barWidgetRegistry.revision
|
||||
}
|
||||
for (var shellKey in _pluginShellApis) {
|
||||
var shellApi = _pluginShellApis[shellKey]
|
||||
var descriptor = _pluginShellApiDescriptors[shellKey]
|
||||
var shellManifest = descriptor ? plugins[descriptor.pluginId] : null
|
||||
shellApi.barConfig = shell.publicBarConfig()
|
||||
shellApi.idleConfig = shell.publicIdleConfigFor(shellManifest)
|
||||
}
|
||||
for (var entryKey in _pluginBarEntryShellApis)
|
||||
_pluginBarEntryShellApis[entryKey].barConfig = shell.publicBarConfig()
|
||||
}
|
||||
|
||||
// Reassigned as each service registers, so a binding that reads this before
|
||||
// looking a service up by id re-evaluates once that service exists.
|
||||
@@ -281,7 +877,14 @@ ShellRoot {
|
||||
}
|
||||
|
||||
function firstPartyServiceFor(pluginId) {
|
||||
return serviceFor(pluginId)
|
||||
return serviceFor(shell.pluginRegistry.resolveEnabledId(pluginId))
|
||||
}
|
||||
|
||||
function isAuthenticationService(manifest, pluginId) {
|
||||
var key = String(pluginId || (manifest && manifest.id) || "")
|
||||
return AuthServiceStore.isTrusted(key)
|
||||
|| (!!manifest && Array.isArray(manifest.__hostCapabilities)
|
||||
&& manifest.__hostCapabilities.indexOf("authentication") !== -1)
|
||||
}
|
||||
|
||||
function ensureService(pluginId) {
|
||||
@@ -294,6 +897,8 @@ ShellRoot {
|
||||
if (!manifest.entryPoints || !manifest.entryPoints.service) return null
|
||||
var url = pluginRegistry.entryPointUrl(manifest, "service")
|
||||
if (!url) return null
|
||||
var authenticationService = shell.isAuthenticationService(manifest, key)
|
||||
if (authenticationService && AuthServiceStore.has(key)) return null
|
||||
|
||||
var comp = Qt.createComponent(url, Component.PreferSynchronous)
|
||||
function finalize() {
|
||||
@@ -301,27 +906,37 @@ ShellRoot {
|
||||
console.warn("service plugin load failed for " + key + ": " + comp.errorString())
|
||||
return
|
||||
}
|
||||
var inst = comp.createObject(serviceHost)
|
||||
// Authentication services and third-party services have no visual
|
||||
// parent. Parenting either to serviceHost would let a plugin's object
|
||||
// traversal walk between the host and credential-bearing QML.
|
||||
var inst = comp.createObject(manifest.__isFirstParty && !authenticationService ? serviceHost : null)
|
||||
if (!inst) {
|
||||
console.warn("service plugin createObject returned null for", key)
|
||||
return
|
||||
}
|
||||
if ("omarchyPath" in inst) inst.omarchyPath = shell.omarchyPath
|
||||
if ("shell" in inst) inst.shell = shell
|
||||
if ("manifest" in inst) inst.manifest = manifest
|
||||
if ("barWidgetRegistry" in inst) inst.barWidgetRegistry = shell.barWidgetRegistry
|
||||
if ("pluginRegistry" in inst) inst.pluginRegistry = shell.pluginRegistry
|
||||
var snext = ({})
|
||||
for (var sk in _services) snext[sk] = _services[sk]
|
||||
snext[key] = inst
|
||||
_services = snext
|
||||
if ("shell" in inst) inst.shell = shell.pluginShellFor(manifest)
|
||||
if ("manifest" in inst) inst.manifest = shell.publicPluginManifest(manifest)
|
||||
if ("barWidgetRegistry" in inst) inst.barWidgetRegistry = shell.pluginBarWidgetRegistryFor(manifest)
|
||||
if ("pluginRegistry" in inst) inst.pluginRegistry = shell.pluginRegistryFor(manifest)
|
||||
if (authenticationService) {
|
||||
// Never publish lock/polkit through ShellRoot._services. The private JS
|
||||
// import retains their lifetime without adding a traversable property
|
||||
// or QObject parent back to the host shell.
|
||||
AuthServiceStore.put(key, inst)
|
||||
} else {
|
||||
var snext = ({})
|
||||
for (var sk in _services) snext[sk] = _services[sk]
|
||||
snext[key] = inst
|
||||
_services = snext
|
||||
}
|
||||
}
|
||||
if (comp.status === Component.Loading) {
|
||||
comp.statusChanged.connect(finalize)
|
||||
return null
|
||||
}
|
||||
finalize()
|
||||
return _services[key] || null
|
||||
return authenticationService ? null : (_services[key] || null)
|
||||
}
|
||||
|
||||
function _syncServices() {
|
||||
@@ -333,11 +948,33 @@ ShellRoot {
|
||||
if (!Array.isArray(m.kinds) || m.kinds.indexOf("service") === -1) continue
|
||||
if (!m.entryPoints || !m.entryPoints.service) continue
|
||||
if (!pluginRegistry.isEnabled(id)) continue
|
||||
var authenticationService = shell.isAuthenticationService(m, id)
|
||||
if (_services[id]) {
|
||||
// A kept instance outlives the rescan; hand it the fresh manifest.
|
||||
var kept = _services[id]
|
||||
if (kept && "manifest" in kept) kept.manifest = m
|
||||
continue
|
||||
if (authenticationService) {
|
||||
// A service that gains a trusted authentication capability must move
|
||||
// out of the host's public service map before it is recreated.
|
||||
var published = _services[id]
|
||||
if (published && typeof published.destroy === "function") published.destroy()
|
||||
var withoutPublished = ({})
|
||||
for (var publishedId in _services)
|
||||
if (publishedId !== id) withoutPublished[publishedId] = _services[publishedId]
|
||||
_services = withoutPublished
|
||||
} else {
|
||||
// A kept instance outlives the rescan; hand it the fresh manifest.
|
||||
var kept = _services[id]
|
||||
if (kept && "shell" in kept) kept.shell = shell.pluginShellFor(m)
|
||||
if (kept && "manifest" in kept) kept.manifest = shell.publicPluginManifest(m)
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (AuthServiceStore.has(id)) {
|
||||
if (authenticationService) {
|
||||
AuthServiceStore.updateManifest(id, shell.publicPluginManifest(m))
|
||||
continue
|
||||
}
|
||||
// A service that loses its trusted authentication capability can move
|
||||
// back to the ordinary service map only after the isolated copy dies.
|
||||
AuthServiceStore.destroy(id)
|
||||
}
|
||||
ensureService(id)
|
||||
}
|
||||
@@ -356,6 +993,21 @@ ShellRoot {
|
||||
for (var k in _services) if (k !== existingId) next[k] = _services[k]
|
||||
_services = next
|
||||
}
|
||||
// Authentication services are retained outside the root object graph, so
|
||||
// reconcile their disable/remove lifecycle separately from _services.
|
||||
var authenticationIds = AuthServiceStore.ids()
|
||||
for (var ai = 0; ai < authenticationIds.length; ai++) {
|
||||
var authenticationId = authenticationIds[ai]
|
||||
var authenticationManifest = plugins[authenticationId]
|
||||
var stillAuthenticationService = authenticationManifest
|
||||
&& Array.isArray(authenticationManifest.kinds)
|
||||
&& authenticationManifest.kinds.indexOf("service") !== -1
|
||||
&& authenticationManifest.entryPoints
|
||||
&& authenticationManifest.entryPoints.service
|
||||
if (stillAuthenticationService && pluginRegistry.isEnabled(authenticationId)
|
||||
&& shell.isAuthenticationService(authenticationManifest, authenticationId)) continue
|
||||
AuthServiceStore.destroy(authenticationId)
|
||||
}
|
||||
}
|
||||
|
||||
function serviceKeepLoaded(pluginId) {
|
||||
@@ -378,11 +1030,33 @@ ShellRoot {
|
||||
if (inst && typeof inst.destroy === "function") inst.destroy()
|
||||
}
|
||||
_services = next
|
||||
var authenticationIds = AuthServiceStore.ids()
|
||||
for (var ai = 0; ai < authenticationIds.length; ai++) {
|
||||
var authenticationId = authenticationIds[ai]
|
||||
if (!serviceKeepLoaded(authenticationId))
|
||||
AuthServiceStore.destroy(authenticationId)
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: shell.pluginRegistry
|
||||
function onPluginsChanged() { if (!shell.pluginReloading) shell._syncServices() }
|
||||
function onPluginsChanged() {
|
||||
shell.syncPluginApis()
|
||||
if (!shell.pluginReloading) shell._syncServices()
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: shell.barWidgetRegistry
|
||||
function onChanged() { shell.syncPluginApis() }
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: shell.appLibrary
|
||||
function onAppsChanged() {
|
||||
for (var id in shell._pluginAppLibraryApis)
|
||||
shell._pluginAppLibraryApis[id].appsChanged()
|
||||
}
|
||||
}
|
||||
|
||||
// Writes inline settings to a bar layout entry or top-level plugin entry in
|
||||
@@ -654,10 +1328,10 @@ ShellRoot {
|
||||
onLoaded: {
|
||||
if (!item) return
|
||||
if ("omarchyPath" in item) item.omarchyPath = shell.omarchyPath
|
||||
if ("shell" in item) item.shell = shell
|
||||
if ("manifest" in item) item.manifest = panelEntry.manifest
|
||||
if ("barWidgetRegistry" in item) item.barWidgetRegistry = shell.barWidgetRegistry
|
||||
if ("pluginRegistry" in item) item.pluginRegistry = shell.pluginRegistry
|
||||
if ("shell" in item) item.shell = shell.pluginShellFor(panelEntry.manifest)
|
||||
if ("manifest" in item) item.manifest = shell.publicPluginManifest(panelEntry.manifest)
|
||||
if ("barWidgetRegistry" in item) item.barWidgetRegistry = shell.pluginBarWidgetRegistryFor(panelEntry.manifest)
|
||||
if ("pluginRegistry" in item) item.pluginRegistry = shell.pluginRegistryFor(panelEntry.manifest)
|
||||
// Plugins that pair a panel UI with a service entry read shared
|
||||
// state off `service`. Hand them the matching singleton if one was
|
||||
// loaded.
|
||||
@@ -723,7 +1397,8 @@ ShellRoot {
|
||||
schema: meta.schema || [],
|
||||
pluginId: manifest.id,
|
||||
sourceDir: manifest.__sourceDir || "",
|
||||
source: "plugin"
|
||||
source: "plugin",
|
||||
firstParty: !!manifest.__isFirstParty
|
||||
}
|
||||
|
||||
// A load already in flight for this URL registers itself when it
|
||||
|
||||
@@ -55,6 +55,14 @@ const entries = [
|
||||
}
|
||||
]
|
||||
|
||||
// Keep the packaged launcher when upstream rebuilds register their own entry.
|
||||
const configuredHides = new Set(fs.readFileSync(path.join(root, 'default/omarchy/launcher.hides'), 'utf8').trim().split(/\n/))
|
||||
const hermesEntries = [{ name: 'Hermes', id: 'hermes' }, { name: 'Hermes', id: 'hermes-desktop' }]
|
||||
for (const query of ['', 'hermes']) {
|
||||
const visible = search.sortedEntries(hermesEntries, query, entry => configuredHides.has(entry.id))
|
||||
assertDeepEqual(visible.map(row => row.entry.id), ['hermes-desktop'], 'only the packaged Hermes launcher is visible')
|
||||
}
|
||||
|
||||
const contactMatches = search.sortedEntries(entries, 'contact').map(row => search.entryName(row.entry))
|
||||
assertDeepEqual(contactMatches, ['Google Contacts'], 'contact search only returns direct contact matches')
|
||||
|
||||
|
||||
@@ -151,6 +151,7 @@ package_defaults = [
|
||||
("default/systemd/user/omarchy-fcitx5.service", "/usr/lib/systemd/user/omarchy-fcitx5.service", "systemd/user/omarchy-fcitx5.service"),
|
||||
("default/systemd/user/omarchy-crash-watch.service", "/usr/lib/systemd/user/omarchy-crash-watch.service", "systemd/user/omarchy-crash-watch.service"),
|
||||
("default/systemd/zram-generator.conf.d/90-omarchy.conf", "/usr/lib/systemd/zram-generator.conf.d/90-omarchy.conf", "systemd/zram-generator.conf.d/90-omarchy.conf"),
|
||||
("default/systemd/system/plocate-updatedb.service.d/10-omarchy.conf", "/usr/lib/systemd/system/plocate-updatedb.service.d/10-omarchy.conf", "systemd/system/plocate-updatedb.service.d/10-omarchy.conf"),
|
||||
("default/fonts/omarchy/omarchy.ttf", "/usr/share/fonts/omarchy/omarchy.ttf", "omarchy.ttf"),
|
||||
("default/snapper/root", "/etc/snapper/config-templates/omarchy", "snapper/root"),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
from kitty.config import load_config
|
||||
from kitty.options.utils import parse_map
|
||||
|
||||
root = Path(os.environ['ROOT'])
|
||||
system = root / 'etc/xdg/kitty/kitty.conf'
|
||||
template = root / 'config/kitty/kitty.conf'
|
||||
legacy = root / 'test/shell.d/fixtures/kitty/legacy.conf'
|
||||
active_lines = [line for line in template.read_text().splitlines() if line and not line.startswith('#')]
|
||||
assert active_lines == ['include ~/.local/state/omarchy/current/theme/kitty.conf']
|
||||
|
||||
with TemporaryDirectory() as tmp:
|
||||
user = Path(tmp) / 'kitty.conf'
|
||||
# Use a local theme to avoid depending on the developer's generated state.
|
||||
theme = Path(tmp) / 'theme.conf'
|
||||
theme.write_text('background #123456\n')
|
||||
themed = f'include {theme}\n'
|
||||
user.write_text(themed)
|
||||
errors = []
|
||||
opts = load_config(str(system), str(user), accumulate_bad_lines=errors)
|
||||
assert not errors, errors
|
||||
assert opts.allow_remote_control == 'socket-only'
|
||||
assert opts.listen_on == 'unix:${XDG_RUNTIME_DIR}/omarchy-kitty-{kitty_pid}'
|
||||
|
||||
old = Path(tmp) / 'legacy.conf'
|
||||
old.write_text(legacy.read_text().replace(active_lines[0], themed.strip()))
|
||||
before = load_config(str(old), accumulate_bad_lines=errors)
|
||||
# Moving defaults must preserve appearance and behavior except remote control.
|
||||
for key in ('font_family', 'bold_italic_font', 'font_size', 'window_padding_width',
|
||||
'hide_window_decorations', 'confirm_os_window_close', 'cursor_shape',
|
||||
'cursor_blink_interval', 'shell_integration', 'enable_audio_bell',
|
||||
'tab_bar_edge', 'tab_bar_style', 'tab_powerline_style',
|
||||
'tab_title_template', 'background'):
|
||||
assert getattr(opts, key) == getattr(before, key), key
|
||||
|
||||
def binding(options, shortcut):
|
||||
trigger = next(parse_map(shortcut)).trigger
|
||||
return [entry.definition for entry in options.keyboard_modes[''].keymap.get(trigger, [])]
|
||||
|
||||
for shortcut in ('ctrl+insert', 'shift+insert', 'shift+enter', 'alt+shift+enter'):
|
||||
assert binding(opts, shortcut) == binding(before, shortcut), shortcut
|
||||
|
||||
user.write_text(themed + 'font_size 15\nmap ctrl+insert\nmap shift+insert copy_to_clipboard\n')
|
||||
opts = load_config(str(system), str(user), accumulate_bad_lines=errors)
|
||||
assert opts.font_size == 15
|
||||
assert not any(binding(opts, 'ctrl+insert'))
|
||||
assert binding(opts, 'shift+insert')[-1] == 'copy_to_clipboard'
|
||||
|
||||
user.write_text(themed + 'clear_all_shortcuts yes\nmap f1 new_window\n')
|
||||
opts = load_config(str(system), str(user), accumulate_bad_lines=errors)
|
||||
assert len(opts.keyboard_modes[''].keymap) == 1
|
||||
assert binding(opts, 'f1') == ['new_window']
|
||||
assert not errors, errors
|
||||
|
||||
print('ok - Kitty loads defaults and theme, preserves appearance, and supports user overrides and unmapping')
|
||||
@@ -0,0 +1,35 @@
|
||||
include ~/.local/state/omarchy/current/theme/kitty.conf
|
||||
|
||||
# Font
|
||||
font_family JetBrainsMono Nerd Font
|
||||
bold_italic_font auto
|
||||
font_size 9.0
|
||||
|
||||
# Window
|
||||
window_padding_width 14
|
||||
hide_window_decorations yes
|
||||
confirm_os_window_close 0
|
||||
|
||||
# Keybindings
|
||||
map ctrl+insert copy_to_clipboard
|
||||
map shift+insert paste_from_clipboard
|
||||
# Send Shift+Enter as CSI-u so TUIs can distinguish it from Enter.
|
||||
map shift+enter send_text all \e[13;2u
|
||||
# Kitty legacy encoding sends Alt+Shift+Enter the same as Alt+Enter; send CSI-u so tmux can match M-S-Enter.
|
||||
map alt+shift+enter send_text all \e[13;4u
|
||||
|
||||
# Allow remote access
|
||||
allow_remote_control yes
|
||||
listen_on unix:${XDG_RUNTIME_DIR}/omarchy-kitty-{kitty_pid}
|
||||
|
||||
# Aesthetics
|
||||
cursor_shape block
|
||||
cursor_blink_interval 0
|
||||
shell_integration no-cursor
|
||||
enable_audio_bell no
|
||||
|
||||
# Minimal Tab bar styling
|
||||
tab_bar_edge bottom
|
||||
tab_bar_style powerline
|
||||
tab_powerline_style slanted
|
||||
tab_title_template {title}{' :{}:'.format(num_windows) if num_windows > 1 else ''}
|
||||
@@ -0,0 +1,20 @@
|
||||
import QtQuick
|
||||
import "services/AuthServiceStore.js" as AuthServiceStore
|
||||
|
||||
QtObject {
|
||||
function retain(id, service) {
|
||||
AuthServiceStore.put(id, service)
|
||||
}
|
||||
|
||||
function has(id) {
|
||||
return AuthServiceStore.has(id)
|
||||
}
|
||||
|
||||
function isTrusted(id) {
|
||||
return AuthServiceStore.isTrusted(id)
|
||||
}
|
||||
|
||||
function updateManifest(id, manifest) {
|
||||
AuthServiceStore.updateManifest(id, manifest)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import QtQuick
|
||||
import "services/AuthServiceStore.js" as AuthServiceStore
|
||||
|
||||
QtObject {
|
||||
function has(id) {
|
||||
return AuthServiceStore.has(id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import "services"
|
||||
|
||||
ShellRoot {
|
||||
id: root
|
||||
|
||||
property var calls: []
|
||||
property QtObject ownService: QtObject {
|
||||
property string marker: "own"
|
||||
property var manifest: null
|
||||
}
|
||||
|
||||
AuthStoreOwner { id: authStoreOwner }
|
||||
AuthStoreReader { id: authStoreReader }
|
||||
|
||||
Component {
|
||||
id: apiComponent
|
||||
PluginShellApi { }
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: resultFile
|
||||
path: Quickshell.env("OMARCHY_QML_TEST_RESULT")
|
||||
atomicWrites: true
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
var caller = "example.safe"
|
||||
authStoreOwner.retain("omarchy.lock", root.ownService)
|
||||
authStoreOwner.updateManifest("omarchy.lock", { version: "kept" })
|
||||
var api = apiComponent.createObject(null, {
|
||||
pluginId: caller,
|
||||
idleConfig: { screensaver: 60, lock: 120 },
|
||||
_serviceLookup: function(requestedId) {
|
||||
return requestedId === caller ? root.ownService : null
|
||||
},
|
||||
_summon: function(requestedId) {
|
||||
if (requestedId !== caller) return false
|
||||
root.calls = root.calls.concat(["summon"])
|
||||
return true
|
||||
},
|
||||
_hide: function(requestedId) {
|
||||
if (requestedId !== caller) return false
|
||||
root.calls = root.calls.concat(["hide"])
|
||||
return true
|
||||
},
|
||||
_toggle: function(requestedId) {
|
||||
if (requestedId !== caller) return false
|
||||
root.calls = root.calls.concat(["toggle"])
|
||||
return true
|
||||
},
|
||||
_isOpen: function(requestedId) { return requestedId === caller },
|
||||
_updateSettings: function(requestedId) {
|
||||
if (requestedId !== caller) return false
|
||||
root.calls = root.calls.concat(["settings"])
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
var own = api.serviceFor(caller)
|
||||
var result = {
|
||||
detached: api.parent === undefined || api.parent === null,
|
||||
ownService: own && own.marker === "own",
|
||||
foreignService: api.serviceFor("omarchy.lock") === null,
|
||||
firstPartyService: api.firstPartyServiceFor("omarchy.polkit") === null,
|
||||
ownSummon: api.summon(caller, "{}") === true,
|
||||
foreignSummon: api.summon("omarchy.lock", "{}") === false,
|
||||
ownHide: api.hide(caller) === true,
|
||||
foreignHide: api.hide("omarchy.lock") === false,
|
||||
ownToggle: api.toggle(caller, "{}") === true,
|
||||
foreignToggle: api.toggle("omarchy.lock", "{}") === false,
|
||||
ownOpen: api.isPluginOpen(caller) === true,
|
||||
foreignOpen: api.isPluginOpen("omarchy.lock") === false,
|
||||
ownSettings: api.updateEntryInline(caller, {}) === true,
|
||||
foreignSettings: api.updateEntryInline("omarchy.lock", {}) === false,
|
||||
detachedIdleConfig: api.idleConfig.screensaver === 60 && api.idleConfig.lock === 120,
|
||||
authStoreOwnerRetains: authStoreOwner.has("omarchy.lock") === true,
|
||||
authStoreOwnerRemembersTrust: authStoreOwner.isTrusted("omarchy.lock") === true,
|
||||
authStoreOwnerUpdatesManifest: root.ownService.manifest
|
||||
&& root.ownService.manifest.version === "kept",
|
||||
authStoreImportIsolated: authStoreReader.has("omarchy.lock") === false,
|
||||
noGenericPluginShellFactory: typeof api.pluginShellForId !== "function",
|
||||
calls: root.calls
|
||||
}
|
||||
result.ok = Object.keys(result).every(function(key) {
|
||||
return key === "ok" || key === "calls" || result[key] === true
|
||||
}) && JSON.stringify(result.calls) === JSON.stringify(["summon", "hide", "toggle", "settings"])
|
||||
resultFile.setText(JSON.stringify(result))
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,9 @@ ShellRoot {
|
||||
scan += block("firstparty", "/first/bar", manifest("omarchy.bar", ["bar"], { bar: "Bar.qml" }))
|
||||
scan += block("firstparty", "/first/panels/grouped", manifest("omarchy.grouped-panel", ["panel"], { panel: "Panel.qml" }))
|
||||
scan += block("firstparty", "/first/hybrid", manifest("omarchy.hybrid", ["menu", "bar-widget"], { menu: "Menu.qml", barWidget: "Widget.qml" }))
|
||||
var futureAuth = manifest("omarchy.future-auth", ["service"], { service: "Service.qml" })
|
||||
futureAuth.omarchy = { capabilities: ["authentication"] }
|
||||
scan += block("firstparty", "/first/future-auth", futureAuth)
|
||||
scan += block("thirdparty", "/third/panel", manifest("third.panel", ["panel"], { panel: "Panel.qml" }))
|
||||
scan += block("thirdparty", "/third/widget", manifest("third.widget", ["bar-widget"], { barWidget: "Widget.qml" }, { defaultSection: "left" }))
|
||||
scan += block("thirdparty", "/third/center-widget", manifest("third.center-widget", ["bar-widget"], { barWidget: "Widget.qml" }))
|
||||
@@ -103,6 +106,12 @@ ShellRoot {
|
||||
localBar.omarchy = { clonedFrom: "omarchy.bar" }
|
||||
scan += block("thirdparty", "/third/local-bar", localBar)
|
||||
scan += block("thirdparty", "/third/bar", manifest("third.bar", ["bar"], { bar: "Bar.qml" }))
|
||||
var localFutureAuth = manifest("local.future-auth", ["service"], { service: "Service.qml" })
|
||||
localFutureAuth.omarchy = { clonedFrom: "omarchy.future-auth" }
|
||||
scan += block("thirdparty", "/third/local-future-auth", localFutureAuth)
|
||||
var spoofedAuth = manifest("third.spoofed-auth", ["service"], { service: "Service.qml" })
|
||||
spoofedAuth.omarchy = { capabilities: ["authentication"] }
|
||||
scan += block("thirdparty", "/third/spoofed-auth", spoofedAuth)
|
||||
scan += block("thirdparty", "/third/shadow", manifest("omarchy.first-widget", ["panel"], { panel: "Panel.qml" }))
|
||||
scan += block("thirdparty", "/third/reserved", manifest("omarchy.reserved", ["panel"], { panel: "Panel.qml" }))
|
||||
scan += block("thirdparty", "/third/unsafe", manifest("third.unsafe", ["panel"], { panel: "../Panel.qml" }))
|
||||
@@ -116,22 +125,28 @@ ShellRoot {
|
||||
root.assertDeepEqual(pluginIds(), [
|
||||
"local.bar",
|
||||
"local.first-widget",
|
||||
"local.future-auth",
|
||||
"local.grouped-panel",
|
||||
"local.hybrid",
|
||||
"local.weather",
|
||||
"omarchy.bar",
|
||||
"omarchy.first-widget",
|
||||
"omarchy.future-auth",
|
||||
"omarchy.grouped-panel",
|
||||
"omarchy.hybrid",
|
||||
"third.bar",
|
||||
"third.center-widget",
|
||||
"third.panel",
|
||||
"third.right-widget",
|
||||
"third.spoofed-auth",
|
||||
"third.widget"
|
||||
], "registry merges valid first-party and third-party manifests")
|
||||
|
||||
root.assertTrue(registry.installedPlugins["omarchy.first-widget"].__isFirstParty === true, "first-party manifests are stamped")
|
||||
root.assertTrue(registry.installedPlugins["third.panel"].__isFirstParty === false, "third-party manifests are stamped")
|
||||
root.assertDeepEqual(registry.installedPlugins["omarchy.future-auth"].__hostCapabilities, ["authentication"], "trusted manifests stamp authentication capability")
|
||||
root.assertDeepEqual(registry.installedPlugins["local.future-auth"].__hostCapabilities, ["authentication"], "clones inherit trusted host capabilities")
|
||||
root.assertDeepEqual(registry.installedPlugins["third.spoofed-auth"].__hostCapabilities, [], "third-party manifests cannot self-grant host capabilities")
|
||||
root.assertEqual(registry.installedPlugins["omarchy.grouped-panel"].__sourceDir, "/first/panels/grouped", "grouped plugin source paths are preserved")
|
||||
root.assertEqual(registry.entryPointUrl(registry.installedPlugins["third.panel"], "panel"), "file:///third/panel/Panel.qml", "entryPointUrl resolves plugin-relative paths")
|
||||
root.assertEqual(registry.entryPointUrl(registry.installedPlugins["third.widget"], "barWidget"), "file:///third/widget/Widget.qml", "entryPointUrl resolves bar widget paths")
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
for command in git jq python3; do require_command "$command"; done
|
||||
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf -- "$test_tmp"' EXIT
|
||||
export OMARCHY_TEST_ROOT="$test_tmp"
|
||||
mkdir -p "$test_tmp/bin" "$test_tmp/package/resources" "$test_tmp/share" "$test_tmp/seed"
|
||||
|
||||
# Real Git exercises patch checks and preservation; all package, desktop and
|
||||
# service commands are mocks. No command reaches the live user installation.
|
||||
git -C "$test_tmp/seed" init -q -b main
|
||||
printf 'venv/\n.hermes-bootstrap-complete\napps/desktop/release/\n__pycache__/\n' >"$test_tmp/seed/.gitignore"
|
||||
printf 'before\n' >"$test_tmp/seed/runtime.txt"
|
||||
mkdir -p "$test_tmp/seed/apps/desktop/src"
|
||||
printf 'desktop source\n' >"$test_tmp/seed/apps/desktop/src/main.js"
|
||||
mkdir -p "$test_tmp/seed/hermes_cli"
|
||||
cat >"$test_tmp/seed/hermes_cli/main.py" <<'PY'
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
def _write_desktop_build_stamp(project_root, *, source_mode):
|
||||
home = Path(os.environ['HERMES_HOME'])
|
||||
assert project_root == home / 'hermes-agent'
|
||||
assert source_mode is False
|
||||
assert (project_root / 'apps/desktop/release/linux-unpacked/resources/app.asar').is_file()
|
||||
(home / 'desktop-build-stamp.json').write_text('upstream build stamp')
|
||||
with (Path(os.environ['OMARCHY_TEST_ROOT']) / 'events').open('a') as log:
|
||||
log.write('build-stamp\n')
|
||||
PY
|
||||
git -C "$test_tmp/seed" add .
|
||||
git -C "$test_tmp/seed" -c user.name=Test -c user.email=test@example.invalid commit -qm fixture
|
||||
release_commit=$(git -C "$test_tmp/seed" rev-parse HEAD)
|
||||
printf 'after\n' >"$test_tmp/seed/runtime.txt"
|
||||
git -C "$test_tmp/seed" diff >"$test_tmp/share/runtime.patch"
|
||||
printf 'before\n' >"$test_tmp/seed/runtime.txt"
|
||||
printf 'newer desktop source\n' >"$test_tmp/seed/apps/desktop/src/main.js"
|
||||
git -C "$test_tmp/seed" add apps/desktop/src/main.js
|
||||
git -C "$test_tmp/seed" -c user.name=Test -c user.email=test@example.invalid commit -qm newer-main
|
||||
origin_commit=$(git -C "$test_tmp/seed" rev-parse HEAD)
|
||||
export OMARCHY_TEST_RELEASE_COMMIT="$release_commit"
|
||||
printf '{"branch":"main","commit":"%s"}\n' "$release_commit" >"$test_tmp/package/resources/install-stamp.json"
|
||||
printf 'packaged app\n' >"$test_tmp/package/resources/app.asar"
|
||||
printf '#!/bin/bash\nexit 0\n' >"$test_tmp/package/Hermes"
|
||||
touch "$test_tmp/package/chrome-sandbox"
|
||||
chmod 755 "$test_tmp/package/Hermes"
|
||||
chmod 4755 "$test_tmp/package/chrome-sandbox"
|
||||
|
||||
cat >"$test_tmp/share/install.sh" <<'MOCK'
|
||||
#!/bin/bash
|
||||
set -e
|
||||
printf 'bootstrap\n' >>"$OMARCHY_TEST_ROOT/events"
|
||||
printf '%s\n' "$@" >"$OMARCHY_TEST_ROOT/install-args"
|
||||
[[ ${OMARCHY_TEST_INSTALL_FAIL:-0} != 1 ]] || exit 7
|
||||
commit=$OMARCHY_TEST_RELEASE_COMMIT
|
||||
force=false
|
||||
while (( $# )); do
|
||||
case "$1" in
|
||||
--dir) runtime=$2; shift ;;
|
||||
--commit) commit=$2; shift ;;
|
||||
--force-commit) force=true ;;
|
||||
--hermes-home) [[ $2 == "$HERMES_HOME" ]] ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
mkdir -p -- "${runtime%/*}"
|
||||
if [[ ! -d $runtime ]]; then
|
||||
git clone -q --depth 1 "file://$OMARCHY_TEST_ROOT/seed" "$runtime"
|
||||
else
|
||||
git -C "$runtime" checkout -q main
|
||||
git -C "$runtime" pull -q --ff-only origin main
|
||||
fi
|
||||
git -C "$runtime" fetch -q origin "$commit"
|
||||
if [[ $force == true ]] || ! git -C "$runtime" merge-base --is-ancestor "$commit" HEAD; then
|
||||
git -C "$runtime" checkout -q --detach "$commit"
|
||||
fi
|
||||
mkdir -p "$runtime/venv/bin"
|
||||
git -C "$runtime" rev-parse HEAD >"$runtime/venv/dependency-commit"
|
||||
printf '#!/bin/bash\nexit 0\n' >"$runtime/venv/bin/hermes"
|
||||
chmod +x "$runtime/venv/bin/hermes"
|
||||
printf '#!/bin/bash\nexec /usr/bin/python3 "$@"\n' >"$runtime/venv/bin/python"
|
||||
chmod +x "$runtime/venv/bin/python"
|
||||
[[ ${OMARCHY_TEST_NO_MARKER:-0} == 1 ]] || touch "$runtime/.hermes-bootstrap-complete"
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
for command in hermes hermes-agent hermes-acp; do
|
||||
rm -f "$HOME/.local/bin/$command"
|
||||
printf 'native runtime shim\n' >"$HOME/.local/bin/$command"
|
||||
done
|
||||
MOCK
|
||||
|
||||
cat >"$test_tmp/bin/omarchy-pkg-add" <<'MOCK'
|
||||
#!/bin/bash
|
||||
printf 'package %s\n' "$*" >>"$OMARCHY_TEST_ROOT/events"
|
||||
[[ ${OMARCHY_TEST_PACKAGE_FAIL:-0} != 1 ]]
|
||||
MOCK
|
||||
cat >"$test_tmp/bin/git" <<'MOCK'
|
||||
#!/bin/bash
|
||||
if [[ ${OMARCHY_TEST_FETCH_FAIL:-0} == 1 && " $* " == *" --unshallow "* ]]; then exit 8; fi
|
||||
exec /usr/bin/git "$@"
|
||||
MOCK
|
||||
cat >"$test_tmp/bin/omarchy-install-hermes-cli" <<'MOCK'
|
||||
#!/bin/bash
|
||||
printf 'handoff\n' >>"$OMARCHY_TEST_ROOT/events"
|
||||
exit 1
|
||||
MOCK
|
||||
cat >"$test_tmp/bin/setsid" <<'MOCK'
|
||||
#!/bin/bash
|
||||
exec "$@"
|
||||
MOCK
|
||||
cat >"$test_tmp/bin/cp" <<'MOCK'
|
||||
#!/bin/bash
|
||||
if [[ ${OMARCHY_TEST_COPY_FAIL:-0} == 1 ]]; then
|
||||
touch "${@: -1}/partial-copy"
|
||||
exit 9
|
||||
fi
|
||||
exec /usr/bin/cp "$@"
|
||||
MOCK
|
||||
cat >"$test_tmp/bin/mv" <<'MOCK'
|
||||
#!/bin/bash
|
||||
if [[ ${OMARCHY_TEST_COPY_RACE:-0} == 1 && $1 == -T ]]; then
|
||||
mkdir -p "${@: -1}"
|
||||
fi
|
||||
exec /usr/bin/mv "$@"
|
||||
MOCK
|
||||
cat >"$test_tmp/bin/uwsm-app" <<'MOCK'
|
||||
#!/bin/bash
|
||||
[[ $1 == -- ]] || exit 1
|
||||
shift
|
||||
exec "$@"
|
||||
MOCK
|
||||
cat >"$test_tmp/bin/hermes-desktop" <<'MOCK'
|
||||
#!/bin/bash
|
||||
sleep 0.05
|
||||
native="$HERMES_HOME/hermes-agent/apps/desktop/release/linux-unpacked"
|
||||
if [[ -x $native/Hermes && -f $native/resources/app.asar ]]; then
|
||||
printf 'launch\n' >>"$OMARCHY_TEST_ROOT/events"
|
||||
else
|
||||
printf 'launch-before-copy\n' >>"$OMARCHY_TEST_ROOT/events"
|
||||
fi
|
||||
MOCK
|
||||
cat >"$test_tmp/bin/systemctl" <<'MOCK'
|
||||
#!/bin/bash
|
||||
printf 'theme-stop\n' >>"$OMARCHY_TEST_ROOT/events"
|
||||
MOCK
|
||||
cat >"$test_tmp/bin/systemd-run" <<'MOCK'
|
||||
#!/bin/bash
|
||||
printf 'theme-start\n' >>"$OMARCHY_TEST_ROOT/events"
|
||||
# Join the mock asynchronous launch so every test owns its full lifetime.
|
||||
for (( attempt=0; attempt<100; attempt++ )); do
|
||||
if grep -q '^launch' "$OMARCHY_TEST_ROOT/events"; then exit 0; fi
|
||||
sleep 0.01
|
||||
done
|
||||
exit 1
|
||||
MOCK
|
||||
chmod +x "$test_tmp/bin/"*
|
||||
|
||||
# Substitute only system package paths in a scratch copy of the actual script.
|
||||
python3 - "$ROOT/bin/omarchy-install-ai-hermes" "$test_tmp" <<'PY'
|
||||
from pathlib import Path
|
||||
import sys
|
||||
source, scratch = Path(sys.argv[1]), Path(sys.argv[2])
|
||||
script = source.read_text()
|
||||
for original, replacement in {
|
||||
'/opt/hermes-desktop': str(scratch / 'package'),
|
||||
'/usr/share/hermes-desktop': str(scratch / 'share'),
|
||||
'/usr/bin/hermes-desktop': str(scratch / 'bin/hermes-desktop'),
|
||||
}.items():
|
||||
script = script.replace(original, replacement)
|
||||
(scratch / 'installer').write_text(script)
|
||||
PY
|
||||
|
||||
new_home() {
|
||||
test_home="$test_tmp/$1"
|
||||
hermes_home="$test_home/.hermes"
|
||||
runtime="$hermes_home/hermes-agent"
|
||||
native="$runtime/apps/desktop/release/linux-unpacked"
|
||||
mkdir -p "$test_home"
|
||||
: >"$test_tmp/events"
|
||||
}
|
||||
run_installer() {
|
||||
HOME="$test_home" HERMES_HOME="${OMARCHY_TEST_HOME:-$hermes_home}" PATH="$test_tmp/bin:$PATH" \
|
||||
bash "$test_tmp/installer" >"$test_tmp/output" 2>&1
|
||||
}
|
||||
assert_stopped() {
|
||||
if grep -Eq '^(launch|theme-|build-stamp)' "$test_tmp/events"; then fail "$1"; fi
|
||||
}
|
||||
|
||||
new_home fresh
|
||||
run_installer || fail "fresh setup succeeds" "$(cat "$test_tmp/output")"
|
||||
expected=$(printf '%s\n' --skip-setup --branch main --commit "$release_commit" --force-commit --dir "$runtime" --hermes-home "$hermes_home")
|
||||
[[ $(cat "$test_tmp/install-args") == "$expected" ]] || fail "upstream installer receives the pinned main arguments"
|
||||
[[ $(head -3 "$test_tmp/events") == $'package hermes-desktop\nhandoff\nbootstrap' ]] || fail "package and CLI handoff precede runtime bootstrap"
|
||||
grep -qx launch "$test_tmp/events" || fail "native app is copied before launch"
|
||||
[[ $(sed -n '4p' "$test_tmp/events") == build-stamp ]] || fail "upstream build stamp follows the app copy and precedes launch"
|
||||
[[ $(cat "$hermes_home/desktop-build-stamp.json") == 'upstream build stamp' ]] || fail "the upstream helper records the completed packaged build"
|
||||
[[ $(cat "$runtime/runtime.txt") == after ]] || fail "the release runtime receives its patch"
|
||||
[[ $(stat -c %a "$native/chrome-sandbox") == 755 ]] || fail "the user sandbox is not setuid"
|
||||
[[ $(stat -c %a "$test_tmp/package/chrome-sandbox") == 4755 ]] || fail "package sandbox permissions remain unchanged"
|
||||
[[ $(git -C "$runtime" symbolic-ref --short HEAD) == main && $(git -C "$runtime" rev-parse main) == "$release_commit" ]] || fail "main starts at the release rather than the clone tip"
|
||||
[[ $(cat "$runtime/venv/dependency-commit") == "$release_commit" ]] || fail "dependencies are installed for the release"
|
||||
[[ $(git -C "$runtime" rev-parse --is-shallow-repository) == false ]] || fail "first update has connected history"
|
||||
# Reproduce the updater's checkout/count/pull sequence while origin stays put.
|
||||
git clone -q "$runtime" "$test_tmp/first-update"
|
||||
git -C "$test_tmp/first-update" remote set-url origin "file://$test_tmp/seed"
|
||||
git -C "$test_tmp/first-update" fetch -q origin main
|
||||
git -C "$test_tmp/first-update" checkout -q main
|
||||
[[ $(git -C "$test_tmp/first-update" rev-list HEAD..origin/main --count) == 1 ]] || fail "first update detects work even when origin has not moved since install"
|
||||
git -C "$test_tmp/first-update" pull -q --ff-only origin main
|
||||
[[ $(git -C "$test_tmp/first-update" rev-parse HEAD) == "$origin_commit" ]] || fail "first update fast-forwards to origin"
|
||||
pass "fresh setup pins main, patches the matching runtime and copies the complete app before launch"
|
||||
|
||||
printf 'user app\n' >"$native/resources/app.asar"
|
||||
printf 'user build stamp\n' >"$hermes_home/desktop-build-stamp.json"
|
||||
: >"$test_tmp/events"
|
||||
run_installer || fail "repeat setup succeeds" "$(cat "$test_tmp/output")"
|
||||
! grep -qx bootstrap "$test_tmp/events" || fail "repeat setup does not bootstrap again"
|
||||
! grep -qx build-stamp "$test_tmp/events" || fail "existing app never reruns the build stamp writer"
|
||||
[[ $(cat "$hermes_home/desktop-build-stamp.json") == 'user build stamp' ]] || fail "existing native build stamp remains unchanged"
|
||||
[[ $(cat "$native/resources/app.asar") == 'user app' ]] || fail "existing native app remains unchanged"
|
||||
pass "repeat setup accepts the applied patch and preserves the existing native app"
|
||||
|
||||
# Advancing the runtime must never reinstall the release or reapply its patch.
|
||||
printf 'new main\n' >"$runtime/runtime.txt"
|
||||
git -C "$runtime" add runtime.txt
|
||||
git -C "$runtime" -c user.name=Test -c user.email=test@example.invalid commit -qm update
|
||||
: >"$test_tmp/events"
|
||||
run_installer || fail "a complete updated runtime and native app are reused"
|
||||
[[ $(cat "$runtime/runtime.txt") == 'new main' ]] || fail "updated runtime is not release-patched"
|
||||
mv "$native" "$test_tmp/saved-native"
|
||||
: >"$test_tmp/events"
|
||||
run_installer && fail "a newer runtime cannot receive an older native app"
|
||||
[[ ! -e $native ]] || fail "no mismatched native app was copied"
|
||||
grep -q 'hermes desktop --build-only' "$test_tmp/output" || fail "missing newer native app has actionable guidance"
|
||||
assert_stopped "a missing updated app prevents launch and theme setup"
|
||||
pass "updated runtimes are preserved and never seeded with the old packaged app"
|
||||
|
||||
new_home dirty-desktop
|
||||
HOME="$test_home" HERMES_HOME="$hermes_home" bash "$test_tmp/share/install.sh" --dir "$runtime" --hermes-home "$hermes_home"
|
||||
printf 'local desktop edit\n' >"$runtime/apps/desktop/src/main.js"
|
||||
: >"$test_tmp/events"
|
||||
run_installer && fail "modified desktop sources cannot be certified as the packaged build"
|
||||
[[ ! -e $native && ! -e $hermes_home/desktop-build-stamp.json ]] || fail "modified desktop sources receive neither packaged app nor build stamp"
|
||||
[[ $(cat "$runtime/apps/desktop/src/main.js") == 'local desktop edit' ]] || fail "desktop source edits are preserved"
|
||||
grep -q 'hermes desktop --build-only' "$test_tmp/output" || fail "modified desktop sources have build guidance"
|
||||
assert_stopped "modified desktop sources prevent stamping, launch and theme setup"
|
||||
pass "a matching commit with modified desktop sources is preserved without seeding or stamping"
|
||||
|
||||
for failure in package install marker; do
|
||||
new_home "$failure-failure"
|
||||
case "$failure" in
|
||||
package) OMARCHY_TEST_PACKAGE_FAIL=1 run_installer && fail "package failure stops setup" ;;
|
||||
install) OMARCHY_TEST_INSTALL_FAIL=1 run_installer && fail "installer failure stops setup" ;;
|
||||
marker) OMARCHY_TEST_NO_MARKER=1 run_installer && fail "missing marker stops setup" ;;
|
||||
esac
|
||||
[[ ! -e $native ]] || fail "failed setup does not seed the app"
|
||||
assert_stopped "failed setup prevents launch and theme setup"
|
||||
done
|
||||
pass "package, upstream installer and readiness failures stop before launch"
|
||||
|
||||
for failure in copy race; do
|
||||
new_home "$failure-failure"
|
||||
if [[ $failure == "copy" ]]; then
|
||||
OMARCHY_TEST_COPY_FAIL=1 run_installer && fail "copy failure stops setup"
|
||||
[[ ! -e $native ]] || fail "partial copy is never published"
|
||||
else
|
||||
OMARCHY_TEST_COPY_RACE=1 run_installer && fail "concurrent native app stops publication"
|
||||
[[ -d $native && -z $(ls -A "$native") ]] || fail "concurrent empty app directory is preserved"
|
||||
fi
|
||||
[[ -z $(find "${native%/*}" -maxdepth 1 -name '.linux-unpacked.*' -print) ]] || fail "owned staging directory is cleaned up"
|
||||
assert_stopped "publication failure prevents launch"
|
||||
done
|
||||
pass "failed copies and concurrent app creation preserve existing work and clean only staging"
|
||||
|
||||
new_home incomplete-native
|
||||
run_installer || fail "incomplete native fixture sets up"
|
||||
rm "$native/resources/app.asar"
|
||||
: >"$test_tmp/events"
|
||||
run_installer && fail "incomplete existing app requires repair"
|
||||
[[ ! -e $native/resources/app.asar ]] || fail "incomplete existing app is not overwritten"
|
||||
assert_stopped "incomplete native app prevents launch"
|
||||
pass "an incomplete existing native app is preserved"
|
||||
|
||||
new_home patch-conflict
|
||||
run_installer || fail "patch conflict fixture sets up"
|
||||
printf 'local edit\n' >"$runtime/runtime.txt"
|
||||
: >"$test_tmp/events"
|
||||
run_installer && fail "unexpected patch conflict stops setup"
|
||||
[[ $(cat "$runtime/runtime.txt") == 'local edit' ]] || fail "conflicting runtime changes are preserved"
|
||||
assert_stopped "patch conflict prevents launch"
|
||||
rm "$runtime/.hermes-bootstrap-complete"
|
||||
: >"$test_tmp/events"
|
||||
run_installer && fail "incomplete modified runtime cannot be reset by upstream installer"
|
||||
! grep -qx bootstrap "$test_tmp/events" || fail "modified runtime never reaches upstream installer"
|
||||
pass "patch conflicts and incomplete modified runtimes retain local changes and stop safely"
|
||||
|
||||
new_home full-history-retry
|
||||
git clone -q "$test_tmp/seed" "$runtime"
|
||||
git -C "$runtime" checkout -q --detach "$release_commit"
|
||||
run_installer || fail "clean incomplete full-history release checkout is repaired" "$(cat "$test_tmp/output")"
|
||||
[[ $(cat "$runtime/venv/dependency-commit") == "$release_commit" ]] || fail "full-history retry pins before installing dependencies"
|
||||
[[ $(git -C "$runtime" rev-parse HEAD) == "$release_commit" && -f $native/resources/app.asar ]] || fail "full-history retry seeds the matching release"
|
||||
pass "full-history retries force the guarded release pin before dependency setup"
|
||||
|
||||
new_home local-main
|
||||
git clone -q "$test_tmp/seed" "$runtime"
|
||||
printf 'local branch work\n' >"$runtime/keep"
|
||||
git -C "$runtime" add keep
|
||||
git -C "$runtime" -c user.name=Test -c user.email=test@example.invalid commit -qm local-work
|
||||
local_main=$(git -C "$runtime" rev-parse main)
|
||||
git -C "$runtime" checkout -q --detach "$release_commit"
|
||||
run_installer && fail "local main commits cannot be reset by upstream installation"
|
||||
! grep -qx bootstrap "$test_tmp/events" || fail "local main is checked before upstream installer"
|
||||
[[ $(git -C "$runtime" rev-parse main) == "$local_main" ]] || fail "local main commit stays referenced"
|
||||
pass "detached release checkouts do not hide local main work from the installer guard"
|
||||
|
||||
new_home deepen-retry
|
||||
OMARCHY_TEST_FETCH_FAIL=1 run_installer && fail "history fetch failure stops setup"
|
||||
[[ ! -e $native ]] || fail "failed history fetch does not seed the app"
|
||||
assert_stopped "failed history fetch prevents launch"
|
||||
: >"$test_tmp/events"
|
||||
run_installer || fail "history fetch can be retried after runtime setup" "$(cat "$test_tmp/output")"
|
||||
! grep -qx bootstrap "$test_tmp/events" || fail "history retry does not repeat upstream installation"
|
||||
pass "a history fetch failure can be retried without reinstalling the ready runtime"
|
||||
|
||||
new_home existing-commands
|
||||
mkdir -p "$test_home/.local/bin"
|
||||
printf 'foreign wrapper\n' >"$test_home/.local/bin/hermes"
|
||||
printf 'symlink target\n' >"$test_home/target"
|
||||
ln -s "$test_home/target" "$test_home/.local/bin/hermes-agent"
|
||||
ln -s "$test_home/missing" "$test_home/.local/bin/hermes-acp"
|
||||
run_installer || fail "existing commands are preserved before upstream replaces them" "$(cat "$test_tmp/output")"
|
||||
backups=("$test_home/.local/bin/".hermes-before-desktop.*)
|
||||
[[ ${#backups[@]} == 1 && -d ${backups[0]} ]] || fail "one backup directory preserves existing command names"
|
||||
[[ $(cat "${backups[0]}/hermes") == 'foreign wrapper' ]] || fail "foreign wrapper bytes are saved"
|
||||
[[ $(readlink "${backups[0]}/hermes-agent") == "$test_home/target" && $(readlink "${backups[0]}/hermes-acp") == "$test_home/missing" ]] || fail "working and broken symlinks are saved as links"
|
||||
[[ $(cat "$test_home/target") == 'symlink target' ]] || fail "upstream does not overwrite the original symlink target"
|
||||
grep -qF "${backups[0]}" "$test_tmp/output" || fail "backup location is reported"
|
||||
pass "pre-existing command files and symlinks are backed up before replacement"
|
||||
|
||||
new_home old-package
|
||||
mv "$test_tmp/package/resources/install-stamp.json" "$test_tmp/saved-install-stamp.json"
|
||||
run_installer && fail "an old installed package cannot bootstrap"
|
||||
grep -q 'omarchy update' "$test_tmp/output" || fail "old package has actionable upgrade guidance"
|
||||
! grep -qx handoff "$test_tmp/events" || fail "old package is rejected before CLI handoff"
|
||||
! grep -qx bootstrap "$test_tmp/events" || fail "old package never reaches upstream installer"
|
||||
mv "$test_tmp/saved-install-stamp.json" "$test_tmp/package/resources/install-stamp.json"
|
||||
pass "old package fails with upgrade guidance before changing the runtime or CLI"
|
||||
|
||||
new_home custom-profile
|
||||
hermes_home="$test_home/custom home"
|
||||
runtime="$hermes_home/hermes-agent"
|
||||
OMARCHY_TEST_HOME="$hermes_home/PrOfIlEs/coder/../coder/" run_installer || fail "profile setup succeeds"
|
||||
[[ -x $runtime/apps/desktop/release/linux-unpacked/Hermes ]] || fail "profile uses the canonical root runtime"
|
||||
grep -qxF "$hermes_home" "$test_tmp/install-args" || fail "canonical custom home reaches upstream installer"
|
||||
pass "custom profile paths normalize to the shared Hermes home"
|
||||
@@ -11,6 +11,14 @@ mock_bin="$test_tmp/bin"
|
||||
test_home="$test_tmp/home"
|
||||
mkdir -p "$mock_bin"
|
||||
|
||||
# Keep package-path checks scoped to the fixture, even with a live app open.
|
||||
python3 - "$ROOT/bin/omarchy-remove-ai-hermes" "$test_tmp" <<'PY'
|
||||
from pathlib import Path
|
||||
import sys
|
||||
source, scratch = map(Path, sys.argv[1:])
|
||||
(scratch / 'remover').write_text(source.read_text().replace('/opt/hermes-desktop', str(scratch / 'package')))
|
||||
PY
|
||||
|
||||
cat >"$mock_bin/omarchy-pkg-drop" <<'SH'
|
||||
#!/bin/bash
|
||||
printf '%s\0' "$@" >>"$OMARCHY_TEST_DROP_LOG"
|
||||
@@ -32,6 +40,14 @@ SH
|
||||
cat >"$mock_bin/gum" <<'SH'
|
||||
#!/bin/bash
|
||||
printf '%s\0' "$@" >>"$OMARCHY_TEST_GUM_LOG"
|
||||
if [[ -n ${OMARCHY_TEST_PROMPT_GATE:-} ]]; then
|
||||
touch "$OMARCHY_TEST_PROMPT_GATE.started"
|
||||
for (( attempt=0; attempt<500; attempt++ )); do
|
||||
[[ ! -e $OMARCHY_TEST_PROMPT_GATE.continue ]] || exit 0
|
||||
sleep 0.01
|
||||
done
|
||||
exit 1
|
||||
fi
|
||||
exit "${OMARCHY_TEST_GUM_STATUS:-1}"
|
||||
SH
|
||||
cat >"$mock_bin/systemctl" <<'SH'
|
||||
@@ -70,7 +86,7 @@ remove() {
|
||||
OMARCHY_TEST_SYSTEMCTL_LOG="$test_tmp/systemctl-log" \
|
||||
OMARCHY_TEST_GUM_LOG="$test_tmp/gum-log" \
|
||||
HOME="$test_home" PATH="$mock_bin:$PATH" \
|
||||
bash "$ROOT/bin/omarchy-remove-ai-hermes" </dev/null >/dev/null 2>&1
|
||||
bash "$test_tmp/remover" </dev/null >"$test_tmp/output" 2>&1
|
||||
}
|
||||
|
||||
# script(1) puts the remover on a pty, which is the only way -t 0 answers true
|
||||
@@ -85,7 +101,7 @@ remove_tty() {
|
||||
OMARCHY_TEST_GUM_LOG="$test_tmp/gum-log" \
|
||||
OMARCHY_TEST_GUM_STATUS="${OMARCHY_TEST_GUM_STATUS:-1}" \
|
||||
HOME="$test_home" PATH="$mock_bin:$PATH" \
|
||||
script -qec "bash '$ROOT/bin/omarchy-remove-ai-hermes'" /dev/null >/dev/null 2>&1
|
||||
script -qec "bash '$test_tmp/remover'" /dev/null >"$test_tmp/output" 2>&1
|
||||
}
|
||||
|
||||
# The app brings its own uv and its own node; both are runtime, not data.
|
||||
@@ -229,3 +245,140 @@ OMARCHY_TEST_INSTALLER_STATUS=1 remove && fail "a failed CLI teardown surfaces i
|
||||
[[ ! -d $test_home/.hermes/hermes-agent ]] ||
|
||||
fail "a failed CLI teardown does not stop the runtime removal"
|
||||
pass "a failed CLI teardown is reported after the runtime is handled"
|
||||
|
||||
# Real SQLite writers exercise the kernel's live/deleted file descriptors.
|
||||
# Package, service and confirmation commands remain confined to the mocks.
|
||||
python3 - "$test_tmp" <<'PY'
|
||||
import os
|
||||
from pathlib import Path
|
||||
import pty
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
scratch = Path(sys.argv[1])
|
||||
writer_code = '''import os, sqlite3, sys
|
||||
c = sqlite3.connect(os.environ['TEST_DB'])
|
||||
c.execute('pragma journal_mode=wal')
|
||||
c.execute('create table fixture(value)')
|
||||
c.execute("insert into fixture values ('keep')")
|
||||
c.commit()
|
||||
print('ready', flush=True)
|
||||
sys.stdin.readline()
|
||||
c.close()
|
||||
'''
|
||||
|
||||
def setup(name):
|
||||
home = scratch / name
|
||||
runtime = home / '.hermes/hermes-agent'
|
||||
runtime.mkdir(parents=True)
|
||||
(runtime / '.hermes-bootstrap-complete').touch()
|
||||
(home / '.config/Hermes').mkdir(parents=True)
|
||||
env = {**os.environ, 'HOME': str(home), 'PATH': f"{scratch / 'bin'}:/usr/bin:/bin",
|
||||
'OMARCHY_TEST_GUM_STATUS': '0'}
|
||||
for key in ('DROP', 'INSTALLER', 'SYSTEMCTL', 'GUM'):
|
||||
log = home / (key + '.log')
|
||||
log.touch()
|
||||
env['OMARCHY_TEST_' + key + '_LOG'] = str(log)
|
||||
return home, runtime, env
|
||||
|
||||
def writer(db):
|
||||
child = subprocess.Popen([sys.executable, '-u', '-c', writer_code],
|
||||
env={**os.environ, 'TEST_DB': str(db)},
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)
|
||||
assert child.stdout.readline().strip() == 'ready'
|
||||
return child
|
||||
|
||||
def stop(child):
|
||||
if child.poll() is None:
|
||||
child.stdin.write('\n')
|
||||
child.stdin.flush()
|
||||
child.wait(timeout=5)
|
||||
|
||||
def remove(env):
|
||||
master, slave = pty.openpty()
|
||||
try:
|
||||
return subprocess.run(['bash', str(scratch / 'remover')], env=env,
|
||||
stdin=slave, capture_output=True, text=True, timeout=10)
|
||||
finally:
|
||||
os.close(master)
|
||||
os.close(slave)
|
||||
|
||||
def blocked(result, home, runtime, child):
|
||||
assert result.returncode != 0 and str(child.pid) in result.stderr, result
|
||||
assert 'Close Hermes' in result.stderr, result.stderr
|
||||
assert (runtime / '.hermes-bootstrap-complete').exists()
|
||||
assert all((home / (name + '.log')).stat().st_size == 0
|
||||
for name in ('DROP', 'INSTALLER', 'SYSTEMCTL', 'GUM'))
|
||||
assert child.poll() is None, 'remover must not kill sessions'
|
||||
|
||||
for deleted in (False, True):
|
||||
home, runtime, env = setup('deleted-writer' if deleted else 'live-writer')
|
||||
db = home / '.hermes/state.db'
|
||||
child = writer(db)
|
||||
try:
|
||||
if deleted:
|
||||
for suffix in ('', '-wal', '-shm'):
|
||||
Path(str(db) + suffix).unlink()
|
||||
db.write_bytes(b'new database generation')
|
||||
blocked(remove(env), home, runtime, child)
|
||||
if deleted:
|
||||
assert db.read_bytes() == b'new database generation'
|
||||
finally:
|
||||
stop(child)
|
||||
assert remove(env).returncode == 0, 'removal succeeds once the writer closes'
|
||||
assert not (home / '.hermes').exists()
|
||||
print('ok - live and deleted SQLite holders block removal before any side effects; closing them allows retry')
|
||||
|
||||
for kind in ('terminal', 'desktop', 'working-directory'):
|
||||
home, runtime, env = setup(kind)
|
||||
executable_name = str(scratch / 'package/Hermes') if kind == 'desktop' else str(runtime / 'hermes')
|
||||
args = ['sleep', '30'] if kind == 'working-directory' else [executable_name, '30']
|
||||
child = subprocess.Popen(args, executable='/usr/bin/sleep',
|
||||
cwd=runtime if kind == 'working-directory' else scratch)
|
||||
try:
|
||||
blocked(remove(env), home, runtime, child)
|
||||
finally:
|
||||
child.terminate()
|
||||
child.wait(timeout=5)
|
||||
print('ok - terminal, packaged desktop and runtime working-directory processes are detected without a database')
|
||||
|
||||
home, runtime, env = setup('unrelated-writer')
|
||||
sibling = home / '.hermes-other'
|
||||
sibling.mkdir()
|
||||
child = writer(sibling / 'state.db')
|
||||
try:
|
||||
assert remove(env).returncode == 0, 'a sibling database does not block Hermes removal'
|
||||
assert child.poll() is None
|
||||
finally:
|
||||
stop(child)
|
||||
print('ok - unrelated database holders are left alone')
|
||||
|
||||
home, runtime, env = setup('prompt-race')
|
||||
gate = home / 'prompt'
|
||||
env['OMARCHY_TEST_PROMPT_GATE'] = str(gate)
|
||||
master, slave = pty.openpty()
|
||||
remover = subprocess.Popen(['bash', str(scratch / 'remover')], env=env, stdin=slave,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
os.close(slave)
|
||||
child = None
|
||||
try:
|
||||
deadline = time.monotonic() + 5
|
||||
while not Path(str(gate) + '.started').exists():
|
||||
assert remover.poll() is None and time.monotonic() < deadline, 'prompt was not reached'
|
||||
time.sleep(0.01)
|
||||
child = writer(home / '.hermes/state.db')
|
||||
Path(str(gate) + '.continue').touch()
|
||||
stdout, stderr = remover.communicate(timeout=10)
|
||||
assert remover.returncode != 0 and str(child.pid) in stderr, (stdout, stderr)
|
||||
assert (home / '.hermes/state.db-wal').exists()
|
||||
assert (home / '.config/Hermes').exists()
|
||||
finally:
|
||||
if child is not None:
|
||||
stop(child)
|
||||
if remover.poll() is None:
|
||||
remover.terminate()
|
||||
remover.wait(timeout=5)
|
||||
os.close(master)
|
||||
print('ok - a writer started during confirmation blocks data deletion')
|
||||
PY
|
||||
Executable
+154
@@ -0,0 +1,154 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh"
|
||||
|
||||
test_dir=$(mktemp -d)
|
||||
trap 'rm -rf "$test_dir"' EXIT
|
||||
test_home="$test_dir/home"
|
||||
kitty_config="$test_home/.config/kitty/kitty.conf"
|
||||
legacy="$ROOT/test/shell.d/fixtures/kitty/legacy.conf"
|
||||
migration="$ROOT/migrations/1788745941.sh"
|
||||
mkdir -p "$(dirname "$kitty_config")" "$test_dir/bin"
|
||||
|
||||
run_migration() {
|
||||
env HOME="$test_home" OMARCHY_PATH="$ROOT" PATH="$ROOT/bin:$PATH" bash -euo pipefail "$migration"
|
||||
}
|
||||
|
||||
cp "$legacy" "$kitty_config"
|
||||
output=$(run_migration)
|
||||
cmp -s "$ROOT/config/kitty/kitty.conf" "$kitty_config" || fail "stock config becomes the user template"
|
||||
backups=("$kitty_config".bak.*)
|
||||
cmp -s "$legacy" "${backups[0]}" || fail "refresh backs up the original config"
|
||||
[[ $output == *"Close and reopen all Kitty windows"* ]] || fail "migration requires a full restart"
|
||||
pass "stock config is refreshed with a backup and restart guidance"
|
||||
|
||||
output=$(run_migration)
|
||||
cmp -s "$ROOT/config/kitty/kitty.conf" "$kitty_config" || fail "stock migration is idempotent"
|
||||
[[ $output != *"Close and reopen"* ]] || fail "rerun does not repeat restart guidance"
|
||||
pass "stock migration is idempotent"
|
||||
|
||||
cat >"$kitty_config" <<'CONF'
|
||||
# Keep this comment and my theme choice
|
||||
include my-theme.conf
|
||||
font_family My Font
|
||||
font_size 13
|
||||
map ctrl+insert
|
||||
include shortcuts.conf
|
||||
map shift+insert paste_from_clipboard
|
||||
allow_remote_control yes
|
||||
allow_remote_control y
|
||||
allow_remote_control true
|
||||
# allow_remote_control yes
|
||||
listen_on unix:/tmp/my-kitty
|
||||
CONF
|
||||
printf 'allow_remote_control yes \n' >>"$kitty_config"
|
||||
cp "$kitty_config" "$test_dir/custom-original"
|
||||
cat >"$test_dir/expected" <<'CONF'
|
||||
# Keep this comment and my theme choice
|
||||
include my-theme.conf
|
||||
font_family My Font
|
||||
font_size 13
|
||||
map ctrl+insert
|
||||
include shortcuts.conf
|
||||
map shift+insert paste_from_clipboard
|
||||
# allow_remote_control yes
|
||||
# allow_remote_control y
|
||||
# allow_remote_control true
|
||||
# allow_remote_control yes
|
||||
listen_on unix:/tmp/my-kitty
|
||||
CONF
|
||||
printf '# allow_remote_control yes \n' >>"$test_dir/expected"
|
||||
chmod 600 "$kitty_config"
|
||||
run_migration >/dev/null
|
||||
cmp -s "$test_dir/expected" "$kitty_config" || fail "customizations survive the security repair"
|
||||
[[ $(stat -c %a "$kitty_config") == "600" ]] || fail "migration preserves config permissions"
|
||||
backup=$(rg -l 'allow_remote_control true' "$kitty_config".bak.* | tail -1)
|
||||
cmp -s "$test_dir/custom-original" "$backup" || fail "custom config is backed up"
|
||||
run_migration >/dev/null
|
||||
cmp -s "$test_dir/expected" "$kitty_config" || fail "custom migration is idempotent"
|
||||
pass "custom config repair preserves ordering, mappings, theme, permissions, and original backup"
|
||||
|
||||
for mode in no n false socket-only socket password; do
|
||||
printf 'allow_remote_control %s\nfont_size 13\n' "$mode" >"$kitty_config"
|
||||
cp "$kitty_config" "$test_dir/expected"
|
||||
run_migration >/dev/null
|
||||
cmp -s "$test_dir/expected" "$kitty_config" || fail "migration preserves $mode"
|
||||
done
|
||||
pass "explicit restricted remote-control modes are preserved"
|
||||
|
||||
printf 'font_size 13\n' >"$kitty_config"
|
||||
cp "$kitty_config" "$test_dir/expected"
|
||||
run_migration >/dev/null
|
||||
cmp -s "$test_dir/expected" "$kitty_config" || fail "omitted setting stays omitted"
|
||||
rm "$kitty_config"
|
||||
run_migration >/dev/null
|
||||
[[ ! -e $kitty_config ]] || fail "absent config stays absent"
|
||||
pass "migration leaves omitted settings and absent user configs alone"
|
||||
|
||||
printf 'allow_remote_control yes\nfont_size 13\n' >"$test_dir/dotfiles.conf"
|
||||
ln -s "$test_dir/dotfiles.conf" "$kitty_config"
|
||||
run_migration >/dev/null
|
||||
[[ -L $kitty_config ]] || fail "migration preserves a dotfile symlink"
|
||||
grep -qx '# allow_remote_control yes' "$test_dir/dotfiles.conf" || fail "symlink target is repaired"
|
||||
pass "custom dotfile symlinks survive the repair"
|
||||
rm "$kitty_config"
|
||||
|
||||
# Exercise the real font commands without changing the running desktop.
|
||||
for command in pkill omarchy-restart-shell omarchy-hook omarchy-notification-send; do
|
||||
printf '#!/bin/bash\nexit 0\n' >"$test_dir/bin/$command"
|
||||
done
|
||||
printf '#!/bin/bash\nexit 1\n' >"$test_dir/bin/pgrep"
|
||||
printf '#!/bin/bash\nprintf "Test Font\\n"\n' >"$test_dir/bin/fc-list"
|
||||
printf '#!/bin/bash\nexit 0\n' >"$test_dir/bin/kitty"
|
||||
cat >"$test_dir/bin/gsettings" <<'SH'
|
||||
#!/bin/bash
|
||||
if [[ $1 == "get" ]]; then
|
||||
if [[ $3 == "font-name" ]]; then
|
||||
echo "'Sans 11'"
|
||||
else
|
||||
echo 1.0
|
||||
fi
|
||||
fi
|
||||
SH
|
||||
chmod +x "$test_dir/bin/"*
|
||||
|
||||
run_command() {
|
||||
env HOME="$test_home" OMARCHY_PATH="$ROOT" PATH="$test_dir/bin:$ROOT/bin:$PATH" "$ROOT/bin/$@"
|
||||
}
|
||||
|
||||
cp "$ROOT/config/kitty/kitty.conf" "$kitty_config"
|
||||
output=$(run_command omarchy-display-text-size)
|
||||
[[ $output == *"terminal font: 9 pt"* ]] || fail "size report accounts for inherited Kitty default"
|
||||
run_command omarchy-font-set 'Test Font'
|
||||
run_command omarchy-display-text-size 16
|
||||
grep -qx 'font_family Test Font' "$kitty_config" || fail "font command creates family override"
|
||||
run_command omarchy-font-set Font
|
||||
grep -qx 'font_family Font' "$kitty_config" || fail "font command updates family override"
|
||||
[[ $(grep -c '^font_family ' "$kitty_config") == "1" ]] || fail "font update avoids duplicate overrides"
|
||||
grep -qx 'font_size 12.0' "$kitty_config" || fail "size command creates size override"
|
||||
grep -qx '# font_size 12' "$kitty_config" || fail "font commands keep commented instructions"
|
||||
run_command omarchy-display-text-size 18
|
||||
[[ $(grep -c '^font_size ' "$kitty_config") == "1" ]] || fail "size update avoids duplicate overrides"
|
||||
run_command omarchy-display-text-size reset
|
||||
grep -qx 'font_size 9.0' "$kitty_config" || fail "size reset restores default"
|
||||
pass "font controls add and update overrides in the minimal template"
|
||||
|
||||
rm "$kitty_config"
|
||||
output=$(run_command omarchy-display-text-size)
|
||||
[[ $output == *"terminal font: 9 pt"* ]] || fail "size report handles absent Kitty config"
|
||||
run_command omarchy-font-set 'Test Font'
|
||||
run_command omarchy-display-text-size 16
|
||||
grep -qx 'font_family Test Font' "$kitty_config" || fail "font command handles absent config"
|
||||
grep -qx 'font_size 12.0' "$kitty_config" || fail "size command handles absent setting"
|
||||
! grep -q '^include ' "$kitty_config" || fail "font controls must not opt users back into theming"
|
||||
rm "$kitty_config"
|
||||
run_command omarchy-display-text-size 16
|
||||
grep -qx 'font_size 12.0' "$kitty_config" || fail "size command handles absent config"
|
||||
pass "font controls create missing Kitty overrides without restoring the theme include"
|
||||
|
||||
if "$ROOT/bin/omarchy-cmd-present" kitty; then
|
||||
kitty +runpy "$(cat "$ROOT/test/shell.d/fixtures/kitty/check-config.py")"
|
||||
else
|
||||
pass "Kitty not installed; skipping native config parser checks"
|
||||
fi
|
||||
+86
-157
@@ -4,166 +4,95 @@ set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
config_script="$ROOT/install/config/locate.sh"
|
||||
require_command python3
|
||||
require_command updatedb
|
||||
require_command plocate
|
||||
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
python3 - <<'PY'
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
stock_conf() {
|
||||
cat >"$1" <<'CONF'
|
||||
PRUNE_BIND_MOUNTS = "yes"
|
||||
PRUNEFS = "9p afs autofs cifs fuse nfs nfs4 proc sysfs tmpfs"
|
||||
PRUNENAMES = ".git .hg .svn"
|
||||
PRUNEPATHS = "/afs /media /mnt /net /sfs /tmp /udev /var/cache /var/lib/pacman/local /var/lock /var/run /var/spool /var/tmp"
|
||||
CONF
|
||||
}
|
||||
root = Path(os.environ["ROOT"])
|
||||
|
||||
# updatedb dies on a config that defines a variable twice, so hand every
|
||||
# rewritten file to the real parser rather than trusting the greps below.
|
||||
empty_tree="$test_tmp/empty-tree"
|
||||
mkdir -p "$empty_tree"
|
||||
def check(condition, description):
|
||||
if not condition:
|
||||
raise SystemExit("not ok - " + description)
|
||||
print("ok - " + description, flush=True)
|
||||
|
||||
assert_conf_parses() {
|
||||
command -v updatedb >/dev/null || return 0
|
||||
drop_in = root / "default/systemd/system/plocate-updatedb.service.d/10-omarchy.conf"
|
||||
directives = [line.strip() for line in drop_in.read_text().splitlines() if line.strip() and not line.startswith("#")]
|
||||
check(len(directives) == 3 and directives[:2] == ["[Service]", "ExecStart="] and directives[2].startswith("ExecStart="),
|
||||
"locate drop-in replaces the command and preserves upstream service restrictions")
|
||||
command = shlex.split(directives[2].removeprefix("ExecStart="))
|
||||
options = ["--prune-bind-mounts=no", "--add-prunepaths=/.snapshots"]
|
||||
check(command == ["/usr/bin/updatedb", *options],
|
||||
"locate service runs updatedb directly with fixed Btrfs options")
|
||||
check("ConditionACPower=true" in (root / "etc/systemd/system/plocate-updatedb.service.d/ac-only.conf").read_text(),
|
||||
"scheduled locate indexing keeps its AC-power condition")
|
||||
check(not (root / "install/config/locate.sh").exists() and not (root / "migrations/1784809451.sh").exists(),
|
||||
"the retired locate configuration helper and migration are absent")
|
||||
for directory in ("bin", "install", "migrations"):
|
||||
for path in (root / directory).rglob("*"):
|
||||
if path.is_file():
|
||||
content = path.read_text()
|
||||
if "OMARCHY_UPDATEDB_CONF_PATH" in content or "config/locate.sh" in content:
|
||||
raise SystemExit("not ok - retired locate configuration path remains in " + str(path))
|
||||
check(True, "runtime and installation no longer reference the configuration rewrite")
|
||||
|
||||
local errors
|
||||
errors=$(updatedb --config-file "$1" -U "$empty_tree" -o "$test_tmp/plocate.db" 2>&1 >/dev/null | grep -F "$1:" || true)
|
||||
[[ -z $errors ]] || fail "updatedb accepts the rewritten config" "$errors"
|
||||
}
|
||||
with tempfile.TemporaryDirectory(prefix="omarchy-locate-") as scratch:
|
||||
scratch = Path(scratch)
|
||||
fake_bin = scratch / "bin"
|
||||
fake_bin.mkdir()
|
||||
stubs = {
|
||||
"updatedb": 'printf "%s\\n" "$@" >"$TEST_CALLS"',
|
||||
"sudo": 'exec "$@"',
|
||||
"fzf": 'cat >/dev/null\nprintf "%s\\n" test-package',
|
||||
"yay": 'if [[ ${1:-} == "-Slqa" ]]; then printf "%s\\n" test-package; fi',
|
||||
"omarchy-sudo-keepalive": ':',
|
||||
"omarchy-show-done": ':',
|
||||
}
|
||||
for name, body in stubs.items():
|
||||
path = fake_bin / name
|
||||
path.write_text("#!/bin/bash\n" + body + "\n")
|
||||
path.chmod(0o755)
|
||||
calls = scratch / "updatedb-arguments"
|
||||
env = dict(os.environ, PATH=str(fake_bin) + ":" + os.environ["PATH"], TEST_CALLS=str(calls))
|
||||
for relative in ("install/post-install/localdb.sh", "bin/omarchy-pkg-aur-install"):
|
||||
subprocess.run(["bash", "-euo", "pipefail", str(root / relative)], env=env, check=True)
|
||||
check(calls.read_text().splitlines() == options,
|
||||
relative + " passes the scheduled service options directly")
|
||||
calls.unlink()
|
||||
|
||||
conf="$test_tmp/updatedb.conf"
|
||||
stock_conf "$conf"
|
||||
|
||||
OMARCHY_UPDATEDB_CONF_PATH="$conf" bash -euo pipefail "$config_script" >/dev/null
|
||||
|
||||
grep -qFx 'PRUNE_BIND_MOUNTS = "no"' "$conf" || fail "locate config indexes Btrfs subvolume mounts like /home"
|
||||
grep -qF 'PRUNEPATHS = "/.snapshots /afs' "$conf" || fail "locate config prunes /.snapshots"
|
||||
assert_conf_parses "$conf"
|
||||
pass "locate config skips Btrfs snapshots and indexes Btrfs subvolumes"
|
||||
|
||||
OMARCHY_UPDATEDB_CONF_PATH="$conf" bash -euo pipefail "$config_script" >/dev/null
|
||||
|
||||
[[ $(grep -o '/\.snapshots' "$conf" | wc -l) -eq 1 ]] || fail "locate config is idempotent"
|
||||
assert_conf_parses "$conf"
|
||||
pass "locate config leaves an already-configured file alone"
|
||||
|
||||
OMARCHY_UPDATEDB_CONF_PATH="$test_tmp/missing.conf" bash -euo pipefail "$config_script" >/dev/null
|
||||
pass "locate config tolerates a missing updatedb.conf"
|
||||
|
||||
# A hand-edited updatedb.conf may drop the settings entirely, or write them
|
||||
# without the spaces around the "=" or the quotes that the stock Arch file uses.
|
||||
conf="$test_tmp/sparse-updatedb.conf"
|
||||
printf '%s\n' 'PRUNENAMES = ".git .hg .svn"' >"$conf"
|
||||
|
||||
OMARCHY_UPDATEDB_CONF_PATH="$conf" bash -euo pipefail "$config_script" >/dev/null
|
||||
|
||||
grep -qFx 'PRUNE_BIND_MOUNTS = "no"' "$conf" || fail "locate config adds a missing PRUNE_BIND_MOUNTS"
|
||||
grep -qFx 'PRUNEPATHS = "/.snapshots"' "$conf" || fail "locate config adds a missing PRUNEPATHS"
|
||||
assert_conf_parses "$conf"
|
||||
pass "locate config adds settings a hand-edited updatedb.conf is missing"
|
||||
|
||||
conf="$test_tmp/unspaced-updatedb.conf"
|
||||
printf '%s\n' 'PRUNE_BIND_MOUNTS="yes"' 'PRUNEPATHS="/tmp /var/tmp"' >"$conf"
|
||||
|
||||
OMARCHY_UPDATEDB_CONF_PATH="$conf" bash -euo pipefail "$config_script" >/dev/null
|
||||
|
||||
grep -qFx 'PRUNE_BIND_MOUNTS = "no"' "$conf" || fail "locate config rewrites an unspaced PRUNE_BIND_MOUNTS"
|
||||
grep -qFx 'PRUNEPATHS = "/.snapshots /tmp /var/tmp"' "$conf" || fail "locate config prunes /.snapshots in an unspaced PRUNEPATHS"
|
||||
[[ $(grep -c 'PRUNEPATHS' "$conf") -eq 1 ]] || fail "locate config keeps a single PRUNEPATHS setting"
|
||||
assert_conf_parses "$conf"
|
||||
pass "locate config handles updatedb.conf written without spaces around ="
|
||||
|
||||
# updatedb allows a comment after a value and indented settings, and defining
|
||||
# either setting twice makes it refuse to run at all.
|
||||
conf="$test_tmp/commented-updatedb.conf"
|
||||
printf '%s\n' ' PRUNE_BIND_MOUNTS = "yes" # subvolumes look like bind mounts' \
|
||||
'PRUNEPATHS = "/tmp" # scratch' >"$conf"
|
||||
|
||||
OMARCHY_UPDATEDB_CONF_PATH="$conf" bash -euo pipefail "$config_script" >/dev/null
|
||||
|
||||
grep -qFx 'PRUNE_BIND_MOUNTS = "no"' "$conf" || fail "locate config rewrites an indented PRUNE_BIND_MOUNTS"
|
||||
grep -qFx 'PRUNEPATHS = "/.snapshots /tmp"' "$conf" || fail "locate config keeps the paths a commented PRUNEPATHS already prunes"
|
||||
[[ $(grep -c 'PRUNEPATHS' "$conf") -eq 1 ]] || fail "locate config replaces a commented PRUNEPATHS instead of adding a second one"
|
||||
assert_conf_parses "$conf"
|
||||
pass "locate config handles indented settings and trailing comments"
|
||||
|
||||
# A hand-edited file may have dropped the quotes updatedb requires, which
|
||||
# leaves it unparseable until something writes the setting out properly.
|
||||
conf="$test_tmp/unquoted-updatedb.conf"
|
||||
printf '%s\n' 'PRUNEPATHS = /tmp' >"$conf"
|
||||
|
||||
OMARCHY_UPDATEDB_CONF_PATH="$conf" bash -euo pipefail "$config_script" >/dev/null
|
||||
|
||||
grep -qFx 'PRUNEPATHS = "/.snapshots"' "$conf" || fail "locate config repairs an unquoted PRUNEPATHS"
|
||||
[[ $(grep -c 'PRUNEPATHS' "$conf") -eq 1 ]] || fail "locate config replaces an unquoted PRUNEPATHS instead of adding a second one"
|
||||
assert_conf_parses "$conf"
|
||||
pass "locate config handles updatedb.conf written without quotes"
|
||||
|
||||
# A path that merely ends in /.snapshots is not the root snapshot directory.
|
||||
conf="$test_tmp/nested-snapshots-updatedb.conf"
|
||||
printf '%s\n' 'PRUNEPATHS = "/var/lib/machines/.snapshots"' >"$conf"
|
||||
|
||||
OMARCHY_UPDATEDB_CONF_PATH="$conf" bash -euo pipefail "$config_script" >/dev/null
|
||||
|
||||
grep -qFx 'PRUNEPATHS = "/.snapshots /var/lib/machines/.snapshots"' "$conf" || fail "locate config prunes /.snapshots alongside a path that ends in it"
|
||||
assert_conf_parses "$conf"
|
||||
pass "locate config tells /.snapshots apart from a path that ends in it"
|
||||
|
||||
locate_migration=$(grep -rl 'Configure locate to skip Btrfs snapshots' "$ROOT/migrations" | head -n 1 || true)
|
||||
[[ -n $locate_migration ]] || fail "locate migration exists"
|
||||
|
||||
fake_bin="$test_tmp/bin"
|
||||
mkdir -p "$fake_bin"
|
||||
|
||||
cat >"$fake_bin/sudo" <<'STUB'
|
||||
#!/bin/bash
|
||||
exec "$@"
|
||||
STUB
|
||||
chmod +x "$fake_bin/sudo"
|
||||
|
||||
cat >"$fake_bin/systemctl" <<'STUB'
|
||||
#!/bin/bash
|
||||
printf 'systemctl %s\n' "$*" >>"$TEST_LOG"
|
||||
STUB
|
||||
chmod +x "$fake_bin/systemctl"
|
||||
|
||||
conf="$test_tmp/migration-updatedb.conf"
|
||||
stock_conf "$conf"
|
||||
|
||||
TEST_LOG="$test_tmp/calls.log" \
|
||||
PATH="$fake_bin:$PATH" \
|
||||
OMARCHY_PATH="$ROOT" \
|
||||
OMARCHY_UPDATEDB_CONF_PATH="$conf" \
|
||||
bash -euo pipefail "$locate_migration" >/dev/null
|
||||
|
||||
grep -qFx 'PRUNE_BIND_MOUNTS = "no"' "$conf" || fail "locate migration rewrites updatedb.conf"
|
||||
grep -qF 'PRUNEPATHS = "/.snapshots /afs' "$conf" || fail "locate migration prunes /.snapshots"
|
||||
grep -qFx 'systemctl restart --no-block plocate-updatedb.service' "$test_tmp/calls.log" || fail "locate migration replaces an in-flight run and rebuilds the index without blocking"
|
||||
pass "locate migration fixes existing installs and rebuilds the index"
|
||||
|
||||
: >"$test_tmp/calls.log"
|
||||
|
||||
TEST_LOG="$test_tmp/calls.log" \
|
||||
PATH="$fake_bin:$PATH" \
|
||||
OMARCHY_PATH="$ROOT" \
|
||||
OMARCHY_UPDATEDB_CONF_PATH="$conf" \
|
||||
bash -euo pipefail "$locate_migration" >/dev/null
|
||||
|
||||
[[ ! -s $test_tmp/calls.log ]] || fail "locate migration skips already-configured installs"
|
||||
pass "locate migration is a no-op once updatedb.conf is configured"
|
||||
|
||||
# A dev checkout carries migrations from a release whose install scripts the
|
||||
# checked-out tree may not have yet, and omarchy-migrate runs under set -e.
|
||||
: >"$test_tmp/calls.log"
|
||||
conf="$test_tmp/no-config-script-updatedb.conf"
|
||||
stock_conf "$conf"
|
||||
|
||||
TEST_LOG="$test_tmp/calls.log" \
|
||||
PATH="$fake_bin:$PATH" \
|
||||
OMARCHY_PATH="$test_tmp/empty" \
|
||||
OMARCHY_UPDATEDB_CONF_PATH="$conf" \
|
||||
bash -euo pipefail "$locate_migration" >/dev/null ||
|
||||
fail "locate migration survives a tree without the locate config script"
|
||||
|
||||
[[ ! -s $test_tmp/calls.log ]] || fail "locate migration touches nothing without the locate config script"
|
||||
pass "locate migration is a no-op when the locate config script is missing"
|
||||
tree = scratch / "tree"
|
||||
visible = tree / "home/current-file"
|
||||
excluded = tree / "private&pipe|directory"
|
||||
hidden = excluded / "private-file"
|
||||
visible.parent.mkdir(parents=True)
|
||||
excluded.mkdir()
|
||||
visible.touch()
|
||||
hidden.touch()
|
||||
conf = scratch / "updatedb.conf"
|
||||
conf.write_text('PRUNE_BIND_MOUNTS = "yes"\nPRUNEPATHS = "' + str(excluded) + '"\n')
|
||||
conf.chmod(0o640)
|
||||
original = conf.read_bytes()
|
||||
metadata = conf.stat()
|
||||
database = scratch / "plocate.db"
|
||||
run = [*command, "--config-file", str(conf), "--database-root", str(tree),
|
||||
"--output", str(database), "--require-visibility", "no", "--debug-pruning"]
|
||||
result = subprocess.run(run, capture_output=True, text=True, check=True)
|
||||
debug = result.stdout + result.stderr
|
||||
check("prune_bind_mounts\\000\n0\\000" in debug and "/.snapshots\\000" in debug,
|
||||
"real updatedb overrides bind-mount pruning and adds root snapshots to exclusions")
|
||||
entries = subprocess.check_output(["plocate", "--database", str(database), ""], text=True).splitlines()
|
||||
check(str(visible) in entries and str(hidden) not in entries,
|
||||
"real locate indexes current files and preserves literal administrator exclusions")
|
||||
check(conf.read_bytes() == original and (conf.stat().st_mode, conf.stat().st_uid, conf.stat().st_gid, conf.stat().st_mtime_ns)
|
||||
== (metadata.st_mode, metadata.st_uid, metadata.st_gid, metadata.st_mtime_ns),
|
||||
"indexing preserves configuration bytes, permissions, ownership, and modification time")
|
||||
subprocess.run(run, capture_output=True, check=True)
|
||||
repeated = subprocess.check_output(["plocate", "--database", str(database), ""], text=True).splitlines()
|
||||
check(repeated == entries, "repeated indexing retains the same results and exclusions")
|
||||
PY
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh"
|
||||
|
||||
tmpdir=$(mktemp -d)
|
||||
trap 'rm -rf "$tmpdir"' EXIT
|
||||
|
||||
home="$tmpdir/home"
|
||||
stub_bin="$tmpdir/bin"
|
||||
mkdir -p "$home" "$stub_bin"
|
||||
|
||||
# Stands in for the real mise so a generated wrapper can be run and asked what
|
||||
# arguments it passed on.
|
||||
cat >"$stub_bin/mise" <<'SH'
|
||||
#!/bin/bash
|
||||
|
||||
printf 'mise' >>"$OMARCHY_MISE_TEST_LOG"
|
||||
for arg in "$@"; do
|
||||
printf '\t%s' "$arg" >>"$OMARCHY_MISE_TEST_LOG"
|
||||
done
|
||||
printf '\n' >>"$OMARCHY_MISE_TEST_LOG"
|
||||
SH
|
||||
chmod +x "$stub_bin/mise"
|
||||
|
||||
install_wrapper() {
|
||||
HOME="$home" "$ROOT/bin/omarchy-mise-install" "$@"
|
||||
}
|
||||
|
||||
# The ordinary case still works, and every call site in install/user/mise.sh
|
||||
# passes names of this shape.
|
||||
install_wrapper npm:playwright playwright >/dev/null
|
||||
[[ -x $home/.local/bin/playwright ]] ||
|
||||
fail "a normal install writes an executable wrapper"
|
||||
|
||||
log="$tmpdir/normal.log"
|
||||
: >"$log"
|
||||
OMARCHY_MISE_TEST_LOG="$log" PATH="$stub_bin:$PATH" "$home/.local/bin/playwright" >/dev/null
|
||||
grep -Fqx $'mise\tuse\t-g\t--quiet\tnpm:playwright' "$log" ||
|
||||
fail "the wrapper asks mise for the package it was given" "$(cat "$log")"
|
||||
|
||||
pass "a normal install writes a wrapper that names its package"
|
||||
|
||||
# A package name is data. Quoted with %q it reaches mise as one argument
|
||||
# instead of being read as shell source when the wrapper runs.
|
||||
install_wrapper 'npm:pkg$(touch '"$tmpdir"'/PWNED)end' hostile >/dev/null
|
||||
|
||||
log="$tmpdir/hostile.log"
|
||||
: >"$log"
|
||||
OMARCHY_MISE_TEST_LOG="$log" PATH="$stub_bin:$PATH" "$home/.local/bin/hostile" >/dev/null
|
||||
|
||||
[[ -e $tmpdir/PWNED ]] &&
|
||||
fail "a package name with shell characters does not run when the wrapper does" \
|
||||
"wrapper: $(cat "$home/.local/bin/hostile")"
|
||||
|
||||
grep -Fqx $'mise\tuse\t-g\t--quiet\tnpm:pkg$(touch '"$tmpdir"'/PWNED)end' "$log" ||
|
||||
fail "the package reaches mise whole" "$(cat "$log")"
|
||||
|
||||
pass "a package name with shell characters reaches mise as one argument"
|
||||
|
||||
# The command name is a file name under ~/.local/bin. These shapes escape it,
|
||||
# hide it, make something that reads as an option, or carry characters that have
|
||||
# no business in a file name. Labelled so a newline in the value does not end up
|
||||
# inside the test output.
|
||||
refused=(
|
||||
"a slash" "../escaped"
|
||||
"a leading dot" ".hidden"
|
||||
"a leading dash" "-dash"
|
||||
"a newline" $'with\nnewline'
|
||||
"a tab" $'with\ttab'
|
||||
)
|
||||
|
||||
for (( i = 0; i < ${#refused[@]}; i += 2 )); do
|
||||
label=${refused[i]}
|
||||
name=${refused[i + 1]}
|
||||
|
||||
if install_wrapper somepkg "$name" >/dev/null 2>"$tmpdir/err"; then
|
||||
fail "a command name with $label is refused"
|
||||
fi
|
||||
grep -Fq 'is not usable as a command name' "$tmpdir/err" ||
|
||||
fail "the refusal says why for a command name with $label" "$(cat "$tmpdir/err")"
|
||||
done
|
||||
|
||||
pass "command names that are not plain file names are refused"
|
||||
|
||||
# The refusal has to land before the rm, which would otherwise delete the
|
||||
# escaped path on its way to failing.
|
||||
victim="$tmpdir/victim"
|
||||
printf 'keep me\n' >"$victim"
|
||||
if install_wrapper somepkg "../../../..$victim" >/dev/null 2>&1; then
|
||||
fail "an escaping command name is refused"
|
||||
fi
|
||||
[[ -f $victim ]] ||
|
||||
fail "an escaping command name removes nothing outside ~/.local/bin"
|
||||
|
||||
pass "an escaping command name removes nothing outside ~/.local/bin"
|
||||
@@ -0,0 +1,123 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(dirname "$0")/base-test.sh"
|
||||
|
||||
script="$ROOT/bin/omarchy-sudo-passwordless"
|
||||
tmpfiles_file="$ROOT/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf"
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
|
||||
mock_bin="$test_tmp/bin"
|
||||
grant="$test_tmp/grant"
|
||||
calls="$test_tmp/calls"
|
||||
mkdir -p "$mock_bin"
|
||||
|
||||
cat >"$mock_bin/gum" <<'SH'
|
||||
#!/bin/bash
|
||||
exit 0
|
||||
SH
|
||||
|
||||
cat >"$mock_bin/systemctl" <<'SH'
|
||||
#!/bin/bash
|
||||
|
||||
printf 'systemctl %s\n' "$*" >>"$TEST_CALLS"
|
||||
[[ ${1:-} == "is-active" && ${TEST_TIMER_ACTIVE:-false} == "true" ]]
|
||||
SH
|
||||
|
||||
cat >"$mock_bin/sudo" <<'SH'
|
||||
#!/bin/bash
|
||||
|
||||
printf 'sudo %s\n' "$*" >>"$TEST_CALLS"
|
||||
|
||||
case ${1:-} in
|
||||
test)
|
||||
[[ ${2:-} == "-f" && -f $TEST_GRANT ]]
|
||||
;;
|
||||
tee)
|
||||
/usr/bin/tee "$TEST_GRANT"
|
||||
;;
|
||||
chmod)
|
||||
/usr/bin/chmod "$2" "$TEST_GRANT"
|
||||
;;
|
||||
systemd-run)
|
||||
[[ ${TEST_FAIL_SYSTEMD_RUN:-false} != "true" ]]
|
||||
;;
|
||||
rm)
|
||||
/usr/bin/rm -f -- "$TEST_GRANT"
|
||||
;;
|
||||
systemctl)
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "unexpected sudo command: $*" >&2
|
||||
exit 90
|
||||
;;
|
||||
esac
|
||||
SH
|
||||
|
||||
chmod +x "$mock_bin/gum" "$mock_bin/sudo" "$mock_bin/systemctl"
|
||||
|
||||
run_command() {
|
||||
TEST_CALLS="$calls" TEST_GRANT="$grant" PATH="$mock_bin:$PATH" USER=alice \
|
||||
"$script" "$@"
|
||||
}
|
||||
|
||||
: >"$calls"
|
||||
enable_output=$(run_command 15)
|
||||
[[ -f $grant ]] || fail "successful timer setup leaves the passwordless sudo grant enabled"
|
||||
[[ $(cat "$grant") == "alice ALL=(ALL) NOPASSWD: ALL" ]] ||
|
||||
fail "the enabled grant belongs to the current user" "$(cat "$grant")"
|
||||
grep -q '^sudo systemd-run --on-active=15m .* rm -f -- /etc/sudoers.d/99-omarchy-nopasswd-alice$' "$calls" ||
|
||||
fail "enabling arms the expiry timer" "$(cat "$calls")"
|
||||
[[ $enable_output == *"automatically disable in 15 minutes"* ]] ||
|
||||
fail "success is reported after the timer is armed" "$enable_output"
|
||||
pass "enabling arms expiry before reporting success"
|
||||
|
||||
: >"$calls"
|
||||
rm -f "$grant"
|
||||
if failure_output=$(TEST_FAIL_SYSTEMD_RUN=true run_command 15 2>&1); then
|
||||
fail "enabling fails when the expiry timer cannot be armed"
|
||||
fi
|
||||
[[ ! -e $grant ]] || fail "timer setup failure revokes the new passwordless sudo grant"
|
||||
[[ $failure_output == *"Revoking access now"* ]] ||
|
||||
fail "timer setup failure explains the fail-closed revocation" "$failure_output"
|
||||
[[ $failure_output != *"Passwordless sudo has been ENABLED"* ]] ||
|
||||
fail "timer setup failure does not report that passwordless sudo was enabled" "$failure_output"
|
||||
pass "timer setup failure revokes a new grant"
|
||||
|
||||
: >"$calls"
|
||||
printf 'alice ALL=(ALL) NOPASSWD: ALL\n' >"$grant"
|
||||
if update_output=$(TEST_TIMER_ACTIVE=true TEST_FAIL_SYSTEMD_RUN=true run_command 30 2>&1); then
|
||||
fail "updating fails when the replacement expiry timer cannot be armed"
|
||||
fi
|
||||
[[ ! -e $grant ]] || fail "timer update failure revokes the existing passwordless sudo grant"
|
||||
[[ $update_output != *"timer updated"* ]] ||
|
||||
fail "timer update failure does not report success" "$update_output"
|
||||
pass "timer update failure revokes the existing grant"
|
||||
|
||||
mapfile -t tmpfiles_rules < <(grep -vE '^[[:space:]]*(#|$)' "$tmpfiles_file")
|
||||
(( ${#tmpfiles_rules[@]} == 1 )) ||
|
||||
fail "passwordless sudo ships one tmpfiles rule" "${tmpfiles_rules[*]}"
|
||||
|
||||
fake_root="$test_tmp/root"
|
||||
sudoers_dir="$fake_root/etc/sudoers.d"
|
||||
mkdir -p "$sudoers_dir"
|
||||
grant_names=(alice buildbot-2 user.123 'service$')
|
||||
for grant_name in "${grant_names[@]}"; do
|
||||
touch "$sudoers_dir/99-omarchy-nopasswd-$grant_name"
|
||||
done
|
||||
touch "$sudoers_dir/omarchy-dns"
|
||||
|
||||
systemd-tmpfiles --root="$fake_root" --remove --inline "${tmpfiles_rules[@]}"
|
||||
[[ -f $sudoers_dir/99-omarchy-nopasswd-alice ]] ||
|
||||
fail "boot-only cleanup leaves a live grant alone outside boot"
|
||||
|
||||
systemd-tmpfiles --root="$fake_root" --remove --boot --inline "${tmpfiles_rules[@]}"
|
||||
for grant_name in "${grant_names[@]}"; do
|
||||
stale_grant="$sudoers_dir/99-omarchy-nopasswd-$grant_name"
|
||||
[[ ! -e $stale_grant ]] || fail "boot cleanup removes every generated grant" "$stale_grant"
|
||||
done
|
||||
[[ -f $sudoers_dir/omarchy-dns ]] || fail "boot cleanup preserves unrelated sudoers rules"
|
||||
pass "systemd-tmpfiles removes generated grants only during boot"
|
||||
Executable
+201
@@ -0,0 +1,201 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
TMPDIR=""
|
||||
QS_PID=""
|
||||
|
||||
cleanup() {
|
||||
if [[ -n $QS_PID ]] && kill -0 "$QS_PID" 2>/dev/null; then
|
||||
kill "$QS_PID" 2>/dev/null || true
|
||||
wait "$QS_PID" 2>/dev/null || true
|
||||
fi
|
||||
if [[ -n $TMPDIR && -d $TMPDIR ]]; then
|
||||
rm -rf "$TMPDIR"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
shell_qml="$ROOT/shell/shell.qml"
|
||||
bar_qml="$ROOT/shell/plugins/bar/Bar.qml"
|
||||
plugin_shell_api="$ROOT/shell/services/PluginShellApi.qml"
|
||||
idle_service="$ROOT/shell/plugins/services/idle/Service.qml"
|
||||
|
||||
# Normalize horizontal and vertical whitespace so the wiring assertions survive
|
||||
# harmless QML reflow. The runtime fixture below behaviorally covers
|
||||
# PluginShellApi and AuthServiceStore; these checks remain the guard for their
|
||||
# integration through shell.qml and Bar.qml, including without a compositor.
|
||||
qml_matches() {
|
||||
local file=$1
|
||||
local pattern=$2
|
||||
|
||||
tr '\n\r\t' ' ' < "$file" | grep -Eq "$pattern"
|
||||
}
|
||||
|
||||
qml_matches "$shell_qml" 'comp\.createObject\( *manifest\.__isFirstParty *&& *!authenticationService *\? *serviceHost *: *null *\)' ||
|
||||
fail "third-party and authentication services are detached from the host object tree"
|
||||
qml_matches "$shell_qml" 'AuthServiceStore\.put\( *key, *inst *\)' ||
|
||||
fail "authentication services are retained outside the host service map"
|
||||
qml_matches "$shell_qml" 'AuthServiceStore\.isTrusted\( *key *\)' ||
|
||||
fail "live authentication classification survives public manifest mutation"
|
||||
qml_matches "$shell_qml" 'AuthServiceStore\.updateManifest\( *id, *shell\.publicPluginManifest\( *m *\) *\)' ||
|
||||
fail "kept authentication services receive only a public manifest snapshot"
|
||||
qml_matches "$shell_qml" 'if *\( *!serviceKeepLoaded\( *authenticationId *\) *\) *AuthServiceStore\.destroy\( *authenticationId *\)' ||
|
||||
fail "keepLoaded authentication services survive plugin rescans"
|
||||
pass "third-party and authentication services are detached from the host object tree"
|
||||
|
||||
run_node_test <<'JS'
|
||||
const fs = require('fs')
|
||||
const vm = require('vm')
|
||||
const store = {}
|
||||
vm.createContext(store)
|
||||
vm.runInContext(
|
||||
fs.readFileSync(path.join(root, 'shell/services/AuthServiceStore.js'), 'utf8'),
|
||||
store
|
||||
)
|
||||
const service = { destroy() {} }
|
||||
store.put('omarchy.lock', service)
|
||||
store.destroy('omarchy.lock')
|
||||
assert(
|
||||
!store.has('omarchy.lock') && store.isTrusted('omarchy.lock'),
|
||||
'authentication classification survives service teardown'
|
||||
)
|
||||
JS
|
||||
|
||||
qml_matches "$shell_qml" 'inst\.shell *= *shell\.pluginShellFor\( *manifest *\)' ||
|
||||
fail "service plugins receive a scoped shell facade"
|
||||
qml_matches "$shell_qml" 'item\.shell *= *shell\.pluginShellFor\( *panelEntry\.manifest *\)' ||
|
||||
fail "panel plugins receive a scoped shell facade"
|
||||
qml_matches "$shell_qml" 'target\.shell *= *shell\.pluginShellFor\( *manifest *\)' ||
|
||||
fail "full-bar plugins receive a scoped shell facade"
|
||||
pass "third-party entry points receive scoped shell facades"
|
||||
|
||||
if qml_matches "$plugin_shell_api" 'function +pluginShellForId\('; then
|
||||
fail "replacement-bar facade exposes a generic plugin-shell factory"
|
||||
fi
|
||||
qml_matches "$bar_qml" 'else if *\( *root\.shell *&& *typeof root\.shell\.pluginShellForBarEntry *=== *"function" *\) *\{[^}]*pluginShell *= *root\.shell\.pluginShellForBarEntry\( *key, *moduleName *\)' ||
|
||||
fail "replacement bars do not fall back to a service-less entry facade"
|
||||
pass "replacement bars cannot manufacture another plugin's service facade"
|
||||
|
||||
qml_matches "$shell_qml" 'target\.barConfig *= *shell\.barConfigFor\( *manifest *\)' ||
|
||||
fail "initial replacement-bar configuration is not detached"
|
||||
qml_matches "$shell_qml" 'bar\.barConfig *= *shell\.barConfigFor\( *shell\.activeBarManifest *\)' ||
|
||||
fail "replacement-bar configuration updates are not detached"
|
||||
pass "replacement bars receive detached configuration snapshots"
|
||||
|
||||
qml_matches "$bar_qml" 'target\.bar *= *firstParty *\? *root *: *root\.pluginBarApiFor\( *pluginApiId, *moduleName, *registered *\)' ||
|
||||
fail "third-party widgets receive a bar facade instead of the host bar"
|
||||
qml_matches "$bar_qml" 'api\.clickTargets *= *root\.pluginClickTargets\( *api\.pluginId *\)' ||
|
||||
fail "third-party bar facades exclude other widgets from their object graph"
|
||||
pass "third-party widgets receive a bar facade instead of the host bar"
|
||||
|
||||
qml_matches "$shell_qml" 'widgets: *shell\.publicBarWidgetSnapshot\( *\)' ||
|
||||
fail "third-party widget registries receive detached snapshots"
|
||||
qml_matches "$bar_qml" 'root\.markPluginObject\( *pluginId, *target, *"clickTarget" *\)' ||
|
||||
fail "third-party bar-object ownership is stamped by the host callback"
|
||||
qml_matches "$bar_qml" 'root\.markPluginObject\( *pluginId, *owner, *"popout" *\)' ||
|
||||
fail "owner-less popouts receive trusted ownership before activation"
|
||||
qml_matches "$shell_qml" 'manifest\.__hostCapabilities\.indexOf\( *"authentication" *\)' ||
|
||||
fail "authentication isolation follows host-stamped capabilities"
|
||||
pass "registry mutation and ownership boundaries are host-controlled"
|
||||
|
||||
qml_matches "$bar_qml" 'root\.moduleWidgets\( *moduleName *\)' ||
|
||||
fail "custom bar module widget lookups use their real module name"
|
||||
qml_matches "$shell_qml" 'shell\.pluginShellForBarEntry\( *cacheKey *\+ *":" *\+ *ownerId, *moduleName *\)' ||
|
||||
fail "full-bar plugins receive a scoped settings facade for custom modules"
|
||||
pass "custom bar modules retain settings and popout identity"
|
||||
|
||||
if qml_matches "$bar_qml" 'on(Foreground|BarForeground|Background|Urgent|FontFamily|Vertical|BarSize|Transparent)Changed: *sync'; then
|
||||
fail "animated scalar properties still trigger full facade resyncs"
|
||||
fi
|
||||
qml_matches "$bar_qml" 'api\.foreground *= *Qt\.binding\( *function\( *\) *\{ *return root\.foreground *\} *\)' ||
|
||||
fail "third-party bar scalar mirrors use bindings"
|
||||
qml_matches "$shell_qml" 'shell\.prunePluginApis\( *\)' ||
|
||||
fail "disabled plugin facade caches are pruned"
|
||||
pass "plugin facade synchronization is bounded"
|
||||
|
||||
qml_matches "$shell_qml" 'descriptor\.profile *!== *expectedProfile[^}]*shell\.revokePluginShellApi\( *shellKey *\)' ||
|
||||
fail "manifest capability changes do not revoke cached plugin facades"
|
||||
qml_matches "$shell_qml" 'shell\.barPluginMayControl\( *currentManifest\( *\), *requestedId *\)' ||
|
||||
fail "bar lifecycle callbacks do not validate the current manifest"
|
||||
qml_matches "$shell_qml" 'return hasCurrentBarCapabilities\( *\) *\? *shell\.mutatePluginBarConfig\( *mutator *\) *: *false' ||
|
||||
fail "bar configuration mutation does not validate the current manifest"
|
||||
pass "manifest changes revoke cached facade capabilities"
|
||||
|
||||
qml_matches "$shell_qml" 'idleConfig: *shell\.publicIdleConfigFor\( *manifest *\)' ||
|
||||
fail "cloned idle services do not receive their configured timeouts"
|
||||
qml_matches "$shell_qml" 'shellApi\.idleConfig *= *shell\.publicIdleConfigFor\( *shellManifest *\)' ||
|
||||
fail "cloned idle service configuration does not refresh"
|
||||
qml_matches "$idle_service" 'shell *&& *shell\.idleConfig *\? *shell\.idleConfig *: *\(\{\}\)' ||
|
||||
fail "the idle service does not consume its scoped configuration"
|
||||
bar_entry_shell=$(sed -n '/^ function pluginShellForBarEntry(/,/^ function pluginShellFor(/p' "$shell_qml")
|
||||
tr '\n\r\t' ' ' <<<"$bar_entry_shell" |
|
||||
grep -Eq 'var id *= *shell\.pluginRegistry\.resolveEnabledId\( *target *\)[^}]*return shell\.pluginRegistry\.installedPlugins\[id\] *\|\| *null' ||
|
||||
fail "replacement-bar clone authorization does not follow the enabled implementation"
|
||||
tr '\n\r\t' ' ' <<<"$bar_entry_shell" |
|
||||
grep -Eq 'shell\.pluginCloneMaySummon\( *currentManifest\( *\), *requestedId *\)' ||
|
||||
fail "built-in clones in replacement bars cannot summon their existing auxiliary UI"
|
||||
qml_matches "$shell_qml" 'shell\.pluginCloneMaySummon\( *currentManifest\( *\), *requestedId *\)' ||
|
||||
fail "built-in clones cannot summon their existing auxiliary UI"
|
||||
qml_matches "$shell_qml" '"omarchy\.media": *\["omarchy\.osd"\]' ||
|
||||
fail "media clones cannot summon their existing OSD target"
|
||||
qml_matches "$shell_qml" '"omarchy\.network": *\["omarchy\.speedtest", *"omarchy\.wifiqr"\]' ||
|
||||
fail "network clones cannot summon their existing auxiliary panels"
|
||||
pass "built-in service and widget clones retain narrow configuration and UI integration"
|
||||
|
||||
qml_matches "$shell_qml" 'shell\.serviceFor\( *shell\.pluginRegistry\.resolveEnabledId\( *id *\) *\)' ||
|
||||
fail "narrow first-party service proxies do not resolve enabled clones"
|
||||
qml_matches "$shell_qml" 'return serviceFor\( *shell\.pluginRegistry\.resolveEnabledId\( *pluginId *\) *\)' ||
|
||||
fail "trusted first-party service lookups do not resolve enabled clones"
|
||||
qml_matches "$shell_qml" 'allowOwnService *&& *shell\.pluginOwnsTarget\( *key, *requestedId *\)[^}]*return shell\.pluginServiceFor\( *key, *requestedId *\)' ||
|
||||
fail "cloned widgets cannot use a source id to reach their own service"
|
||||
pass "service facades resolve enabled clones without widening replacement-bar access"
|
||||
|
||||
require_compositor "plugin authentication boundary runtime test"
|
||||
|
||||
if ! command -v quickshell >/dev/null 2>&1; then
|
||||
pass "quickshell not installed; skipping plugin authentication boundary runtime test"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
require_command jq
|
||||
|
||||
TMPDIR=$(mktemp -d)
|
||||
result="$TMPDIR/result.json"
|
||||
log="$TMPDIR/quickshell.log"
|
||||
config_dir="$TMPDIR/plugin-auth-boundary"
|
||||
mkdir -p "$config_dir" "$TMPDIR/home"
|
||||
cp "$SHELL_TEST_DIR/fixtures/plugin-auth-boundary/"*.qml "$config_dir/"
|
||||
ln -s "$ROOT/shell/services" "$config_dir/services"
|
||||
|
||||
OMARCHY_QML_TEST_RESULT="$result" \
|
||||
HOME="$TMPDIR/home" \
|
||||
XDG_CONFIG_HOME="$TMPDIR/home/.config" \
|
||||
XDG_CACHE_HOME="$TMPDIR/home/.cache" \
|
||||
XDG_STATE_HOME="$TMPDIR/home/.local/state" \
|
||||
quickshell -p "$config_dir" --no-color >"$log" 2>&1 &
|
||||
QS_PID=$!
|
||||
|
||||
for _ in {1..80}; do
|
||||
[[ -s $result ]] && break
|
||||
if ! kill -0 "$QS_PID" 2>/dev/null; then
|
||||
sed -n '1,220p' "$log" >&2
|
||||
fail "plugin authentication boundary fixture exited before writing result"
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
[[ -s $result ]] || {
|
||||
sed -n '1,220p' "$log" >&2
|
||||
fail "plugin authentication boundary runtime test timed out"
|
||||
}
|
||||
|
||||
if ! jq -e '.ok == true' "$result" >/dev/null; then
|
||||
jq . "$result" >&2
|
||||
sed -n '1,220p' "$log" >&2
|
||||
fail "plugin authentication boundary runtime behavior"
|
||||
fi
|
||||
|
||||
pass "plugin authentication boundary runtime behavior"
|
||||
@@ -112,6 +112,200 @@ Item {
|
||||
}
|
||||
QML
|
||||
|
||||
# A replacement bar must not receive a generic factory for another plugin's
|
||||
# live service, and its barConfig must be a detached snapshot on both initial
|
||||
# injection and later host-config updates.
|
||||
victim_service_id="acme.victim-service"
|
||||
victim_service_dir="$test_home/.config/omarchy/plugins/$victim_service_id"
|
||||
mkdir -p "$victim_service_dir"
|
||||
cat >"$victim_service_dir/manifest.json" <<JSON
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "$victim_service_id",
|
||||
"name": "Victim Service",
|
||||
"version": "1.0.0",
|
||||
"kinds": ["service"],
|
||||
"entryPoints": {"service": "Service.qml"}
|
||||
}
|
||||
JSON
|
||||
cat >"$victim_service_dir/Service.qml" <<'QML'
|
||||
import QtQuick
|
||||
|
||||
Item {
|
||||
property string privateValue: "victim-secret"
|
||||
}
|
||||
QML
|
||||
|
||||
# A clone of the built-in media service exercises both supported service paths:
|
||||
# its own widget receives the raw companion service under the trusted bar, while
|
||||
# a replacement bar receives only the narrow media proxy resolved to the clone.
|
||||
media_clone_id="acme.media-clone"
|
||||
media_clone_dir="$test_home/.config/omarchy/plugins/$media_clone_id"
|
||||
mkdir -p "$media_clone_dir"
|
||||
cat >"$media_clone_dir/manifest.json" <<JSON
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "$media_clone_id",
|
||||
"name": "Media Clone",
|
||||
"version": "1.0.0",
|
||||
"kinds": ["service", "bar-widget"],
|
||||
"entryPoints": {"service": "Service.qml", "barWidget": "BarWidget.qml"},
|
||||
"barWidget": {"defaultSection": "center"},
|
||||
"omarchy": {"clonedFrom": "omarchy.media"}
|
||||
}
|
||||
JSON
|
||||
cat >"$media_clone_dir/Service.qml" <<'QML'
|
||||
import QtQuick
|
||||
import Quickshell.Io
|
||||
|
||||
Item {
|
||||
id: root
|
||||
property string marker: "clone-service"
|
||||
property bool enabled: true
|
||||
property var activePlayer: null
|
||||
property var sourcePlayers: []
|
||||
property var shell: null
|
||||
|
||||
function runAction(action, showFeedback, targetKey) {}
|
||||
function playerKey(player) { return "" }
|
||||
function selectPlayer(playerKey) {}
|
||||
|
||||
IpcHandler {
|
||||
target: "acme-media-clone-service"
|
||||
function ping(): string { return marker }
|
||||
function summonOsd(): string {
|
||||
return root.shell && root.shell.summon("omarchy.osd", "{}") ? "true" : "false"
|
||||
}
|
||||
}
|
||||
}
|
||||
QML
|
||||
cat >"$media_clone_dir/BarWidget.qml" <<'QML'
|
||||
import QtQuick
|
||||
import Quickshell.Io
|
||||
|
||||
Item {
|
||||
id: root
|
||||
property var bar: null
|
||||
|
||||
IpcHandler {
|
||||
target: "acme-media-clone-widget"
|
||||
function probeOwnService(): string {
|
||||
var service = root.bar && root.bar.shell
|
||||
? root.bar.shell.firstPartyServiceFor("omarchy.media") : null
|
||||
return JSON.stringify({
|
||||
reachable: !!service,
|
||||
marker: service ? String(service.marker || "") : ""
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
QML
|
||||
|
||||
review_bar_id="acme.review-bar"
|
||||
review_bar_dir="$test_home/.config/omarchy/plugins/$review_bar_id"
|
||||
mkdir -p "$review_bar_dir"
|
||||
cat >"$review_bar_dir/manifest.json" <<JSON
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "$review_bar_id",
|
||||
"name": "Review Bar",
|
||||
"version": "1.0.0",
|
||||
"kinds": ["bar", "service"],
|
||||
"keepLoaded": true,
|
||||
"entryPoints": {"bar": "Bar.qml", "service": "Service.qml"}
|
||||
}
|
||||
JSON
|
||||
cat >"$review_bar_dir/Bar.qml" <<'QML'
|
||||
import QtQuick
|
||||
import Quickshell.Io
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property var shell: null
|
||||
property var barConfig: ({})
|
||||
|
||||
IpcHandler {
|
||||
target: "acme-review-bar"
|
||||
|
||||
function probeVictim(): string {
|
||||
var genericFactory = root.shell
|
||||
&& typeof root.shell.pluginShellForId === "function"
|
||||
var entryFacade = root.shell
|
||||
&& typeof root.shell.pluginShellForBarEntry === "function"
|
||||
? root.shell.pluginShellForBarEntry("probe", "acme.victim-service") : null
|
||||
var victim = entryFacade && typeof entryFacade.serviceFor === "function"
|
||||
? entryFacade.serviceFor("acme.victim-service") : null
|
||||
return JSON.stringify({
|
||||
genericFactory: !!genericFactory,
|
||||
entryFacade: !!entryFacade,
|
||||
victimServiceReachable: !!victim
|
||||
})
|
||||
}
|
||||
|
||||
function snapshot(): string {
|
||||
return JSON.stringify(root.barConfig || {})
|
||||
}
|
||||
|
||||
function probeMediaProxy(): string {
|
||||
var service = root.shell
|
||||
? root.shell.firstPartyServiceFor("omarchy.media") : null
|
||||
return JSON.stringify({ reachable: !!service, enabled: service ? service.enabled === true : false })
|
||||
}
|
||||
|
||||
function probeMediaWidgetSummon(): string {
|
||||
var entryFacade = root.shell
|
||||
&& typeof root.shell.pluginShellForBarEntry === "function"
|
||||
? root.shell.pluginShellForBarEntry("probe-media", "acme.media-clone") : null
|
||||
return JSON.stringify({
|
||||
entryFacade: !!entryFacade,
|
||||
osdSummoned: entryFacade ? entryFacade.summon("omarchy.osd", "{}") : false,
|
||||
foreignSummoned: entryFacade ? entryFacade.summon("omarchy.lock", "{}") : false
|
||||
})
|
||||
}
|
||||
|
||||
function mutateSnapshot(): string {
|
||||
if (root.barConfig && root.barConfig.layout
|
||||
&& root.barConfig.layout.left && root.barConfig.layout.left.length > 0)
|
||||
root.barConfig.layout.left[0].id = "tampered.by.review-bar"
|
||||
return snapshot()
|
||||
}
|
||||
}
|
||||
}
|
||||
QML
|
||||
cat >"$review_bar_dir/Service.qml" <<'QML'
|
||||
import QtQuick
|
||||
import Quickshell.Io
|
||||
|
||||
Item {
|
||||
id: root
|
||||
property var shell: null
|
||||
property var retainedShell: null
|
||||
|
||||
onShellChanged: if (!retainedShell && shell) retainedShell = shell
|
||||
|
||||
function mutationAllowed(candidate) {
|
||||
if (!candidate) return false
|
||||
try {
|
||||
return typeof candidate.mutateShellConfig === "function"
|
||||
&& candidate.mutateShellConfig(function(config) {}) === true
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "acme-review-capability"
|
||||
function probe(): string {
|
||||
return JSON.stringify({
|
||||
currentAllowed: root.mutationAllowed(root.shell),
|
||||
retainedAllowed: root.mutationAllowed(root.retainedShell)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
QML
|
||||
|
||||
cat >"$stub_bin/omarchy-update-available" <<'SH'
|
||||
#!/bin/bash
|
||||
echo "Omarchy update available (test)"
|
||||
@@ -434,3 +628,122 @@ jq -e 'all(.bar.layout.right[]; (.id // .) != "omarchy.keyboard-layout")' \
|
||||
<<<"$(shell_ipc shell listShellConfig)" >/dev/null ||
|
||||
fail_with_log "bar put added a second copy of a widget already on the bar"
|
||||
pass "bar put leaves a widget already on the bar alone"
|
||||
|
||||
# Run the replacement-bar probes last: switching bar loaders can transiently
|
||||
# leave bar-aware panels without a visual host, which should not add noise to
|
||||
# the default-bar assertions above.
|
||||
[[ $(shell_ipc shell setPluginEnabled "$media_clone_id" true) == "ok" ]] ||
|
||||
fail_with_log "media clone fixture could not be enabled"
|
||||
clone_widget_probe=""
|
||||
for _ in {1..80}; do
|
||||
clone_widget_probe=$(shell_ipc acme-media-clone-widget probeOwnService 2>/dev/null || true)
|
||||
if jq -e '.reachable == true and .marker == "clone-service"' \
|
||||
<<<"$clone_widget_probe" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e '.reachable == true and .marker == "clone-service"' \
|
||||
<<<"$clone_widget_probe" >/dev/null || {
|
||||
printf 'Clone own-service probe: %s\n' "$clone_widget_probe" >&2
|
||||
fail_with_log "a cloned widget resolves its source id to its own companion service"
|
||||
}
|
||||
pass "trusted bar gives a cloned widget its own companion service"
|
||||
|
||||
[[ $(shell_ipc acme-media-clone-service summonOsd) == "true" ]] ||
|
||||
fail_with_log "a cloned media service cannot summon its existing OSD target"
|
||||
pass "a cloned built-in service retains its auxiliary UI integration"
|
||||
|
||||
[[ $(shell_ipc shell setPluginEnabled "$victim_service_id" true) == "ok" ]] ||
|
||||
fail_with_log "victim service fixture could not be enabled"
|
||||
[[ $(shell_ipc shell enablePlugin "$review_bar_id" '{}') == "ok" ]] ||
|
||||
fail_with_log "replacement-bar fixture could not be enabled"
|
||||
|
||||
review_probe=""
|
||||
for _ in {1..80}; do
|
||||
review_probe=$(shell_ipc acme-review-bar probeVictim 2>/dev/null || true)
|
||||
if jq -e '.genericFactory == false and .entryFacade == false and .victimServiceReachable == false' \
|
||||
<<<"$review_probe" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
if ! kill -0 "$QS_PID" 2>/dev/null; then
|
||||
fail_with_log "test shell exited while loading the replacement-bar fixture"
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e '.genericFactory == false and .entryFacade == false and .victimServiceReachable == false' \
|
||||
<<<"$review_probe" >/dev/null || {
|
||||
printf 'Replacement-bar service probe: %s\n' "$review_probe" >&2
|
||||
fail_with_log "replacement bar cannot recover another plugin's live service"
|
||||
}
|
||||
|
||||
media_proxy_probe=$(shell_ipc acme-review-bar probeMediaProxy)
|
||||
jq -e '.reachable == true and .enabled == true' <<<"$media_proxy_probe" >/dev/null || {
|
||||
printf 'Replacement-bar media proxy probe: %s\n' "$media_proxy_probe" >&2
|
||||
fail_with_log "replacement-bar service proxies resolve enabled clones"
|
||||
}
|
||||
|
||||
media_summon_probe=$(shell_ipc acme-review-bar probeMediaWidgetSummon)
|
||||
jq -e '.entryFacade == true and .osdSummoned == true and .foreignSummoned == false' \
|
||||
<<<"$media_summon_probe" >/dev/null || {
|
||||
printf 'Replacement-bar media summon probe: %s\n' "$media_summon_probe" >&2
|
||||
fail_with_log "replacement-bar clone facades retain only their auxiliary UI integration"
|
||||
}
|
||||
|
||||
bar_config_before=$(shell_ipc shell listShellConfig | jq -c '.bar')
|
||||
shell_ipc acme-review-bar mutateSnapshot >/dev/null
|
||||
bar_config_after=$(shell_ipc shell listShellConfig | jq -c '.bar')
|
||||
[[ $bar_config_after == "$bar_config_before" ]] ||
|
||||
fail_with_log "replacement bar mutated the initially injected host configuration"
|
||||
|
||||
[[ $(shell_ipc shell setBarWidget omarchy.clock format '"HH:mm:ss"' '{}') == "ok" ]] ||
|
||||
fail_with_log "host bar configuration could not be updated for snapshot testing"
|
||||
updated_snapshot=""
|
||||
for _ in {1..80}; do
|
||||
updated_snapshot=$(shell_ipc acme-review-bar snapshot 2>/dev/null || true)
|
||||
if jq -e 'any(.layout.center[]; (.id // .) == "omarchy.clock" and .format == "HH:mm:ss")' \
|
||||
<<<"$updated_snapshot" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e 'any(.layout.center[]; (.id // .) == "omarchy.clock" and .format == "HH:mm:ss")' \
|
||||
<<<"$updated_snapshot" >/dev/null ||
|
||||
fail_with_log "replacement bar did not receive the refreshed configuration snapshot"
|
||||
bar_config_before=$(shell_ipc shell listShellConfig | jq -c '.bar')
|
||||
shell_ipc acme-review-bar mutateSnapshot >/dev/null
|
||||
bar_config_after=$(shell_ipc shell listShellConfig | jq -c '.bar')
|
||||
[[ $bar_config_after == "$bar_config_before" ]] ||
|
||||
fail_with_log "replacement bar mutated a refreshed host configuration"
|
||||
|
||||
pass "replacement-bar service and configuration boundaries hold at runtime"
|
||||
|
||||
capability_before=$(shell_ipc acme-review-capability probe)
|
||||
jq -e '.currentAllowed == true and .retainedAllowed == true' \
|
||||
<<<"$capability_before" >/dev/null ||
|
||||
fail_with_log "bar service fixture did not initially receive bar capabilities"
|
||||
|
||||
# Keep the same enabled plugin ID and service instance while dropping the bar
|
||||
# kind. Both the currently injected facade and a reference retained by the
|
||||
# plugin must lose the old configuration capability after the manifest rescan.
|
||||
jq '.kinds = ["service"] | .entryPoints = {"service": "Service.qml"}' \
|
||||
"$review_bar_dir/manifest.json" >"$review_bar_dir/manifest.json.tmp"
|
||||
mv "$review_bar_dir/manifest.json.tmp" "$review_bar_dir/manifest.json"
|
||||
capability_after=""
|
||||
for _ in {1..80}; do
|
||||
capability_after=$(shell_ipc acme-review-capability probe 2>/dev/null || true)
|
||||
if jq -e '.currentAllowed == false and .retainedAllowed == false' \
|
||||
<<<"$capability_after" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
if ! kill -0 "$QS_PID" 2>/dev/null; then
|
||||
fail_with_log "test shell exited while revoking changed manifest capabilities"
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e '.currentAllowed == false and .retainedAllowed == false' \
|
||||
<<<"$capability_after" >/dev/null || {
|
||||
printf 'Capability revocation probe: %s\n' "$capability_after" >&2
|
||||
fail_with_log "cached plugin facades revoke capabilities removed from the manifest"
|
||||
}
|
||||
pass "manifest reload revokes cached facade capabilities"
|
||||
@@ -0,0 +1,705 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(dirname "$0")/base-test.sh"
|
||||
|
||||
migration="$ROOT/migrations/1788662350.sh"
|
||||
test_tmp=$(mktemp -d -p /tmp)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
|
||||
mock_omarchy="$test_tmp/omarchy"
|
||||
sleep_dir="$test_tmp/system-sleep"
|
||||
systemd_dir="$test_tmp/systemd"
|
||||
drop_in="$systemd_dir/supergfxd.service.d/delay-start.conf"
|
||||
quarantine="$test_tmp/quarantine"
|
||||
reload_needed_marker="$test_tmp/reload-needed"
|
||||
migration_copy="$test_tmp/migration.sh"
|
||||
stub_bin="$test_tmp/bin"
|
||||
calls="$test_tmp/calls"
|
||||
|
||||
mkdir -p "$mock_omarchy/default/systemd/system-sleep" \
|
||||
"$mock_omarchy/default/systemd/system/supergfxd.service.d" \
|
||||
"$sleep_dir" "${drop_in%/*}" "$stub_bin"
|
||||
cp "$ROOT/default/systemd/system-sleep/keyboard-backlight" \
|
||||
"$mock_omarchy/default/systemd/system-sleep/keyboard-backlight"
|
||||
cp "$ROOT/default/systemd/system-sleep/force-igpu" \
|
||||
"$mock_omarchy/default/systemd/system-sleep/force-igpu"
|
||||
cp "$ROOT/default/systemd/system/supergfxd.service.d/delay-start.conf" \
|
||||
"$mock_omarchy/default/systemd/system/supergfxd.service.d/delay-start.conf"
|
||||
|
||||
[[ $(grep -Fxc 'system_sleep_dir=/usr/lib/systemd/system-sleep' "$migration") == 1 ]] ||
|
||||
fail "migration fixes one literal system-sleep directory"
|
||||
[[ $(grep -Fxc 'supergfxd_drop_in=/etc/systemd/system/supergfxd.service.d/delay-start.conf' "$migration") == 1 ]] ||
|
||||
fail "migration fixes one literal supergfxd drop-in"
|
||||
|
||||
sed \
|
||||
-e "s|system_sleep_dir=/usr/lib/systemd/system-sleep|system_sleep_dir=$sleep_dir|" \
|
||||
-e "s|supergfxd_drop_in=/etc/systemd/system/supergfxd.service.d/delay-start.conf|supergfxd_drop_in=$drop_in|" \
|
||||
-e "s|quarantine_root=/var/lib/omarchy/migrations/1788662350-system-sleep|quarantine_root=$quarantine|" \
|
||||
-e "s|/var/lib/omarchy/migrations/1788662350-systemd-reload-needed|$reload_needed_marker|" \
|
||||
-e "s|/usr/bin/stat|$stub_bin/stat|g" \
|
||||
-e "s|/usr/bin/readlink|$stub_bin/readlink|g" \
|
||||
"$migration" >"$migration_copy"
|
||||
|
||||
cat >"$stub_bin/stat" <<'SH'
|
||||
#!/bin/bash
|
||||
|
||||
path=${!#}
|
||||
if [[ :${INACCESSIBLE_AS_USER:-}: == *":$path:"* && ${FAKE_SUDO:-0} == 0 ]]; then
|
||||
exit 13
|
||||
fi
|
||||
|
||||
actual_file_mode=$(/usr/bin/stat -c '%f' -- "$path") || exit 1
|
||||
actual_mode=$(/usr/bin/stat -c '%a' -- "$path") || exit 1
|
||||
|
||||
if [[ :${FAKE_ROOT_DIRS:-}: == *":$path:"* ]]; then
|
||||
uid=0
|
||||
gid=0
|
||||
mode=$(printf '%o' "$((8#$actual_mode & ~8#022))")
|
||||
elif [[ :${FAKE_ROOT_FILES:-}: == *":$path:"* ]]; then
|
||||
uid=0
|
||||
gid=${FAKE_ROOT_GID:-0}
|
||||
mode=${FAKE_ROOT_MODE:-$actual_mode}
|
||||
else
|
||||
exec /usr/bin/stat "$@"
|
||||
fi
|
||||
|
||||
file_type=$((16#$actual_file_mode & 16#f000))
|
||||
file_mode=$(printf '%x' "$((file_type | 8#$mode))")
|
||||
|
||||
case "$*" in
|
||||
*"%f %u %g %a"*) printf '%s %s %s %s\n' "$file_mode" "$uid" "$gid" "$mode" ;;
|
||||
*"%u %g %a"*) printf '%s %s %s\n' "$uid" "$gid" "$mode" ;;
|
||||
*"%a"*) printf '%s\n' "$mode" ;;
|
||||
*) exec /usr/bin/stat "$@" ;;
|
||||
esac
|
||||
SH
|
||||
|
||||
cat >"$stub_bin/readlink" <<'SH'
|
||||
#!/bin/bash
|
||||
|
||||
path=${!#}
|
||||
if [[ :${INACCESSIBLE_AS_USER:-}: == *":$path:"* && ${FAKE_SUDO:-0} == 0 ]]; then
|
||||
exit 13
|
||||
fi
|
||||
|
||||
exec /usr/bin/readlink "$@"
|
||||
SH
|
||||
chmod +x "$stub_bin/stat" "$stub_bin/readlink"
|
||||
|
||||
cat >"$stub_bin/sudo" <<'SH'
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
printf 'sudo' >>"$CALLS"
|
||||
printf '\t%s' "$@" >>"$CALLS"
|
||||
printf '\n' >>"$CALLS"
|
||||
|
||||
case "$1" in
|
||||
*/stat | */readlink)
|
||||
FAKE_SUDO=1 exec "$@"
|
||||
;;
|
||||
/usr/bin/test)
|
||||
shift
|
||||
if [[ $1 == "-x" && :${FAKE_ROOT_DIRS:-}: == *":$2:"* ]]; then
|
||||
exit 0
|
||||
else
|
||||
exec /usr/bin/test "$@"
|
||||
fi
|
||||
;;
|
||||
/usr/bin/mktemp | /usr/bin/mv | /usr/bin/chmod | /usr/bin/cp | /usr/bin/rm)
|
||||
exec "$@"
|
||||
;;
|
||||
/usr/bin/systemctl)
|
||||
if [[ -n ${SYSTEMCTL_FAIL_ONCE_FILE:-} && -e $SYSTEMCTL_FAIL_ONCE_FILE ]]; then
|
||||
/usr/bin/rm -f -- "$SYSTEMCTL_FAIL_ONCE_FILE"
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
;;
|
||||
/usr/bin/install)
|
||||
shift
|
||||
args=()
|
||||
while (($#)); do
|
||||
case "$1" in
|
||||
-o | -g)
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
args+=("$1")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
exec /usr/bin/install "${args[@]}"
|
||||
;;
|
||||
*)
|
||||
printf 'unexpected sudo command: %s\n' "$*" >&2
|
||||
exit 97
|
||||
;;
|
||||
esac
|
||||
SH
|
||||
chmod +x "$stub_bin/sudo"
|
||||
|
||||
run_migration() {
|
||||
local fake_root_dirs
|
||||
|
||||
: >"$calls"
|
||||
fake_root_dirs="/:/tmp:$test_tmp:$sleep_dir:$systemd_dir:${drop_in%/*}"
|
||||
[[ -z ${EXTRA_FAKE_ROOT_DIRS:-} ]] || fake_root_dirs+=":$EXTRA_FAKE_ROOT_DIRS"
|
||||
|
||||
CALLS="$calls" \
|
||||
FAKE_ROOT_DIRS="$fake_root_dirs" \
|
||||
FAKE_ROOT_FILES="${FAKE_ROOT_FILES:-${2:-}}" \
|
||||
FAKE_ROOT_MODE="${FAKE_ROOT_MODE:-${3:-}}" \
|
||||
FAKE_ROOT_GID="${FAKE_ROOT_GID:-0}" \
|
||||
INACCESSIBLE_AS_USER="${INACCESSIBLE_AS_USER:-}" \
|
||||
SYSTEMCTL_FAIL_ONCE_FILE="${SYSTEMCTL_FAIL_ONCE_FILE:-}" \
|
||||
OMARCHY_PATH="$mock_omarchy" \
|
||||
PATH="$stub_bin:$PATH" bash -euo pipefail "$migration_copy" >/dev/null
|
||||
}
|
||||
|
||||
printf 'attacker keyboard\n' >"$sleep_dir/keyboard-backlight"
|
||||
printf 'attacker gpu\n' >"$sleep_dir/force-igpu"
|
||||
printf 'attacker drop-in\n' >"$drop_in"
|
||||
chmod 0777 "$sleep_dir/keyboard-backlight" "$sleep_dir/force-igpu"
|
||||
chmod 0666 "$drop_in"
|
||||
exec 9>>"$sleep_dir/keyboard-backlight"
|
||||
|
||||
run_migration Integrated
|
||||
printf 'write through stale attacker descriptor\n' >&9
|
||||
exec 9>&-
|
||||
|
||||
cmp -s "$mock_omarchy/default/systemd/system-sleep/keyboard-backlight" "$sleep_dir/keyboard-backlight" ||
|
||||
fail "migration replaces the user-owned keyboard hook with trusted content"
|
||||
cmp -s "$mock_omarchy/default/systemd/system-sleep/force-igpu" "$sleep_dir/force-igpu" ||
|
||||
fail "migration replaces the user-owned GPU hook with trusted content"
|
||||
cmp -s "$mock_omarchy/default/systemd/system/supergfxd.service.d/delay-start.conf" "$drop_in" ||
|
||||
fail "migration replaces the user-owned root service drop-in with trusted content"
|
||||
[[ $(stat -c '%a' "$sleep_dir/keyboard-backlight") == 755 ]] ||
|
||||
fail "migration activates the repaired keyboard hook"
|
||||
[[ $(stat -c '%a' "$sleep_dir/force-igpu") == 755 ]] ||
|
||||
fail "migration activates force-igpu only in Integrated mode"
|
||||
[[ $(stat -c '%a' "$drop_in") == 644 ]] ||
|
||||
fail "migration installs the service drop-in as configuration"
|
||||
grep -Fx $'sudo\t/usr/bin/systemctl\tdaemon-reload' "$calls" >/dev/null ||
|
||||
fail "migration reloads systemd after repairing its root service drop-in"
|
||||
[[ ! -e $reload_needed_marker ]] ||
|
||||
fail "migration leaves a reload marker after systemd accepted the repaired drop-in"
|
||||
[[ $(stat -c '%a' "$quarantine") == 700 ]] ||
|
||||
fail "migration keeps preserved unsafe custom content in a root-only directory"
|
||||
keyboard_backup=$(find "$quarantine" -path '*/keyboard-backlight.*/original' -type f -print -quit)
|
||||
force_backup=$(find "$quarantine" -path '*/force-igpu.*/original' -type f -print -quit)
|
||||
drop_in_backup=$(find "$quarantine" -path '*/delay-start.conf.*/original' -type f -print -quit)
|
||||
grep -Fxq 'attacker keyboard' "$keyboard_backup" ||
|
||||
fail "migration preserves unknown keyboard-hook content before replacing it"
|
||||
grep -Fxq 'attacker gpu' "$force_backup" ||
|
||||
fail "migration preserves unknown force-iGPU content before replacing it"
|
||||
grep -Fxq 'attacker drop-in' "$drop_in_backup" ||
|
||||
fail "migration preserves unknown service-drop-in content before replacing it"
|
||||
pass "migration replaces writable privileged files with trusted root-owned copies"
|
||||
|
||||
backup_count=$(find "$quarantine" -mindepth 2 -maxdepth 2 -name original | wc -l)
|
||||
FAKE_ROOT_FILES="$sleep_dir/keyboard-backlight:$sleep_dir/force-igpu:$drop_in" \
|
||||
run_migration Integrated
|
||||
[[ ! -s $calls ]] ||
|
||||
fail "migration changes already-repaired privileged files on a second run" "$(<"$calls")"
|
||||
[[ $(find "$quarantine" -mindepth 2 -maxdepth 2 -name original | wc -l) == "$backup_count" ]] ||
|
||||
fail "migration creates duplicate quarantines on a second run"
|
||||
pass "migration is idempotent after repairing unsafe privileged files"
|
||||
|
||||
printf 'attacker drop-in\n' >"$drop_in"
|
||||
chmod 0666 "$drop_in"
|
||||
reload_failure="$test_tmp/fail-systemd-reload-once"
|
||||
touch "$reload_failure"
|
||||
set +e
|
||||
SYSTEMCTL_FAIL_ONCE_FILE="$reload_failure" \
|
||||
FAKE_ROOT_FILES="$sleep_dir/keyboard-backlight:$sleep_dir/force-igpu" \
|
||||
run_migration Integrated
|
||||
reload_status=$?
|
||||
set -e
|
||||
(( reload_status != 0 )) ||
|
||||
fail "migration reports success after systemd rejects the repaired drop-in"
|
||||
cmp -s "$mock_omarchy/default/systemd/system/supergfxd.service.d/delay-start.conf" "$drop_in" ||
|
||||
fail "migration does not repair the drop-in before the simulated reload failure"
|
||||
[[ -e $reload_needed_marker && $(stat -c '%a' "$reload_needed_marker") == 644 ]] ||
|
||||
fail "migration does not persist the reload requirement before replacing the drop-in"
|
||||
|
||||
FAKE_ROOT_FILES="$sleep_dir/keyboard-backlight:$sleep_dir/force-igpu:$drop_in" \
|
||||
run_migration Integrated
|
||||
grep -Fx $'sudo\t/usr/bin/systemctl\tdaemon-reload' "$calls" >/dev/null ||
|
||||
fail "migration does not retry a failed reload after the drop-in is already safe"
|
||||
[[ ! -e $reload_needed_marker ]] ||
|
||||
fail "migration does not clear the reload requirement after a successful retry"
|
||||
|
||||
FAKE_ROOT_FILES="$sleep_dir/keyboard-backlight:$sleep_dir/force-igpu:$drop_in" \
|
||||
run_migration Integrated
|
||||
[[ ! -s $calls ]] ||
|
||||
fail "migration repeats a successfully completed reload repair" "$(<"$calls")"
|
||||
pass "migration persists and retries systemd reload after failure or interruption"
|
||||
|
||||
keyboard_backup_count=$(find "$quarantine" -path '*/keyboard-backlight.*/original' | wc -l)
|
||||
cp "$mock_omarchy/default/systemd/system-sleep/keyboard-backlight" \
|
||||
"$sleep_dir/keyboard-backlight"
|
||||
chmod 0644 "$sleep_dir/keyboard-backlight"
|
||||
FAKE_ROOT_FILES="$sleep_dir/force-igpu:$drop_in" run_migration Integrated
|
||||
[[ $(stat -c '%a' "$sleep_dir/keyboard-backlight") == 755 ]] ||
|
||||
fail "migration does not safely activate a user-owned canonical hook"
|
||||
[[ $(find "$quarantine" -path '*/keyboard-backlight.*/original' | wc -l) == "$keyboard_backup_count" ]] ||
|
||||
fail "migration quarantines an exact legacy artifact as administrator content"
|
||||
pass "migration replaces exact vulnerable installer artifacts without inventing backups"
|
||||
|
||||
legacy_keyboard="$test_tmp/legacy-keyboard-backlight"
|
||||
cat >"$legacy_keyboard" <<'SH'
|
||||
#!/bin/bash
|
||||
|
||||
# Turn off keyboard backlight before hibernate to prevent hang on power-off.
|
||||
# The ASUS keyboard controller can block S4 shutdown if LEDs are active.
|
||||
|
||||
if [[ $1 == "pre" && $2 == "hibernate" ]]; then
|
||||
device=""
|
||||
for candidate in /sys/class/leds/*kbd_backlight*; do
|
||||
if [[ -e "$candidate" ]]; then
|
||||
device="$(basename "$candidate")"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -n "$device" ]]; then
|
||||
brightnessctl -d "$device" set 0 >/dev/null 2>&1
|
||||
fi
|
||||
fi
|
||||
SH
|
||||
[[ $(sha256sum "$legacy_keyboard" | cut -d' ' -f1) == f313a81e47401f0d38b8602e5997f52c5286d5e97f74027564ddd515b3d16511 ]] ||
|
||||
fail "keyboard-backlight legacy fixture no longer matches the migration fingerprint"
|
||||
keyboard_backup_count=$(find "$quarantine" -path '*/keyboard-backlight.*/original' | wc -l)
|
||||
cp "$legacy_keyboard" "$sleep_dir/keyboard-backlight"
|
||||
chmod 0644 "$sleep_dir/keyboard-backlight"
|
||||
FAKE_ROOT_FILES="$sleep_dir/keyboard-backlight:$sleep_dir/force-igpu:$drop_in" \
|
||||
run_migration Integrated
|
||||
cmp -s "$mock_omarchy/default/systemd/system-sleep/keyboard-backlight" "$sleep_dir/keyboard-backlight" ||
|
||||
fail "migration does not upgrade the released keyboard-backlight hook"
|
||||
[[ $(stat -c '%a' "$sleep_dir/keyboard-backlight") == 755 ]] ||
|
||||
fail "migration leaves the released keyboard-backlight hook non-executable"
|
||||
[[ $(find "$quarantine" -path '*/keyboard-backlight.*/original' | wc -l) == "$keyboard_backup_count" ]] ||
|
||||
fail "migration quarantines the released keyboard hook as administrator content"
|
||||
pass "migration activates the released root-owned keyboard-backlight hook"
|
||||
|
||||
cp "$legacy_keyboard" "$sleep_dir/keyboard-backlight"
|
||||
chmod 0755 "$sleep_dir/keyboard-backlight"
|
||||
FAKE_ROOT_FILES="$sleep_dir/keyboard-backlight:$sleep_dir/force-igpu:$drop_in" \
|
||||
run_migration Integrated
|
||||
cmp -s "$mock_omarchy/default/systemd/system-sleep/keyboard-backlight" "$sleep_dir/keyboard-backlight" ||
|
||||
fail "migration mistakes executable released hook bytes for a current artifact"
|
||||
pass "migration refreshes recognized legacy hook contents at the final mode"
|
||||
|
||||
printf 'attacker gpu\n' >"$sleep_dir/force-igpu"
|
||||
chmod 0777 "$sleep_dir/force-igpu"
|
||||
run_migration Hybrid
|
||||
[[ $(stat -c '%a' "$sleep_dir/force-igpu") == 755 ]] ||
|
||||
fail "migration does not activate the trusted self-guarding force-igpu hook"
|
||||
pass "migration repairs force-igpu without depending on a live GPU-mode query"
|
||||
|
||||
printf 'administrator customization\n' >"$sleep_dir/keyboard-backlight"
|
||||
chmod 0755 "$sleep_dir/keyboard-backlight"
|
||||
run_migration Integrated "$sleep_dir/keyboard-backlight" 755
|
||||
grep -Fxq 'administrator customization' "$sleep_dir/keyboard-backlight" ||
|
||||
fail "migration preserves a secure administrator-owned custom hook"
|
||||
|
||||
cp "$mock_omarchy/default/systemd/system-sleep/keyboard-backlight" "$sleep_dir/keyboard-backlight"
|
||||
chmod 0644 "$sleep_dir/keyboard-backlight"
|
||||
run_migration Integrated "$sleep_dir/keyboard-backlight" 644
|
||||
[[ $(stat -c '%a' "$sleep_dir/keyboard-backlight") == 755 ]] ||
|
||||
fail "migration leaves an exact packaged keyboard hook non-executable"
|
||||
|
||||
printf 'administrator customization\n' >"$sleep_dir/keyboard-backlight"
|
||||
chmod 0644 "$sleep_dir/keyboard-backlight"
|
||||
run_migration Integrated "$sleep_dir/keyboard-backlight" 644
|
||||
[[ $(stat -c '%a' "$sleep_dir/keyboard-backlight") == 644 ]] ||
|
||||
fail "migration changes the mode of a safe noncanonical administrator hook"
|
||||
grep -Fxq 'administrator customization' "$sleep_dir/keyboard-backlight" ||
|
||||
fail "migration replaces a safe noncanonical administrator hook"
|
||||
pass "migration activates only exact packaged hooks while preserving safe custom files"
|
||||
|
||||
legacy_force_igpu="$test_tmp/legacy-force-igpu"
|
||||
cat >"$legacy_force_igpu" <<'SH'
|
||||
#!/bin/bash
|
||||
|
||||
# Use the Vfio to Integrated trick to turn off NVIDIA dgpu when in integrated mode
|
||||
# without needing to restart the computer. This is needed because computers like the Asus G14
|
||||
# will wake after suspend in Hybrid mode, even if the system was in Integrated mode before
|
||||
# suspending.
|
||||
|
||||
case "$1" in
|
||||
pre)
|
||||
# Before hibernating, switch to Vfio so the nvidia driver is detached from the dGPU.
|
||||
# Without this, hibernate resume fails because the nvidia driver can't freeze a
|
||||
# powered-off dGPU (returns -EIO), which aborts the entire resume.
|
||||
if [[ $2 == "hibernate" ]]; then
|
||||
/usr/bin/supergfxctl -m Vfio
|
||||
sleep 1
|
||||
fi
|
||||
;;
|
||||
post)
|
||||
# small delay so the device is fully re-enumerated
|
||||
sleep 4
|
||||
|
||||
# force-bind dGPU to vfio (fully detached from nvidia)
|
||||
/usr/bin/supergfxctl -m Vfio
|
||||
sleep 1
|
||||
|
||||
# then go back to Integrated, which powers it off again
|
||||
/usr/bin/supergfxctl -m Integrated
|
||||
;;
|
||||
esac
|
||||
SH
|
||||
[[ $(sha256sum "$legacy_force_igpu" | cut -d' ' -f1) == d604e7c4903829563e45fc52188fc5602c3f1bc66e247f0a2cc0a974ed6e57db ]] ||
|
||||
fail "force-igpu legacy fixture no longer matches the migration fingerprint"
|
||||
cp "$legacy_force_igpu" "$sleep_dir/force-igpu"
|
||||
chmod 0644 "$sleep_dir/force-igpu"
|
||||
FAKE_ROOT_FILES="$sleep_dir/keyboard-backlight:$sleep_dir/force-igpu:$drop_in" \
|
||||
run_migration Integrated
|
||||
cmp -s "$mock_omarchy/default/systemd/system-sleep/force-igpu" "$sleep_dir/force-igpu" ||
|
||||
fail "migration does not upgrade the exact legacy force-igpu hook"
|
||||
[[ $(stat -c '%a' "$sleep_dir/force-igpu") == 755 ]] ||
|
||||
fail "migration leaves the exact legacy force-igpu hook non-executable"
|
||||
pass "migration activates the exact legacy force-igpu artifact with its new guard"
|
||||
|
||||
printf 'wheel-managed customization\n' >"$sleep_dir/keyboard-backlight"
|
||||
chmod 0755 "$sleep_dir/keyboard-backlight"
|
||||
FAKE_ROOT_FILES="$sleep_dir/keyboard-backlight:$sleep_dir/force-igpu:$drop_in" \
|
||||
FAKE_ROOT_GID=10 run_migration Integrated
|
||||
grep -Fxq 'wheel-managed customization' "$sleep_dir/keyboard-backlight" ||
|
||||
fail "migration replaces a safe root:wheel administrator hook"
|
||||
[[ ! -s $calls ]] ||
|
||||
fail "migration escalates while preserving safe root:wheel entries"
|
||||
pass "migration treats non-writable root-owned files as safe regardless of group"
|
||||
|
||||
admin_dir="$test_tmp/admin-hooks"
|
||||
admin_keyboard="$admin_dir/keyboard"
|
||||
admin_delay="$admin_dir/delay.conf"
|
||||
mkdir -p "$admin_dir"
|
||||
printf 'protected keyboard customization\n' >"$admin_keyboard"
|
||||
printf 'protected delay customization\n' >"$admin_delay"
|
||||
chmod 0755 "$admin_keyboard"
|
||||
chmod 0644 "$admin_delay"
|
||||
rm -f "$sleep_dir/keyboard-backlight" "$drop_in"
|
||||
ln -s "$admin_keyboard" "$sleep_dir/keyboard-backlight"
|
||||
ln -s "$admin_delay" "$drop_in"
|
||||
|
||||
EXTRA_FAKE_ROOT_DIRS="$admin_dir" \
|
||||
FAKE_ROOT_FILES="$admin_keyboard:$admin_delay:$sleep_dir/force-igpu" \
|
||||
FAKE_ROOT_GID=10 run_migration Integrated
|
||||
[[ -L $sleep_dir/keyboard-backlight && $(readlink "$sleep_dir/keyboard-backlight") == "$admin_keyboard" ]] ||
|
||||
fail "migration replaces a safe administrator-managed keyboard-hook symlink"
|
||||
[[ -L $drop_in && $(readlink "$drop_in") == "$admin_delay" ]] ||
|
||||
fail "migration replaces a safe administrator-managed service-drop-in symlink"
|
||||
[[ ! -s $calls ]] ||
|
||||
fail "migration escalates while preserving safe administrator symlinks"
|
||||
pass "migration preserves symlinks whose full target paths are root-controlled"
|
||||
|
||||
dangling_target="$admin_dir/future-keyboard"
|
||||
rm -f "$sleep_dir/keyboard-backlight" "$dangling_target"
|
||||
ln -s "$dangling_target" "$sleep_dir/keyboard-backlight"
|
||||
|
||||
EXTRA_FAKE_ROOT_DIRS="$admin_dir" \
|
||||
FAKE_ROOT_FILES="$admin_delay:$sleep_dir/force-igpu:$drop_in" \
|
||||
run_migration Integrated
|
||||
[[ -L $sleep_dir/keyboard-backlight && $(readlink "$sleep_dir/keyboard-backlight") == "$dangling_target" ]] ||
|
||||
fail "migration replaces a safe dangling administrator symlink"
|
||||
[[ ! -s $calls ]] ||
|
||||
fail "migration asks for sudo to verify an absent target below a searchable root-controlled directory"
|
||||
pass "migration handles safe dangling administrator symlinks without sudo"
|
||||
|
||||
escaping_user_dir="$test_tmp/escaping-user-hooks"
|
||||
escaping_user_hook="$escaping_user_dir/keyboard"
|
||||
escaping_missing_dir="$admin_dir/future"
|
||||
escaping_target="$escaping_missing_dir/../../escaping-user-hooks/keyboard"
|
||||
mkdir -p "$escaping_user_dir"
|
||||
printf 'future unsafe keyboard customization\n' >"$escaping_user_hook"
|
||||
chmod 0755 "$escaping_user_hook"
|
||||
rm -f "$sleep_dir/keyboard-backlight"
|
||||
ln -s "$escaping_target" "$sleep_dir/keyboard-backlight"
|
||||
|
||||
EXTRA_FAKE_ROOT_DIRS="$admin_dir" \
|
||||
FAKE_ROOT_FILES="$admin_delay:$sleep_dir/force-igpu:$drop_in" \
|
||||
run_migration Integrated
|
||||
[[ ! -L $sleep_dir/keyboard-backlight ]] ||
|
||||
fail "migration trusts a dangling symlink whose unresolved suffix escapes to a user-controlled path"
|
||||
cmp -s "$mock_omarchy/default/systemd/system-sleep/keyboard-backlight" \
|
||||
"$sleep_dir/keyboard-backlight" ||
|
||||
fail "migration does not replace a future user-controlled dangling symlink"
|
||||
pass "migration resolves the full dangling-symlink suffix before trusting it"
|
||||
|
||||
protected_dir="$test_tmp/root-only-hooks"
|
||||
protected_target="$protected_dir/target"
|
||||
protected_bridge="$protected_dir/bridge"
|
||||
mkdir -p "$protected_dir"
|
||||
printf 'root-only administrator customization\n' >"$protected_target"
|
||||
ln -s "$protected_target" "$protected_bridge"
|
||||
chmod 0700 "$protected_dir"
|
||||
rm -f "$sleep_dir/keyboard-backlight"
|
||||
ln -s "$protected_bridge" "$sleep_dir/keyboard-backlight"
|
||||
|
||||
EXTRA_FAKE_ROOT_DIRS="$admin_dir:$protected_dir" \
|
||||
FAKE_ROOT_FILES="$protected_target:$admin_delay:$sleep_dir/force-igpu:$drop_in" \
|
||||
INACCESSIBLE_AS_USER="$protected_bridge:$protected_target" \
|
||||
run_migration Integrated
|
||||
[[ -L $sleep_dir/keyboard-backlight && $(readlink "$sleep_dir/keyboard-backlight") == "$protected_bridge" ]] ||
|
||||
fail "migration replaces a safe symlink whose target is hidden by a root-only directory"
|
||||
grep -q $'^sudo\t.*/stat\t-c\t%f %u %g %a\t--\t.*/root-only-hooks/bridge$' "$calls" ||
|
||||
fail "migration does not inspect inaccessible symlink metadata with privilege"
|
||||
grep -q $'^sudo\t.*/readlink\t--\t.*/root-only-hooks/bridge$' "$calls" ||
|
||||
fail "migration does not resolve an inaccessible administrator symlink with privilege"
|
||||
grep -q $'^sudo\t.*/stat\t-c\t%f %u %g %a\t--\t.*/root-only-hooks/target$' "$calls" ||
|
||||
fail "migration does not inspect an inaccessible administrator target with privilege"
|
||||
pass "migration preserves root-controlled symlink chains hidden from the invoking user"
|
||||
|
||||
protected_dangling_dir="$test_tmp/root-only-dangling"
|
||||
protected_dangling_target="$protected_dangling_dir/future-keyboard"
|
||||
mkdir -p "$protected_dangling_dir"
|
||||
chmod 0000 "$protected_dangling_dir"
|
||||
rm -f "$sleep_dir/keyboard-backlight"
|
||||
ln -s "$protected_dangling_target" "$sleep_dir/keyboard-backlight"
|
||||
|
||||
EXTRA_FAKE_ROOT_DIRS="$admin_dir:$protected_dangling_dir" \
|
||||
FAKE_ROOT_FILES="$admin_delay:$sleep_dir/force-igpu:$drop_in" \
|
||||
INACCESSIBLE_AS_USER="$protected_dangling_target" \
|
||||
run_migration Integrated
|
||||
[[ -L $sleep_dir/keyboard-backlight && $(readlink "$sleep_dir/keyboard-backlight") == "$protected_dangling_target" ]] ||
|
||||
fail "migration replaces a safe dangling symlink below a root-only directory"
|
||||
grep -q $'^sudo\t.*/stat\t-c\t%f %u %g %a\t--\t.*/root-only-dangling/future-keyboard$' "$calls" ||
|
||||
fail "migration does not inspect a protected dangling target with privilege"
|
||||
grep -q $'^sudo\t/usr/bin/test\t-x\t.*/root-only-dangling$' "$calls" ||
|
||||
fail "migration does not distinguish a protected missing target from an inaccessible parent"
|
||||
pass "migration preserves dangling administrator symlinks below root-only directories"
|
||||
|
||||
user_dir="$test_tmp/user-hooks"
|
||||
user_keyboard="$user_dir/keyboard"
|
||||
mkdir -p "$user_dir"
|
||||
printf 'unsafe symlink customization\n' >"$user_keyboard"
|
||||
chmod 0755 "$user_keyboard"
|
||||
rm -f "$sleep_dir/keyboard-backlight"
|
||||
ln -s "$user_keyboard" "$sleep_dir/keyboard-backlight"
|
||||
|
||||
EXTRA_FAKE_ROOT_DIRS="$admin_dir" \
|
||||
FAKE_ROOT_FILES="$admin_delay:$sleep_dir/force-igpu" run_migration Integrated
|
||||
[[ ! -L $sleep_dir/keyboard-backlight ]] ||
|
||||
fail "migration leaves a user-controlled keyboard-hook symlink active"
|
||||
cmp -s "$mock_omarchy/default/systemd/system-sleep/keyboard-backlight" \
|
||||
"$sleep_dir/keyboard-backlight" ||
|
||||
fail "migration does not replace an unsafe symlink with trusted hook content"
|
||||
symlink_backup=$(find "$quarantine" -path '*/keyboard-backlight.*/original' -type l -print -quit)
|
||||
[[ -n $symlink_backup && $(readlink "$symlink_backup") == "$user_keyboard" ]] ||
|
||||
fail "migration discards an unsafe custom symlink instead of preserving it"
|
||||
pass "migration quarantines unsafe symlinks outside the active systemd directory"
|
||||
|
||||
bridge="$user_dir/bridge"
|
||||
ln -s "$admin_keyboard" "$bridge"
|
||||
rm -f "$sleep_dir/keyboard-backlight"
|
||||
ln -s "$bridge" "$sleep_dir/keyboard-backlight"
|
||||
|
||||
EXTRA_FAKE_ROOT_DIRS="$admin_dir" \
|
||||
FAKE_ROOT_FILES="$admin_keyboard:$admin_delay:$sleep_dir/force-igpu" \
|
||||
run_migration Integrated
|
||||
[[ ! -L $sleep_dir/keyboard-backlight ]] ||
|
||||
fail "migration trusts a symlink chain routed through a user-controlled directory"
|
||||
cmp -s "$mock_omarchy/default/systemd/system-sleep/keyboard-backlight" \
|
||||
"$sleep_dir/keyboard-backlight" ||
|
||||
fail "migration does not repair an indirectly user-controlled symlink"
|
||||
pass "migration checks every intermediate component in a symlink chain"
|
||||
|
||||
hook_copy="$test_tmp/force-igpu-hook"
|
||||
hook_calls="$test_tmp/force-igpu-calls"
|
||||
hook_queries="$test_tmp/force-igpu-queries"
|
||||
hook_config="$test_tmp/supergfxd.conf"
|
||||
hook_marker="$test_tmp/force-igpu-restore"
|
||||
hook_pending="$test_tmp/force-igpu-pending"
|
||||
sed \
|
||||
-e "s|/usr/bin/supergfxctl|$stub_bin/hook-supergfxctl|g" \
|
||||
-e "s|/usr/bin/install|$stub_bin/hook-install|g" \
|
||||
-e "s|/etc/supergfxd.conf|$hook_config|g" \
|
||||
-e "s|/run/omarchy-force-igpu-integrated|$hook_marker|g" \
|
||||
"$ROOT/default/systemd/system-sleep/force-igpu" >"$hook_copy"
|
||||
cat >"$stub_bin/hook-supergfxctl" <<'SH'
|
||||
#!/bin/bash
|
||||
|
||||
case "$1" in
|
||||
-m)
|
||||
printf '%s\n' "$*" >>"$HOOK_CALLS"
|
||||
if [[ ${HOOK_BLOCK_MODE:-} == "$2" ]]; then
|
||||
trap '' TERM
|
||||
/usr/bin/sleep 30
|
||||
fi
|
||||
current=$(sed -n 's/.*"mode"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$HOOK_CONFIG")
|
||||
if [[ $current != "$2" ]]; then
|
||||
printf '%s %s\n' "$2" "${HOOK_CONFIRM_AFTER:-1}" >"$HOOK_PENDING"
|
||||
fi
|
||||
;;
|
||||
-g)
|
||||
printf '%s\n' "$*" >>"$HOOK_QUERIES"
|
||||
if [[ -f $HOOK_PENDING ]]; then
|
||||
read -r pending remaining <"$HOOK_PENDING"
|
||||
if [[ ${HOOK_FAIL_MODE:-} != "$pending" ]]; then
|
||||
remaining=$((remaining - 1))
|
||||
if (( remaining <= 0 )); then
|
||||
sed -i "s/\"mode\"[[:space:]]*:[[:space:]]*\"[^\"]*\"/\"mode\": \"$pending\"/" "$HOOK_CONFIG"
|
||||
rm -f -- "$HOOK_PENDING"
|
||||
else
|
||||
printf '%s %s\n' "$pending" "$remaining" >"$HOOK_PENDING"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
sed -n 's/.*"mode"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$HOOK_CONFIG"
|
||||
;;
|
||||
esac
|
||||
SH
|
||||
cat >"$stub_bin/hook-install" <<'SH'
|
||||
#!/bin/bash
|
||||
args=()
|
||||
while (($#)); do
|
||||
case "$1" in
|
||||
-o | -g)
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
args+=("$1")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
exec /usr/bin/install "${args[@]}"
|
||||
SH
|
||||
cat >"$stub_bin/sleep" <<'SH'
|
||||
#!/bin/bash
|
||||
:
|
||||
SH
|
||||
chmod +x "$stub_bin/hook-supergfxctl" "$stub_bin/hook-install" "$stub_bin/sleep"
|
||||
|
||||
hook_env=(
|
||||
"HOOK_CALLS=$hook_calls"
|
||||
"HOOK_QUERIES=$hook_queries"
|
||||
"HOOK_CONFIG=$hook_config"
|
||||
"HOOK_PENDING=$hook_pending"
|
||||
"PATH=$stub_bin:$PATH"
|
||||
)
|
||||
|
||||
printf '{ "mode": "Hybrid" }\n' >"$hook_config"
|
||||
env "${hook_env[@]}" bash "$hook_copy" pre suspend
|
||||
env "${hook_env[@]}" bash "$hook_copy" post suspend
|
||||
[[ ! -e $hook_calls ]] || fail "force-igpu runs while the root-owned config says Hybrid"
|
||||
[[ ! -e $hook_marker ]] || fail "force-igpu records restore intent while configured for Hybrid mode"
|
||||
|
||||
rm -f "$hook_config"
|
||||
env "${hook_env[@]}" bash "$hook_copy" pre suspend
|
||||
env "${hook_env[@]}" bash "$hook_copy" post suspend
|
||||
[[ ! -e $hook_calls ]] || fail "force-igpu runs when its mode config is unavailable"
|
||||
[[ ! -e $hook_marker ]] || fail "force-igpu records restore intent without a mode config"
|
||||
|
||||
printf '{ "mode": "Integrated" }\n' >"$hook_config"
|
||||
env "${hook_env[@]}" bash "$hook_copy" pre suspend
|
||||
[[ -f $hook_marker && $(stat -c '%a' "$hook_marker") == 600 ]] ||
|
||||
fail "force-igpu does not securely record Integrated restore intent during pre-suspend"
|
||||
HOOK_CONFIRM_AFTER=2 env "${hook_env[@]}" bash "$hook_copy" post suspend
|
||||
[[ $(wc -l <"$hook_calls") == 2 ]] ||
|
||||
fail "force-igpu does not run both GPU transitions in Integrated mode"
|
||||
grep -Fqx -- '-m Integrated' "$hook_calls" ||
|
||||
fail "force-igpu does not restore Integrated mode after suspend"
|
||||
[[ ! -e $hook_marker ]] || fail "force-igpu leaves stale restore intent after suspend"
|
||||
(( $(wc -l <"$hook_queries") >= 4 )) ||
|
||||
fail "force-igpu does not wait for asynchronous GPU transitions"
|
||||
pass "force-igpu confirms asynchronous transitions for Integrated sleep cycles"
|
||||
|
||||
: >"$hook_calls"
|
||||
: >"$hook_queries"
|
||||
printf '{ "mode": "Integrated" }\n' >"$hook_config"
|
||||
env "${hook_env[@]}" bash "$hook_copy" pre suspend
|
||||
set +e
|
||||
HOOK_CONFIRM_AFTER=2 HOOK_FAIL_MODE=Integrated env "${hook_env[@]}" \
|
||||
bash "$hook_copy" post suspend >/dev/null 2>&1
|
||||
restore_status=$?
|
||||
set -e
|
||||
(( restore_status != 0 )) || fail "force-igpu reports success without confirming Integrated mode"
|
||||
grep -Fq '"mode": "Vfio"' "$hook_config" ||
|
||||
fail "force-igpu failure test does not leave the transition in Vfio mode"
|
||||
[[ -f $hook_marker ]] || fail "force-igpu discards restore intent after an asynchronous transition failure"
|
||||
HOOK_CONFIRM_AFTER=2 env "${hook_env[@]}" bash "$hook_copy" post suspend
|
||||
grep -Fq '"mode": "Integrated"' "$hook_config" ||
|
||||
fail "force-igpu does not recover the Integrated transition on the next sleep cycle"
|
||||
[[ ! -e $hook_marker ]] || fail "force-igpu leaves restore intent after a confirmed retry"
|
||||
pass "force-igpu retains restore intent until Integrated mode is confirmed"
|
||||
|
||||
: >"$hook_calls"
|
||||
: >"$hook_queries"
|
||||
printf '{ "mode": "Integrated" }\n' >"$hook_config"
|
||||
env "${hook_env[@]}" bash "$hook_copy" pre suspend
|
||||
set +e
|
||||
HOOK_BLOCK_MODE=Vfio env "${hook_env[@]}" \
|
||||
bash "$hook_copy" post suspend >/dev/null 2>&1
|
||||
blocked_request_status=$?
|
||||
set -e
|
||||
(( blocked_request_status != 0 )) || fail "force-igpu waits forever for a blocked GPU transition request"
|
||||
[[ -f $hook_marker ]] || fail "force-igpu discards restore intent after a blocked transition request"
|
||||
[[ ! -s $hook_queries ]] || fail "force-igpu polls before a blocked transition request returns"
|
||||
env "${hook_env[@]}" bash "$hook_copy" post suspend
|
||||
[[ ! -e $hook_marker ]] || fail "force-igpu cannot retry after a blocked transition request"
|
||||
pass "force-igpu bounds blocked transition requests and retains retry intent"
|
||||
|
||||
: >"$hook_calls"
|
||||
printf '{ "mode": "Integrated" }\n' >"$hook_config"
|
||||
env "${hook_env[@]}" bash "$hook_copy" pre hibernate
|
||||
grep -Fq '"mode": "Vfio"' "$hook_config" ||
|
||||
fail "force-igpu test double does not model the pre-hibernate Vfio persistence"
|
||||
[[ -f $hook_marker ]] || fail "force-igpu loses restore intent during the Vfio transition"
|
||||
env "${hook_env[@]}" bash "$hook_copy" post hibernate
|
||||
[[ $(wc -l <"$hook_calls") == 3 ]] ||
|
||||
fail "force-igpu skips the post-hibernate transitions after Vfio changes the config"
|
||||
[[ $(tail -1 "$hook_calls") == "-m Integrated" ]] ||
|
||||
fail "force-igpu does not finish post-hibernate restoration in Integrated mode"
|
||||
grep -Fq '"mode": "Integrated"' "$hook_config" ||
|
||||
fail "force-igpu leaves supergfxd configured for Vfio after hibernation"
|
||||
[[ ! -e $hook_marker ]] || fail "force-igpu leaves stale restore intent after hibernation"
|
||||
pass "force-igpu restores Integrated mode after pre-hibernate persists Vfio"
|
||||
|
||||
: >"$hook_calls"
|
||||
printf '{ "mode": "Integrated" }\n' >"$hook_config"
|
||||
SYSTEMD_SLEEP_ACTION=suspend env "${hook_env[@]}" bash "$hook_copy" pre suspend-then-hibernate
|
||||
SYSTEMD_SLEEP_ACTION=suspend env "${hook_env[@]}" bash "$hook_copy" post suspend-then-hibernate
|
||||
[[ $(wc -l <"$hook_calls") == 2 ]] ||
|
||||
fail "force-igpu does not complete the initial suspend phase of suspend-then-hibernate"
|
||||
SYSTEMD_SLEEP_ACTION=hibernate env "${hook_env[@]}" bash "$hook_copy" pre suspend-then-hibernate
|
||||
[[ $(wc -l <"$hook_calls") == 3 && $(tail -1 "$hook_calls") == "-m Vfio" ]] ||
|
||||
fail "force-igpu skips the Vfio transition before compound hibernation"
|
||||
grep -Fq '"mode": "Vfio"' "$hook_config" ||
|
||||
fail "force-igpu does not detach the dGPU during the hibernate phase"
|
||||
[[ -f $hook_marker ]] || fail "force-igpu loses restore intent during compound hibernation"
|
||||
SYSTEMD_SLEEP_ACTION=hibernate env "${hook_env[@]}" bash "$hook_copy" post suspend-then-hibernate
|
||||
[[ $(wc -l <"$hook_calls") == 5 && $(tail -1 "$hook_calls") == "-m Integrated" ]] ||
|
||||
fail "force-igpu does not restore Integrated mode after compound hibernation"
|
||||
grep -Fq '"mode": "Integrated"' "$hook_config" ||
|
||||
fail "force-igpu leaves supergfxd configured for Vfio after compound hibernation"
|
||||
[[ ! -e $hook_marker ]] || fail "force-igpu leaves stale restore intent after compound hibernation"
|
||||
pass "force-igpu handles both phases of suspend-then-hibernate"
|
||||
|
||||
keyboard_hook_copy="$test_tmp/keyboard-backlight-hook"
|
||||
keyboard_calls="$test_tmp/keyboard-backlight-calls"
|
||||
keyboard_led_dir="$test_tmp/leds"
|
||||
mkdir -p "$keyboard_led_dir/asus::kbd_backlight"
|
||||
sed "s|/sys/class/leds/\*kbd_backlight\*|$keyboard_led_dir/*kbd_backlight*|" \
|
||||
"$ROOT/default/systemd/system-sleep/keyboard-backlight" >"$keyboard_hook_copy"
|
||||
cat >"$stub_bin/brightnessctl" <<'SH'
|
||||
#!/bin/bash
|
||||
printf '%s\n' "$*" >>"$KEYBOARD_CALLS"
|
||||
SH
|
||||
chmod +x "$stub_bin/brightnessctl"
|
||||
|
||||
SYSTEMD_SLEEP_ACTION=suspend KEYBOARD_CALLS="$keyboard_calls" PATH="$stub_bin:$PATH" \
|
||||
bash "$keyboard_hook_copy" pre suspend-then-hibernate
|
||||
[[ ! -e $keyboard_calls ]] || fail "keyboard-backlight runs during the suspend phase of compound sleep"
|
||||
SYSTEMD_SLEEP_ACTION=hibernate KEYBOARD_CALLS="$keyboard_calls" PATH="$stub_bin:$PATH" \
|
||||
bash "$keyboard_hook_copy" pre suspend-then-hibernate
|
||||
grep -Fqx -- '-d asus::kbd_backlight set 0' "$keyboard_calls" ||
|
||||
fail "keyboard-backlight skips the hibernate phase of compound sleep"
|
||||
pass "keyboard-backlight handles the hibernate phase of suspend-then-hibernate"
|
||||
@@ -132,3 +132,47 @@ if problems:
|
||||
PYTHON
|
||||
|
||||
pass "no Omarchy script writes a path under /usr that no package owns"
|
||||
|
||||
for script in bin/omarchy-hibernation-setup bin/omarchy-toggle-hybrid-gpu; do
|
||||
grep -F '"${destination%/*}/.${destination##*/}.omarchy.XXXXXX"' "$ROOT/$script" >/dev/null ||
|
||||
fail "$script reserves a hidden sibling for the privileged replacement"
|
||||
grep -F 'sudo /usr/bin/install -m "$mode" -o root -g root -T "$source" "$stage"' "$ROOT/$script" >/dev/null ||
|
||||
fail "$script prepares privileged files with final root ownership and mode"
|
||||
grep -F 'sudo /usr/bin/mv -Tf -- "$stage" "$destination"' "$ROOT/$script" >/dev/null ||
|
||||
fail "$script atomically replaces the privileged destination"
|
||||
if grep -F 'sudo /usr/bin/chmod "$mode" "$destination"' "$ROOT/$script" >/dev/null; then
|
||||
fail "$script changes mode after publishing the privileged destination"
|
||||
fi
|
||||
done
|
||||
|
||||
grep -F ' /usr/lib/systemd/system-sleep/keyboard-backlight 0755' "$ROOT/bin/omarchy-hibernation-setup" >/dev/null ||
|
||||
fail "hibernation setup installs keyboard-backlight as a root-owned executable"
|
||||
|
||||
hook_install_line=$(rg -n '^if ! install_root_file .*keyboard-backlight' "$ROOT/bin/omarchy-hibernation-setup" | cut -d: -f1)
|
||||
resume_marker_line=$(rg -n '^echo "HOOKS\+=\(resume\)"' "$ROOT/bin/omarchy-hibernation-setup" | cut -d: -f1)
|
||||
[[ -n $hook_install_line && -n $resume_marker_line ]] ||
|
||||
fail "hibernation setup keeps recognizable hook-install and resume-marker steps"
|
||||
(( hook_install_line < resume_marker_line )) ||
|
||||
fail "hibernation setup marks completion before a failed hook install can be retried"
|
||||
|
||||
grep -F ' /usr/lib/systemd/system-sleep/force-igpu 0755' "$ROOT/bin/omarchy-toggle-hybrid-gpu" >/dev/null ||
|
||||
fail "hybrid GPU setup installs force-igpu as a root-owned executable"
|
||||
grep -F ' /etc/systemd/system/supergfxd.service.d/delay-start.conf 0644' "$ROOT/bin/omarchy-toggle-hybrid-gpu" >/dev/null ||
|
||||
fail "hybrid GPU setup installs its root service drop-in as root-owned configuration"
|
||||
|
||||
delay_install_line=$(rg -n '^ if ! install_root_file .*delay-start\.conf' "$ROOT/bin/omarchy-toggle-hybrid-gpu" | cut -d: -f1)
|
||||
force_install_line=$(rg -n '^ if ! install_root_file .*force-igpu' "$ROOT/bin/omarchy-toggle-hybrid-gpu" | cut -d: -f1)
|
||||
config_switch_line=$(rg -n '^ sudo sed -i \\' "$ROOT/bin/omarchy-toggle-hybrid-gpu" | tail -1 | cut -d: -f1)
|
||||
[[ -n $delay_install_line && -n $force_install_line && -n $config_switch_line ]] ||
|
||||
fail "hybrid GPU setup keeps recognizable support-file and config-switch steps"
|
||||
(( delay_install_line < config_switch_line && force_install_line < config_switch_line )) ||
|
||||
fail "hybrid GPU setup switches config before every required file is installed"
|
||||
|
||||
grep -Fq '/usr/bin/grep -Eq' "$ROOT/default/systemd/system-sleep/force-igpu" ||
|
||||
fail "force-igpu does not guard execution with the configured GPU mode"
|
||||
|
||||
if rg -n 'cp -p.*(system-sleep|supergfxd\.service\.d)' "$ROOT/bin/omarchy-hibernation-setup" "$ROOT/bin/omarchy-toggle-hybrid-gpu"; then
|
||||
fail "privileged sleep and hybrid GPU files are never copied with source ownership"
|
||||
fi
|
||||
|
||||
pass "system-sleep hooks and the hybrid GPU drop-in enforce root ownership"
|
||||
Reference in new issue
Block a user