diff --git a/CHANGELOG.md b/CHANGELOG.md index c8eeaa9391..c8347d9c52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,16 @@ for finer detail. user is no longer in a mango session. ### Fixed +- Fedora installs keep existing RPM Fusion FFmpeg and Docker CE packages, and + no longer request the nonexistent `util-linux-libs` package. +- RyoStore no longer leaves other products with inert download buttons while an + install is running; their actions are disabled and say that another item is + downloading until the single-flight operation completes. +- RyoStore category pages now list products A to Z and include a search field + that filters only the open category. The Apps marketplace keeps its Flathub + rankings and adds an explicit A-to-Z sort. +- The Apps marketplace gives its sort chips and scoped search separate rows on + narrow windows, so neither overlaps the compact app grid. - Users on filtered networks can route `ryoku update` and pacman through a local HTTP proxy without weakening package signature checks. - Screenshots no longer pick up the animated pointer when a cursor effect has diff --git a/VERSION b/VERSION index af0b7ddbff..238d6e882a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.6 +1.0.7 diff --git a/docs/barstyles.md b/docs/barstyles.md index ebc79edf49..9f5d7282e3 100644 --- a/docs/barstyles.md +++ b/docs/barstyles.md @@ -146,7 +146,8 @@ Its settings use the `orpheus` namespace in `shell.json`: | `mirror` | boolean | `true` | Mirror the spectrum around the centre. | | `feel` | `fluid` or `tight` | `fluid` | Set the wave spring and damping. | | `workspaceStyle` | `dots`, `numbers`, `kanji`, `greek`, `dashes`, or `icons` | `dots` | Choose the workspace marks. | -| `workspaceShowEmpty` | boolean | `true` | Include empty workspaces. Where workspaces are numbered, gaps fill in and one free slot follows the last used. | +| `workspaceShowEmpty` | boolean | `true` | Include empty workspaces. Where workspaces are numbered, gaps fill in and one free slot follows the last used unless `workspaceSpare` is off. | +| `workspaceSpare` | boolean | `true` | With `workspaceShowEmpty` on, add one free slot after the last used workspace. Off ends the strip at the last workspace that exists. | | `showDate` | boolean | `true` | Show the date under the idle time. | | `seconds` | boolean | `false` | Show seconds in the idle time. | | `voiceWeather` | boolean | `true` | Show the weather beside the idle time. | @@ -160,6 +161,7 @@ Its settings use the `orpheus` namespace in `shell.json`: | `actions` | subset of `launcher`, `ask`, `overview`, `clipboard`, `capture`, `wallpaper`, `hub` | `["launcher","capture","clipboard"]` | Choose shortcut actions. | | `trayLimit` | `3` to `12` | `6` | Set the visible tray item limit. | | `statusItems` | subset of `wifi`, `bluetooth`, `volume`, `mic` | all four | Choose the icons in the Controls module. | +| `aiTools` | subset of `claude`, `codex`, `opencode` | all three | Choose which coding agents the AI usage module counts. Modules page, AI usage. An empty list hides the module. | The name is Orpheus, the musician of Greek myth whose lyre moved rivers and stones. Made by neur0map. diff --git a/docs/compositors.md b/docs/compositors.md index e72ec45615..136ea4f5fd 100644 --- a/docs/compositors.md +++ b/docs/compositors.md @@ -35,6 +35,10 @@ A provider is one binary, `ryoku-wm-`, shipped by |`session`|the `wayland-session` desktop entry| |`environment `|export the provider's opaque handle from one verified session process| +Ordinary workspace frames carry a one-based `index` inside their output. The +handle in `id` remains the only action argument; `index` is presentation order, +and consumers preserve frame order when an older provider omits it. + `ryoku wm compat ` is the neutral plugin gate. It scans QML, JavaScript, shell, Python and executable files without running them, then compares any foreign shell interfaces it finds with `caps.foreignApis`. A diff --git a/docs/store.md b/docs/store.md index 5c50b5cc83..d701509093 100644 --- a/docs/store.md +++ b/docs/store.md @@ -71,6 +71,18 @@ product keeps an artwork stage, while a responsive editorial grid exposes the rest of the collection. A card may animate its cover only while hovered. Status and actions always belong to the committed selection. +The Apps catalogue is the marketplace surface. It opens with a featured shelf, +a row of Flathub category plates, a `TRENDING / POPULAR / NEW / UPDATED` +strip, and compact icon and text tiles in a responsive grid. The category +plates come from the main category tags the provider already carries and show +their counts, so a plate exists only when the catalogue has something in it. +The field beside the sort strip narrows the loaded catalogue in place, and the +shelf steps aside while a category or a filter is active. The strip sorts the +union of Flathub's popular, trending, recently added and recently updated +collections by the matching ranking field, so the tabs describe the catalogue +rather than reshuffling one popular page. A header search for Flathub keeps +its relevance order and uses the same compact tiles. + The rail exposes one semantic route order: ```text @@ -168,15 +180,21 @@ ryoku/apps/ryostore/ provider_bars.go provider_shell_qml.go provider_fastfetch.go + provider_flatpak.go quickshell/ shell.qml App.qml StoreHeader.qml ShowroomStage.qml - Filmstrip.qml - SearchLayer.qml + AppsHero.qml + AppsTile.qml + AppsSearch.qml ProductDetail.qml ProductCover.qml + ProductMedia.qml + ProductGrid.qml + ProductCard.qml + ProviderTabs.qml StatusReadout.qml lib/ store.js @@ -262,6 +280,16 @@ lives under `modules/bar/barstyles//`, a qylock theme under the qylock theme and a plugin in the plugin data directory. Ryostore owns their catalogue adapter and installation, not a second runtime copy. +Install, update, removal, and enablement are single-flight so receipts and package +manager state cannot race. While one product is active, every other product keeps +its action visible but disabled and names the operation that is already running; +the active product continues to show the backend's real byte or step progress. +Within a category, products are ordered by display name and a local search field +filters only that category. Marketplace categories with explicit ranking controls +keep those controls; Apps also offers **A to Z** alongside Flathub's Trending, +Popular, New, and Updated rankings. + + ## Provider behavior - **Lockscreens:** fetch the qylock catalogue, preview cache, and install-only @@ -288,14 +316,18 @@ and installation, not a second runtime copy. the category offers Popular, New, and Verified orderings and global search. Its thousands of preview and gallery URLs deliberately bypass eager asset warming; only visible delegates request their remote media. -- **Apps (Flatpak):** list up to 300 of the most popular desktop applications - from the Flathub collection API, joined with `flatpak list`, so an installed - Flathub application outside that ranking is still listed and removable. - Flathub is a third-party source: it does not pass through the `ryostore` - registry or its screening. The detail labels Flathub's verified or - unverified state when known (or unknown for installed-only items) and warns - before installation; verification is not a Ryostore safety review. New apps - use the system Flatpak installation and its shared runtimes when its +- **Apps (Flatpak):** list the union of Flathub's popular, trending, recently + added and recently updated collections (up to 300 each), joined with + `flatpak list`, so an installed Flathub application outside those rankings is + still listed and removable. Each hit carries its trending score, monthly + installs, listing date and update date for the marketplace strip. The union is + cached as one snapshot and served flagged offline when Flathub is + unreachable; a partial fetch keeps the collections that answered and leaves + the complete cache in place. Flathub is a third-party source: it does not pass + through the `ryostore` registry or its screening. The detail labels Flathub's + verified or unverified state when known (or unknown for installed-only items) + and warns before installation; verification is not a Ryostore safety review. + New apps use the system Flatpak installation and its shared runtimes when its `flathub` remote is valid; if only the user installation has a valid remote, they install there instead. System installs may ask for authorization. The store never adds a remote. When neither installation has a valid remote, diff --git a/docs/updates.md b/docs/updates.md index 842bb032df..0c96eb9771 100644 --- a/docs/updates.md +++ b/docs/updates.md @@ -428,7 +428,11 @@ package provides (rustup for rust, tuned-ppd for power-profiles-daemon) counts as present, so the box's own choice is never overwritten, and the opt-in names (`ryokumanifest.OptIn`: hardware packages, the Limine hook and snapshot sync, and prowl, which only ever arrives as ryoku-rashin's dependency) -belong to their gated reconcilers and are never converged. The deliver-once +belong to their gated reconcilers and are never converged. On a CachyOS box +(os-release `ID=cachyos`) the manifest is read without the names its layer +replaces (`cachyosReplaces`, from `system/packages/cachyos.replaces`: the stock +`linux` kernel and `yay-bin`), so a box converted in place, which has no ledger +entries for them, is never offered them either. The deliver-once apps stay `reconcileShippedApps'` lane, so one update never runs two transactions over the same names. Best-effort: a box with no mirror or no network reports what did not land and the update moves on. `ryoku verify` diff --git a/fedora/packages/rpm/fetch/hyprland-preview-share-picker.sh b/fedora/packages/rpm/fetch/hyprland-preview-share-picker.sh index 149bb16067..7d1d1662d2 100755 --- a/fedora/packages/rpm/fetch/hyprland-preview-share-picker.sh +++ b/fedora/packages/rpm/fetch/hyprland-preview-share-picker.sh @@ -3,7 +3,7 @@ set -euo pipefail : "${RYOKU_FETCH_TREE:?}" package=hyprland-preview-share-picker -commit=e2f30ff85486e557018523da45ccbc846e3a499c +commit=7e11349d3f6ecae75b42be73d6a258503656cc8f root="$RYOKU_FETCH_TREE/.rpm-$package" dest="$root/$package" rm -rf "$root" diff --git a/fedora/packages/rpm/gpu-screen-recorder.spec b/fedora/packages/rpm/gpu-screen-recorder.spec index 835de77fd5..7c0d9fd2b0 100644 --- a/fedora/packages/rpm/gpu-screen-recorder.spec +++ b/fedora/packages/rpm/gpu-screen-recorder.spec @@ -27,7 +27,6 @@ BuildRequires: pulseaudio-libs-devel BuildRequires: vulkan-headers BuildRequires: wayland-devel Requires: bash -Requires: ffmpeg-free Requires: libcap Requires: libjpeg-turbo Requires: mesa-dri-drivers diff --git a/fedora/packages/rpm/hyprland-preview-share-picker.spec b/fedora/packages/rpm/hyprland-preview-share-picker.spec index c02dc57e86..d474aaabd9 100644 --- a/fedora/packages/rpm/hyprland-preview-share-picker.spec +++ b/fedora/packages/rpm/hyprland-preview-share-picker.spec @@ -3,7 +3,7 @@ %global ryoku_vendor cargo %global ryoku_vendor_dir .rpm-hyprland-preview-share-picker/hyprland-preview-share-picker Name: hyprland-preview-share-picker -Version: 0.1 +Version: 0.3.0 Release: 1%{?dist} Summary: Preview screen share picker for Hyprland License: MIT diff --git a/fedora/packages/rpm/ryogami.spec b/fedora/packages/rpm/ryogami.spec index 5e99d61a0d..917dca2593 100644 --- a/fedora/packages/rpm/ryogami.spec +++ b/fedora/packages/rpm/ryogami.spec @@ -24,8 +24,8 @@ BuildRequires: wayland-devel BuildRequires: wayland-protocols-devel BuildRequires: ffmpeg-free-devel Requires: quickshell >= 0.3.0 -Requires: ffmpeg-free -# ffmpeg-free can otherwise resolve libopenh264 to Fedora's no-codec stub. +Requires: /usr/bin/ffmpeg +# Fedora's ffmpeg-free can otherwise resolve libopenh264 to its no-codec stub. Requires: openh264 Requires: ImageMagick Requires: qt6-qtmultimedia diff --git a/fedora/packages/rpm/ryoku-desktop.spec b/fedora/packages/rpm/ryoku-desktop.spec index ff61d07a33..ddac4b11b5 100644 --- a/fedora/packages/rpm/ryoku-desktop.spec +++ b/fedora/packages/rpm/ryoku-desktop.spec @@ -52,8 +52,6 @@ Requires: papirus-icon-theme Requires: kf6-syntax-highlighting Requires: brightnessctl Requires: playerctl -Requires: mpv -Requires: mpv-mpris Requires: yt-dlp Requires: wl-clipboard Requires: grim @@ -65,7 +63,7 @@ Requires: pulseaudio-utils Requires: jq Requires: dbus-tools Requires: ImageMagick -Requires: ffmpeg-free +Requires: /usr/bin/ffmpeg Requires: curl Requires: zenity Requires: iw @@ -105,6 +103,8 @@ Requires: kernel-headers Requires: dkms Recommends: power-profiles-daemon +Suggests: mpv +Suggests: mpv-mpris Suggests: kitty Suggests: fish Suggests: blesh diff --git a/fedora/packages/rpm/ryoku-keysounds-profiles.spec b/fedora/packages/rpm/ryoku-keysounds-profiles.spec index 27268dcd71..fc9346b139 100644 --- a/fedora/packages/rpm/ryoku-keysounds-profiles.spec +++ b/fedora/packages/rpm/ryoku-keysounds-profiles.spec @@ -11,7 +11,7 @@ BuildRequires: coreutils BuildRequires: ffmpeg-free BuildRequires: findutils BuildRequires: python3 -Requires: ffmpeg-free +Requires: /usr/bin/ffmpeg %description The real switch recordings both key-sound engines use, cut from the MIT-licensed diff --git a/fedora/packages/rpm/ryoku-shell.spec b/fedora/packages/rpm/ryoku-shell.spec index 718e8a3bbe..c0f1c7b987 100644 --- a/fedora/packages/rpm/ryoku-shell.spec +++ b/fedora/packages/rpm/ryoku-shell.spec @@ -16,7 +16,7 @@ BuildRequires: wayland-devel BuildRequires: wayland-protocols-devel BuildRequires: ffmpeg-free-devel Requires: quickshell >= 0.3.0 -Requires: ffmpeg-free +Requires: /usr/bin/ffmpeg Requires: libwayland-client Requires: libwayland-cursor Requires: libwayland-egl diff --git a/fedora/packages/rpm/ryotunes.spec b/fedora/packages/rpm/ryotunes.spec index 3276408b58..2fb4e62491 100644 --- a/fedora/packages/rpm/ryotunes.spec +++ b/fedora/packages/rpm/ryotunes.spec @@ -31,7 +31,7 @@ Requires: webkit2gtk4.1 Requires: libappindicator-gtk3 Requires: mpv-libs Requires: yt-dlp -Requires: ffmpeg-free +Requires: /usr/bin/ffmpeg Requires: openssl-libs Requires: librsvg2 Requires: desktop-file-utils diff --git a/fedora/packages/translations.tsv b/fedora/packages/translations.tsv index 1b3129d59e..54a3b435d4 100644 --- a/fedora/packages/translations.tsv +++ b/fedora/packages/translations.tsv @@ -139,8 +139,8 @@ matugen ryoku-extras desktop Bundled by the Ryoku extras RPM. mesa mesa-dri-drivers mesa-libGL desktop mise mise dev Ryoku-owned RPM. mkinitcpio dracut system -mpv mpv desktop -mpv-mpris mpv-mpris desktop +mpv mpv optional +mpv-mpris mpv-mpris optional nautilus nautilus desktop nautilus-python nautilus-python desktop neovim neovim desktop @@ -254,7 +254,7 @@ ttf-readex-pro ttf-readex-pro desktop Ryoku-owned RPM. ttf-rubik-vf google-rubik-fonts desktop udev systemd-udev desktop upower upower desktop -util-linux-libs util-linux-libs hyprland +util-linux-libs - hyprland Fedora splits these libraries; RPM soname dependencies install the required providers. uv uv desktop v4l2loopback-dkms v4l2loopback v4l2loopback-dkms optional Provided by a restricted COPR. vdirsyncer vdirsyncer desktop diff --git a/installation/CHANGELOG.md b/installation/CHANGELOG.md index a6143926c2..2734c65d7d 100644 --- a/installation/CHANGELOG.md +++ b/installation/CHANGELOG.md @@ -1,6 +1,18 @@ # Changelog: installation/ ## Unreleased +- **Zsh installs open with Ryoku's configured prompt on the first boot.** The + Arch, CachyOS and Void installers copy the packaged startup template after + selecting Zsh, while leaving an existing `.zshrc` untouched. +- **Fresh installs keep the launcher radio while leaving its player removable.** + The default-on Apps list owns `mpv` and `mpv-mpris` on Fedora and Void; + Arch and CachyOS continue to install both from their base set + (`installerplan/components.tsv`, `../ryoku-shell-installer/engine.go`). +- **Shell conversions install headers for their existing kernels.** The + installer skips stock `linux-headers` with the rest of the replacement boot + chain, reads each installed kernel's `pkgbase`, and adds its matching header + package only when pacman's enabled repositories provide it + (`../ryoku-shell-installer/engine.go`). ### Added - **The Arch, CachyOS and Void ISOs have one edition-appropriate Gaming diff --git a/installation/backend/lib/deploy.sh b/installation/backend/lib/deploy.sh index 7ab5be2342..70018a331d 100755 --- a/installation/backend/lib/deploy.sh +++ b/installation/backend/lib/deploy.sh @@ -24,6 +24,7 @@ ryoku_deploy() { ryoku_deploy_repo # [ryoku] stanza + mirrorlist + keyring trust (local) ryoku_deploy_packages # pacman -S the desktop set (needs net) + ryoku_seed_login_shell_config ryoku_seed_initcpio_hook # the HOOKS-named trim hook, if that set never came ryoku_seed_keymap # chosen kb_layout into the neutral store ryoku_deploy_chown "$u" # the store seed creates ~/.config as root; hand @@ -35,6 +36,33 @@ ryoku_deploy() { ryoku_deploy_qylock # qylock writes user files as the now-owning user } +ryoku_seed_login_shell_config() { + local root=${RYOKU_TARGET_ROOT:-/mnt} template user target + [[ ${RYOKU_LOGIN_SHELL:-fish} == zsh ]] || return 0 + template="$root/usr/share/ryoku/zsh/zshrc" + + if [[ -n ${RYOKU_DRYRUN:-} ]]; then + for user in "${RYOKU_ACCOUNT_NAMES[@]}"; do + log 'DRYRUN: seed %s from %s when missing' "$root/home/$user/.zshrc" "$template" + done + return 0 + fi + if [[ ! -f $template ]]; then + log 'WARNING: zsh is the login shell but its Ryoku startup template is missing at %s' "$template" + return 0 + fi + + for user in "${RYOKU_ACCOUNT_NAMES[@]}"; do + target="$root/home/$user/.zshrc" + [[ ! -e $target && ! -L $target ]] || continue + install -Dm644 "$template" "$target" + if [[ $root == /mnt ]]; then + run "$RYOKU_TARGET_CHROOT" /mnt chown "$user:$user" "/home/$user/.zshrc" + fi + log 'seeded the zsh login config for %s' "$user" + done +} + # deploy_file: one config file, mode 644, parents made. deploy_file() { local src=$1 dst=$2 diff --git a/installation/installerplan/components.tsv b/installation/installerplan/components.tsv index b389cd125d..ce502bd987 100644 --- a/installation/installerplan/components.tsv +++ b/installation/installerplan/components.tsv @@ -11,6 +11,7 @@ yazi Yazi terminal Terminal yazi - on apps false * - - terminal file manager cli-tools Modern CLI tools terminal Terminal eza,bat,fzf,zoxide - on apps false * - - ls and cat replacements; fuzzy history and directory search git-tools Git tools terminal Terminal github-cli,lazygit - on apps false * - - the gh CLI and lazygit terminal UI pavucontrol PulseAudio GUI media Media & tools pavucontrol - on apps false * - - the mixer the bar's audio button opens +radio Launcher radio media Media & tools mpv,mpv-mpris - on apps false * - - live radio in the launcher songrec Songrec media Media & tools songrec - on apps false * - - Recognize Music in the launcher openrgb OpenRGB media Media & tools openrgb - on apps false * - - keyboard and mouse lighting follows the wallpaper palette upscale AI upscale media Media & tools waifu2x-ncnn-vulkan - on apps false * - - ryoshot Beautify HD and ryowalls Enhance diff --git a/release/CHANGELOG.md b/release/CHANGELOG.md index 0508fe045b..4a74e9dcdd 100644 --- a/release/CHANGELOG.md +++ b/release/CHANGELOG.md @@ -1,6 +1,17 @@ # Release / Packaging Changelog ## Unreleased +- **The desktop package carries the Zsh startup template.** Fresh installers and + Doctor can seed it without relying on CachyOS skeleton files or replacing a + user's `.zshrc`. +- **The launcher radio's player can be removed.** `mpv` and `mpv-mpris` are + optional desktop packages delivered once on a fresh Ryoku box. Removing them + now sticks across upgrades, and the radio offers an install hint instead of + failing silently. +- **Desktop packages no longer force stock Arch kernel headers.** + `ryoku-desktop` keeps DKMS but leaves headers to the installed kernels. The + CachyOS installer replaces both stock kernel packages, and doctor installs + each installed Arch or CachyOS kernel's matching header package. ### Added - **The shell installs its equalizer backend automatically.** `ryoku-shell` @@ -152,6 +163,10 @@ the other plugin packages. ### Fixed +- **The Hyprland share picker works with Hyprland 0.57 again.** The Arch and + Fedora packages now ship upstream 0.3.0, which no longer parses Hyprland's + workspace data through `hyprland-rs`. Outputs and Windows load again instead + of leaving Discord monitor shares black or forcing Region selection. - **Fedora and Arch build the image-border plugin against Hyprland 0.57.** Fedora moved `hyprland-devel` and Arch moved `hyprland` to 0.57, whose renderer passes an explicit `CRenderContext` and dropped the `m_renderData` diff --git a/release/packages/README.md b/release/packages/README.md index 37ab91a9bc..7482dd1cdc 100644 --- a/release/packages/README.md +++ b/release/packages/README.md @@ -54,9 +54,10 @@ full checkout. The Go binaries and QML plugins are built into `$srcdir`, so the source tree is never modified, and `makepkg --clean` removes `$srcdir` and `$pkgdir` afterward. -The `gpk`, `skwd-paper-bin`, and `ryoku-keyring` PKGBUILDs fetch pinned -upstream artifacts instead of building from the checkout. `prowl` is also -pinned upstream, but builds its selected commit from source. +The `gpk`, `skwd-paper-bin`, `hyprland-preview-share-picker`, and +`ryoku-keyring` PKGBUILDs fetch pinned upstream artifacts or source instead of +building from the checkout. `prowl` is also pinned upstream, but builds its +selected commit from source. ## Shared package payloads diff --git a/release/packages/hyprland-preview-share-picker/PKGBUILD b/release/packages/hyprland-preview-share-picker/PKGBUILD index 44bdae8ee8..61957ad52e 100644 --- a/release/packages/hyprland-preview-share-picker/PKGBUILD +++ b/release/packages/hyprland-preview-share-picker/PKGBUILD @@ -1,8 +1,8 @@ # Maintainer: Ryoku pkgname=hyprland-preview-share-picker -pkgver=0.2.1.r9.ge2f30ff +pkgver=0.3.0 pkgrel=1 -_commit=e2f30ff85486e557018523da45ccbc846e3a499c +_commit=7e11349d3f6ecae75b42be73d6a258503656cc8f pkgdesc="Preview-based screen-share source picker for Hyprland" arch=('x86_64') url="https://github.com/WhySoBad/hyprland-preview-share-picker" diff --git a/release/packages/ryoku-desktop/PKGBUILD b/release/packages/ryoku-desktop/PKGBUILD index 30714eaf91..1d246aa947 100644 --- a/release/packages/ryoku-desktop/PKGBUILD +++ b/release/packages/ryoku-desktop/PKGBUILD @@ -9,7 +9,7 @@ # this dir from a full checkout, so the repo root is three levels up. pkgname=ryoku-desktop pkgver=${RYOKU_PKGVER:-0.1.0} -pkgrel=3 +pkgrel=5 pkgdesc="Ryoku desktop: Quickshell configuration, helpers, and runtime (compositor-neutral base)" # package() compiles Go helpers (ryovm-fetch), so the payload is arch-specific. arch=('x86_64') @@ -102,10 +102,6 @@ depends=( # tools the shipped autostart, pill, ryoshot, and helper scripts call by name 'brightnessctl' 'playerctl' - # the launcher's "@" live radio: mpv plays the stream, mpv-mpris puts it on - # the players bus, yt-dlp resolves the Lofi Girl live page at play time. - 'mpv' - 'mpv-mpris' 'yt-dlp' 'wl-clipboard' 'wl-clip-persist' @@ -201,19 +197,19 @@ depends=( 'hypridle' # idle daemon ryoku-idle renders a config for (ext-idle-notify-v1) 'hyprpicker' # screen color picker (ryoku-cmd-color-picker, Super+Shift+C) # Game controllers: xpadneo-dkms (Xbox One/Series Bluetooth pads, from the AUR - # step) is a DKMS module, so it needs the kernel headers and the DKMS build - # system to compile its module; without them the build silently fails and the - # pad is not detected at all (users had to install linux-headers + dkms by - # hand). linux-headers matches the base `linux` kernel; dkms's alpm hooks then - # (re)build the module whenever headers land, so `ryoku update` pulling these - # onto an existing box rebuilds an already-installed pad driver. - 'linux-headers' + # step) needs DKMS and headers for every installed kernel. The installer pairs + # each shipped kernel with its headers, and doctor fills a missing + # -headers package on existing Arch and CachyOS boxes. Keeping only + # dkms here avoids forcing stock linux-headers onto a box that has no stock + # linux kernel. 'dkms' ) optdepends=( # The applications Ryoku ships. Optional here on purpose: the ISO pacstraps # them and `ryoku doctor` delivers them once to an existing box, so pacman # never has a missing dependency to "repair" and a deleted app stays deleted. + 'mpv: playback for the launcher radio' + 'mpv-mpris: media controls for the launcher radio' 'kitty: the default terminal' 'fish: optional interactive shell' 'blesh: Bash line editor' diff --git a/release/packages/ryoku-desktop/payload.sh b/release/packages/ryoku-desktop/payload.sh index b002f30176..35fa73689e 100755 --- a/release/packages/ryoku-desktop/payload.sh +++ b/release/packages/ryoku-desktop/payload.sh @@ -198,6 +198,7 @@ EOF install -Dm644 "$_repo/ryoku/apps/bash/rashin.bash" "$cfg/bash/rashin.bash" install -Dm644 "$_repo/ryoku/apps/zsh/ryoku.zsh" "$cfg/zsh/ryoku.zsh" install -Dm644 "$_repo/ryoku/apps/zsh/rashin.zsh" "$cfg/zsh/rashin.zsh" + install -Dm644 "$_repo/ryoku/hub/backend/zshrc" "$pkgdir/usr/share/ryoku/zsh/zshrc" # GTK toolkit baseline for the xsettings-less Wayland session (see the header # of each file); materialized and pruned by `ryoku materialize` like qt6ct.conf. install -Dm644 "$_repo/ryoku/shell/gtk-3.0/settings.ini" "$cfg/gtk-3.0/settings.ini" diff --git a/ryoku-shell-installer/engine.go b/ryoku-shell-installer/engine.go index 4e9573a633..69c087c480 100644 --- a/ryoku-shell-installer/engine.go +++ b/ryoku-shell-installer/engine.go @@ -247,7 +247,7 @@ const ryokuOverwriteGlob = "/usr/bin/ryoku-*," + // to drift out of sync. var bootChainSkip = map[string]bool{ "base": true, "base-devel": true, "linux": true, "linux-firmware": true, - "mkinitcpio": true, "sudo": true, "btrfs-progs": true, "cryptsetup": true, + "linux-headers": true, "mkinitcpio": true, "sudo": true, "btrfs-progs": true, "dosfstools": true, "efibootmgr": true, "limine": true, "plymouth": true, "snapper": true, "snap-pac": true, // the limine hook packages pull limine + mkinitcpio back as depends and ship @@ -573,8 +573,8 @@ type engine struct { state *runState freshAccount bool installedPkg func(string) bool - - steps []estep + availablePkg func(string) bool + steps []estep } func newEngine(f *facts, p *plan, dry bool, ref, payloadOverride string) *engine { @@ -1819,6 +1819,38 @@ func stepRepo(e *engine) error { return e.sudo("pacman", "-Sy") } +var installerKernelModulesDir = "/usr/lib/modules" + +func availableKernelHeaderPackages(modulesDir string, installed, available func(string) bool) ([]string, error) { + entries, err := os.ReadDir(modulesDir) + if err != nil { + return nil, err + } + seen := map[string]bool{} + var headers []string + for _, entry := range entries { + data, err := os.ReadFile(filepath.Join(modulesDir, entry.Name(), "pkgbase")) + if err != nil { + if os.IsNotExist(err) { + continue + } + return nil, fmt.Errorf("read kernel metadata for %s: %w", entry.Name(), err) + } + kernel := strings.TrimSpace(string(data)) + if !strings.HasPrefix(kernel, "linux") || strings.ContainsAny(kernel, " \t\r\n/") || + !installed(kernel) { + continue + } + header := kernel + "-headers" + if !seen[header] && available(header) { + seen[header] = true + headers = append(headers, header) + } + } + sort.Strings(headers) + return headers, nil +} + // readBasePackages parses system/packages/base.packages from the payload (the // same manifest the ISO pacstraps): one package per line, '#' comments and // blank lines dropped, boot-chain entries skipped. This is the single source of @@ -1897,7 +1929,8 @@ func selectedInstallPackages(p *plan) []string { if !availability.Available || !p.componentOn(component.ID) { continue } - if component.Group == "ryoku-apps" || component.Step == installerplan.ComponentRashin || + if component.Group == "ryoku-apps" || component.ID == "radio" || + component.Step == installerplan.ComponentRashin || (component.Step == installerplan.StepGaming && !component.NeedsNetwork) { packages = append(packages, component.Packages...) } @@ -2008,6 +2041,7 @@ func stepPackages(e *engine) error { if err != nil { return err } + pkgs = e.dropSatisfied(pkgs) return installPackagePlan(e, d, pkgs) } base, err := e.readBasePackages() @@ -2017,6 +2051,11 @@ func stepPackages(e *engine) error { } e.say(i18n.T("DRYRUN: payload not cloned; would read system/packages/base.packages")) } + headers, err := availableKernelHeaderPackages(installerKernelModulesDir, d.installedPkg, e.packageAvailable) + if err != nil { + return fmt.Errorf("inspect installed kernels: %w", err) + } + base = append(base, headers...) var pkgs []string if d.fromSource { // no [ryoku] repository here: the desktop is built from the payload, so @@ -2188,23 +2227,46 @@ func uniquePackages(pkgs []string) []string { return out } -// dropSatisfied keeps only what no installed provider satisfies (pacman -T -// prints the unmet names), so a provides-conflict like qt6ct-kde cannot -// abort the install under --noconfirm. +var fedoraProviderAlternatives = map[string]string{ + "ffmpeg-free": "/usr/bin/ffmpeg", + "moby-engine": "docker", +} + +func filterFedoraSatisfied(pkgs []string, provided func(string) bool) []string { + keep := make([]string, 0, len(pkgs)) + for _, pkg := range pkgs { + capability, hasAlternative := fedoraProviderAlternatives[pkg] + if hasAlternative && provided(capability) { + continue + } + keep = append(keep, pkg) + } + return keep +} + +// dropSatisfied keeps package names whose requirement is not already met by +// an installed provider, avoiding package-name conflicts during installation. func (e *engine) dropSatisfied(pkgs []string) []string { if e.dry || len(pkgs) == 0 { return pkgs } - if e.d().id != "arch" { - return pkgs - } - out, err := exec.Command("pacman", append([]string{"-T"}, pkgs...)...).Output() - // exit 127 aside, pacman -T exits 1 when any target is unmet; the list on - // stdout is the answer either way, so only a total failure falls back. - if len(out) == 0 && err != nil { + var keep []string + switch e.d().id { + case "arch": + out, err := exec.Command("pacman", append([]string{"-T"}, pkgs...)...).Output() + // exit 127 aside, pacman -T exits 1 when any target is unmet; the list + // on stdout is the answer either way, so only a total failure falls back. + if len(out) == 0 && err != nil { + return pkgs + } + keep = filterByUnmet(pkgs, string(out)) + case "fedora": + keep = filterFedoraSatisfied(pkgs, func(capability string) bool { + return exec.Command("rpm", "-q", "--whatprovides", capability).Run() == nil + }) + default: return pkgs } - keep := filterByUnmet(pkgs, string(out)) if len(keep) < len(pkgs) { e.say(i18n.Tf("%d of %d packages already satisfied by installed providers", len(pkgs)-len(keep), len(pkgs))) } @@ -2943,6 +3005,16 @@ func (e *engine) packageInstalled(pkg string) bool { return local != "" && e.d().installedPkg(local) } +func (e *engine) packageAvailable(pkg string) bool { + if e.availablePkg != nil { + return e.availablePkg(pkg) + } + if e.dry { + return true + } + return exec.Command("pacman", "-Si", pkg).Run() == nil +} + func (e *engine) writeProvisionedLedger() error { path := filepath.Join(e.f.homeDir, ".local/state/ryoku/provisioned") var existingData []byte diff --git a/ryoku-shell-installer/engine_reader_test.go b/ryoku-shell-installer/engine_reader_test.go index 137248db0e..5e57b6b97e 100644 --- a/ryoku-shell-installer/engine_reader_test.go +++ b/ryoku-shell-installer/engine_reader_test.go @@ -4,6 +4,7 @@ import ( "os" "os/exec" "path/filepath" + "reflect" "strings" "testing" @@ -401,7 +402,41 @@ func TestVoidDefaultsToFirstPackagedProvider(t *testing.T) { } } -func TestEditionPackagePlansIncludeSelectedFileManagerAndRyokuApps(t *testing.T) { +func TestAvailableKernelHeaderPackagesMatchInstalledKernels(t *testing.T) { + root := t.TempDir() + for version, pkgbase := range map[string]string{ + "7.2.11-arch1-1": "linux", + "7.3.0-cachyos-bore-lto": "linux-cachyos-bore-lto", + "7.3.0-custom": "linux-custom", + "stale": "linux-zen", + } { + dir := filepath.Join(root, version) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "pkgbase"), []byte(pkgbase+"\n"), 0o644); err != nil { + t.Fatal(err) + } + } + installed := map[string]bool{ + "linux": true, "linux-cachyos-bore-lto": true, "linux-custom": true, + } + available := map[string]bool{ + "linux-headers": true, "linux-cachyos-bore-lto-headers": true, + } + got, err := availableKernelHeaderPackages(root, + func(name string) bool { return installed[name] }, + func(name string) bool { return available[name] }) + if err != nil { + t.Fatal(err) + } + want := []string{"linux-cachyos-bore-lto-headers", "linux-headers"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("headers = %v, want %v", got, want) + } +} + +func TestEditionPackagePlansIncludeSelectedFileManagerAppsAndRadio(t *testing.T) { root := filepath.Clean("..") for _, editionName := range []string{"void", "fedora"} { t.Run(editionName, func(t *testing.T) { @@ -425,7 +460,7 @@ func TestEditionPackagePlansIncludeSelectedFileManagerAndRyokuApps(t *testing.T) t.Fatal(err) } set := packageSet(packages) - for _, pkg := range []string{"dolphin", "ryotunes", "ryomanager", "ryoku-rashin"} { + for _, pkg := range []string{"dolphin", "ryotunes", "ryomanager", "ryoku-rashin", "mpv", "mpv-mpris"} { if !set[pkg] { t.Errorf("selected package %s is missing from %v", pkg, packages) } diff --git a/ryoku-shell-installer/ryoku-shell-install b/ryoku-shell-installer/ryoku-shell-install index 2eab7e3d95..5f7386f679 100755 Binary files a/ryoku-shell-installer/ryoku-shell-install and b/ryoku-shell-installer/ryoku-shell-install differ diff --git a/ryoku-shell-installer/ryoku-shell-install.sha256 b/ryoku-shell-installer/ryoku-shell-install.sha256 index c1ffae7d9b..e09257a27f 100644 --- a/ryoku-shell-installer/ryoku-shell-install.sha256 +++ b/ryoku-shell-installer/ryoku-shell-install.sha256 @@ -1 +1 @@ -36b1e2c088ac9f4d8c46906a276cddce39a202b7eb132ca9bf4f0d78825d8f63 ryoku-shell-install +3f1b5d1ee007c8efb6a1ffbfdec6eba0bea4a761be5f622ebc2f3d56f1482e9a ryoku-shell-install diff --git a/ryoku-shell-installer/satisfied_test.go b/ryoku-shell-installer/satisfied_test.go index 73d0c91ed6..9401548252 100644 --- a/ryoku-shell-installer/satisfied_test.go +++ b/ryoku-shell-installer/satisfied_test.go @@ -1,6 +1,9 @@ package main -import "testing" +import ( + "slices" + "testing" +) // The qt6ct-kde shape: an installed provider must drop the shipped name from // the install set instead of the run dying on the conflict prompt. @@ -20,3 +23,35 @@ func TestDropSatisfiedEmptyUnmetDropsAll(t *testing.T) { t.Errorf("everything satisfied should drop all, got %v", got) } } + +func TestDropFedoraSatisfiedByAlternativeProvider(t *testing.T) { + tests := []struct { + name string + provided map[string]bool + want []string + }{ + { + name: "RPM Fusion ffmpeg and Docker CE", + provided: map[string]bool{ + "/usr/bin/ffmpeg": true, + "docker": true, + }, + want: []string{"ryoku-desktop"}, + }, + { + name: "no alternatives installed", + want: []string{"ffmpeg-free", "moby-engine", "ryoku-desktop"}, + }, + } + packages := []string{"ffmpeg-free", "moby-engine", "ryoku-desktop"} + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := filterFedoraSatisfied(packages, func(capability string) bool { + return test.provided[capability] + }) + if !slices.Equal(got, test.want) { + t.Errorf("filter = %v, want %v", got, test.want) + } + }) + } +} diff --git a/ryoku/CHANGELOG.md b/ryoku/CHANGELOG.md index 8b31322c93..51dba34898 100644 --- a/ryoku/CHANGELOG.md +++ b/ryoku/CHANGELOG.md @@ -3,6 +3,23 @@ ## Unreleased ### Fixed +- **The login screen appears without a six-second black screen, at the + panel's top refresh rate.** The greeter asked `modetest` for the card by + `/dev/dri` path, which its `-D` (a legacy bus id) never opens, so every boot + polled until the settle deadline and then fell back to weston's preferred + mode. It now probes by DRM driver, keeps only connectors sysfs reports on the + chosen card, and starts weston at once when the card cannot be probed + (`lockscreen/sddm/ryoku-greeter`). +- **Super+Arrow crosses screens from fullscreen windows again.** The shortcuts + now use Hyprland's width and height fields and fall back to normal directional + focus when monitor geometry is unavailable (`hyprland/modules/binds.lua`). +- **Fullscreen browsers no longer make some HDR displays go black.** Automatic + HDR switching starts off on Hyprland, while Settings offers an opt-in switch + for panels that handle the mode change (`hyprland/modules/render.lua`, + `wm/hyprland/`). +- **Hyprland no longer draws its splash text at startup.** Splash rendering is + off beside the already disabled logo, so the first frame is the wallpaper + (`hyprland/modules/misc.lua`, contributed by feelyourwarmth). - **Holding a mute, mic-mute or play key on niri toggles once.** niri repeats binds while a key is held, so a slightly long press could mute and unmute the microphone again or pause and resume playback; these toggles now ignore diff --git a/ryoku/apps/CHANGELOG.md b/ryoku/apps/CHANGELOG.md index 3541ce8132..b1cd82b36b 100644 --- a/ryoku/apps/CHANGELOG.md +++ b/ryoku/apps/CHANGELOG.md @@ -3,6 +3,19 @@ ## Unreleased ### Added +- `ryostore/`: **Apps is a marketplace page.** The Apps catalogue opens with a + featured shelf, a row of Flathub category plates with counts, a Trending / + Popular / New / Updated strip, a filter field, and compact icon and text + tiles that fit several apps per row; every other category keeps the artwork + grid. The plates and the filter narrow the loaded catalogue in place, and + the shelf steps aside while either is active. The strip sorts the union of + Flathub's popular, trending, recently added and recently updated + collections, so a tab describes the catalogue instead of reshuffling one + popular page. The union is cached as one snapshot, a partial fetch still + serves what answered and leaves the complete cache in place, and search + keeps its relevance order (`ryostore/backend/provider_flatpak.go`, + `ryostore/quickshell/AppsHero.qml`, `AppsTile.qml`, `AppsSearch.qml`, + `lib/store.js`). - `ryostore/`: **Apps: browse and install Flathub applications.** The Apps category lists popular and installed applications, searches the wider Flathub catalogue, and labels Flathub verification. New apps use the doctor's shared diff --git a/ryoku/apps/ryostore/backend/provider_flatpak.go b/ryoku/apps/ryostore/backend/provider_flatpak.go index f5ded1f20c..490f30633e 100644 --- a/ryoku/apps/ryostore/backend/provider_flatpak.go +++ b/ryoku/apps/ryostore/backend/provider_flatpak.go @@ -16,6 +16,7 @@ import ( "runtime" "sort" "strings" + "sync" "time" "unicode/utf8" ) @@ -29,11 +30,15 @@ const ( flathubSearchHits = 30 flathubQueryMin = 2 flathubQueryMax = 100 - flathubCache = "flathub-popular.json" + flathubCache = "flathub-catalogue.json" flatpakTimeout = 30 * time.Minute flatpakUpdateProbe = 20 * time.Second ) +// The four collections the store ranks by. Popular stays first so an +// unrequested order matches the old catalogue. +var flathubCollections = []string{"popular", "trending", "recently-added", "recently-updated"} + var flatpakIDPattern = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9_-]*(\.[A-Za-z0-9_-]+){2,}$`) func validFlatpakID(id string) bool { return len(id) <= 255 && flatpakIDPattern.MatchString(id) } @@ -52,6 +57,8 @@ type flathubHit struct { Verified bool `json:"verification_verified"` Arches []string `json:"arches"` AddedAt int64 `json:"added_at"` + UpdatedAt int64 `json:"updated_at"` + Trending float64 `json:"trending"` InstallsLastMonth int64 `json:"installs_last_month"` } @@ -128,7 +135,7 @@ func (flatpakProvider) Category() Category { ID: flatpakCategory, Name: "Apps", Group: "EXTEND", - Description: "Popular applications from Flathub, installed with Flatpak. Flathub is a third-party source: Ryostore does not review these.", + Description: "Applications from Flathub, installed with Flatpak. Flathub is a third-party source: Ryostore does not review these.", } } @@ -161,7 +168,7 @@ func parseFlatpakList(out []byte) []installedFlatpak { } func (p flatpakProvider) Load(ctx context.Context, refresh bool) ([]Item, SourceState, error) { - hits, state, err := p.popular(ctx, refresh) + hits, state, err := p.catalogue(ctx, refresh) var installed []installedFlatpak if p.present() && p.installed != nil { installed, _ = p.installed(ctx) @@ -335,7 +342,7 @@ func hasArch(arches []string, arch string) bool { return false } -func (p flatpakProvider) popular(ctx context.Context, refresh bool) ([]flathubHit, SourceState, error) { +func (p flatpakProvider) catalogue(ctx context.Context, refresh bool) ([]flathubHit, SourceState, error) { file := filepath.Join(extrasCacheDir(), flathubCache) if !refresh { if info, err := os.Stat(file); err == nil && time.Since(info.ModTime()) < 6*time.Hour { @@ -344,14 +351,14 @@ func (p flatpakProvider) popular(ctx context.Context, refresh bool) ([]flathubHi } } } - raw, err := p.fetchPopular(ctx) + hits, complete, err := p.fetchCollections(ctx) if err == nil { - if hits, derr := decodeFlathub(raw); derr == nil { - _ = atomicWrite(file, raw, 0o644) - return hits, SourceState{}, nil - } else { - err = derr + if complete { + if raw, merr := json.Marshal(flathubCollection{Hits: hits}); merr == nil { + _ = atomicWrite(file, raw, 0o644) + } } + return hits, SourceState{}, nil } if archived := mustRead(file); len(archived) > 0 { if hits, derr := decodeFlathub(archived); derr == nil { @@ -365,6 +372,59 @@ func (p flatpakProvider) popular(ctx context.Context, refresh bool) ([]flathubHi return nil, SourceState{}, fmt.Errorf("flathub: %w", err) } +// fetchCollections merges the ranking collections by app id. A partial failure +// keeps the collections that answered and reports complete as false. +func (p flatpakProvider) fetchCollections(ctx context.Context) ([]flathubHit, bool, error) { + type result struct { + hits []flathubHit + err error + } + results := make([]result, len(flathubCollections)) + var wg sync.WaitGroup + for i, name := range flathubCollections { + wg.Add(1) + go func() { + defer wg.Done() + raw, err := p.fetchCollection(ctx, name) + if err != nil { + results[i].err = err + return + } + hits, err := decodeFlathub(raw) + results[i] = result{hits: hits, err: err} + }() + } + wg.Wait() + + var merged []flathubHit + seen := map[string]bool{} + complete := true + var firstErr error + for _, r := range results { + if r.err != nil { + complete = false + if firstErr == nil { + firstErr = r.err + } + continue + } + for _, hit := range r.hits { + if seen[hit.AppID] { + continue + } + seen[hit.AppID] = true + merged = append(merged, hit) + } + } + if len(merged) == 0 { + if firstErr == nil { + firstErr = errors.New("empty collection") + } + return nil, false, firstErr + } + return merged, complete, nil +} + func mustRead(path string) []byte { f, err := os.Open(path) if err != nil { @@ -389,8 +449,8 @@ func decodeFlathub(raw []byte) ([]flathubHit, error) { return c.Hits, nil } -func (p flatpakProvider) fetchPopular(ctx context.Context) ([]byte, error) { - return p.flathub(ctx, http.MethodGet, fmt.Sprintf("/collection/popular?page=1&per_page=%d", flathubPageSize), nil) +func (p flatpakProvider) fetchCollection(ctx context.Context, name string) ([]byte, error) { + return p.flathub(ctx, http.MethodGet, fmt.Sprintf("/collection/%s?page=1&per_page=%d", url.PathEscape(name), flathubPageSize), nil) } func (p flatpakProvider) flathub(ctx context.Context, method, path string, payload any) ([]byte, error) { @@ -453,6 +513,16 @@ func flatpakItem(hit flathubHit, owned installedFlatpak, unavailable string) Ite if hit.Verified { verification = "verified" } + metadata := map[string]any{ + "provider": "Flathub", + "installsLastMonth": hit.InstallsLastMonth, + } + if hit.Trending > 0 { + metadata["trending"] = hit.Trending + } + if hit.UpdatedAt > 0 { + metadata["updatedAt"] = time.Unix(hit.UpdatedAt, 0).UTC().Format(time.RFC3339) + } item := Item{ ID: hit.AppID, Category: flatpakCategory, @@ -468,10 +538,7 @@ func flatpakItem(hit flathubHit, owned installedFlatpak, unavailable string) Ite Tags: flathubTags(hit), Installed: owned.ID != "", InstalledVersion: owned.Version, - Metadata: map[string]any{ - "provider": "Flathub", - "installsLastMonth": hit.InstallsLastMonth, - }, + Metadata: metadata, } if hit.AddedAt > 0 { item.ListedAt = time.Unix(hit.AddedAt, 0).UTC().Format(time.RFC3339) diff --git a/ryoku/apps/ryostore/backend/provider_flatpak_test.go b/ryoku/apps/ryostore/backend/provider_flatpak_test.go index 1137088007..7250b3d1de 100644 --- a/ryoku/apps/ryostore/backend/provider_flatpak_test.go +++ b/ryoku/apps/ryostore/backend/provider_flatpak_test.go @@ -6,6 +6,7 @@ import ( "errors" "net/http" "net/http/httptest" + "os" "path/filepath" "reflect" "strings" @@ -13,13 +14,15 @@ import ( ) type flatpakFixture struct { - provider flatpakProvider - calls [][]string - failing bool - body map[string]any - remotes map[string]string - found map[string]any - searched []map[string]any + provider flatpakProvider + calls [][]string + failing bool + body map[string]any + bodies map[string]map[string]any + failPaths map[string]bool + remotes map[string]string + found map[string]any + searched []map[string]any } func hit(id, name, kind string, verified bool, arches ...string) map[string]any { @@ -30,7 +33,8 @@ func hit(id, name, kind string, verified bool, arches ...string) map[string]any "project_license": "GPL-3.0+", "developer_name": "Dev", "main_categories": "graphics", "sub_categories": []string{"Photography"}, "verification_verified": verified, "arches": arches, - "added_at": 1493820137, "installs_last_month": 42, + "added_at": 1493820137, "updated_at": 1700000000, + "trending": 12.5, "installs_last_month": 42, } } @@ -48,14 +52,25 @@ func newFlatpakFixture(t *testing.T, installed []installedFlatpak) *flatpakFixtu hit("org.example.Foreign", "Foreign", "desktop-application", true, "riscv64"), hit("--evil", "Evil", "desktop-application", true, arch), }} + fx.bodies = map[string]map[string]any{} + fx.failPaths = map[string]bool{} + for _, name := range flathubCollections { + fx.bodies[name] = fx.body + } server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if fx.failing { http.Error(w, "down", http.StatusBadGateway) return } switch { - case r.Method == http.MethodGet && r.URL.Path == "/collection/popular": - _ = json.NewEncoder(w).Encode(fx.body) + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/collection/"): + name := strings.TrimPrefix(r.URL.Path, "/collection/") + body, ok := fx.bodies[name] + if !ok || fx.failPaths[name] { + http.NotFound(w, r) + return + } + _ = json.NewEncoder(w).Encode(body) case r.Method == http.MethodPost && r.URL.Path == "/search": var sent map[string]any _ = json.NewDecoder(r.Body).Decode(&sent) @@ -150,6 +165,12 @@ func TestFlatpakLoadFiltersAndMarksInstalled(t *testing.T) { if !reflect.DeepEqual(alpha.Tags, []string{"graphics", "photography"}) { t.Errorf("tags = %v", alpha.Tags) } + if alpha.Metadata["trending"] != 12.5 { + t.Errorf("trending = %v", alpha.Metadata["trending"]) + } + if alpha.Metadata["updatedAt"] != "2023-11-14T22:13:20Z" { + t.Errorf("updatedAt = %v", alpha.Metadata["updatedAt"]) + } if alpha.Unavailable { t.Errorf("alpha should be available") } @@ -168,6 +189,38 @@ func TestFlatpakCategoryCountsInstalled(t *testing.T) { } } +func TestFlatpakLoadMergesEveryCollection(t *testing.T) { + fx := newFlatpakFixture(t, nil) + arch := flathubArch() + fx.bodies["trending"] = searchResult(hit("org.example.Trend", "Trend", "desktop-application", true, arch)) + fx.bodies["recently-added"] = searchResult(hit("org.example.New", "New", "desktop-application", true, arch)) + fx.bodies["recently-updated"] = searchResult(hit("org.example.Updated", "Updated", "desktop-application", true, arch)) + items, state, err := fx.provider.Load(context.Background(), true) + if err != nil || state.Offline { + t.Fatalf("Load = %v, offline %v", err, state.Offline) + } + var ids []string + for _, it := range items { + ids = append(ids, it.ID) + } + want := []string{"org.example.Alpha", "org.example.Beta", "org.example.Trend", "org.example.New", "org.example.Updated"} + if !reflect.DeepEqual(ids, want) { + t.Fatalf("ids = %v, want %v", ids, want) + } +} + +func TestFlatpakPartialCollectionFailureKeepsTheRest(t *testing.T) { + fx := newFlatpakFixture(t, nil) + fx.failPaths["trending"] = true + items, state, err := fx.provider.Load(context.Background(), true) + if err != nil || state.Offline || len(items) != 2 { + t.Fatalf("Load = %d items, state %+v, err %v", len(items), state, err) + } + if _, err := os.Stat(filepath.Join(extrasCacheDir(), flathubCache)); !os.IsNotExist(err) { + t.Fatalf("a partial collection must not replace the complete cache: %v", err) + } +} + func TestFlatpakOfflineServesArchive(t *testing.T) { fx := newFlatpakFixture(t, nil) if _, _, err := fx.provider.Load(context.Background(), true); err != nil { @@ -814,7 +867,7 @@ func TestFlathubRejectsCrossOriginRedirect(t *testing.T) { })) defer source.Close() p := flatpakProvider{api: source.URL} - if _, err := p.fetchPopular(context.Background()); err == nil { + if _, err := p.fetchCollection(context.Background(), "popular"); err == nil { t.Fatal("cross-origin redirect accepted") } if fetched { diff --git a/ryoku/apps/ryostore/quickshell/App.qml b/ryoku/apps/ryostore/quickshell/App.qml index 71d47ef9a9..ddfc2f5e60 100644 --- a/ryoku/apps/ryostore/quickshell/App.qml +++ b/ryoku/apps/ryostore/quickshell/App.qml @@ -22,6 +22,7 @@ Rectangle { property string providerFilter: "" property string pluginFilter: "" property string omarchySort: "popular" + property string appSort: "trending" property string selectedKey: "" property var previewItem: null property var detailItem: null @@ -112,12 +113,14 @@ Rectangle { readonly property var collection: StoreLogic.collection(searchableItems, { view: view, categoryID: categoryID, - query: query, + query: app.appsBrowse ? app.appsQuery : (app.categoryBrowse ? app.categoryQuery : query), + tag: app.appsBrowse ? app.appsTag : "", provider: app.themesBrowse && app.providerFilter !== "" && app.providerFilter !== "__mine__" ? app.providerFilter : "", installedOnly: app.themesBrowse && app.providerFilter === "__mine__", pluginKind: app.pluginsBrowse ? app.pluginFilter : "", seed: app.discoverSeed, - omarchySort: app.omarchySort + omarchySort: app.omarchySort, + appSort: app.appsBrowse ? app.appSort : "" }) readonly property var selectedItem: itemForKey(selectedKey, collection) readonly property var busyItem: itemForKey(Store.busyKey, searchableItems) @@ -131,13 +134,28 @@ Rectangle { ? String(selectedIndex + 1) + " / " + String(collection.length) : "" readonly property bool showHero: view === "discover" && categoryID === "" && !searchOpen && collection.length > 0 + readonly property bool appsBrowse: categoryID === "flatpak" && view === "discover" && !searchOpen + readonly property bool categoryBrowse: view === "discover" && categoryID !== "" && !searchOpen + property string categoryQuery: "" + readonly property bool appsScope: appsBrowse || remoteScope + readonly property bool narrowAppsTools: width < 980 + || (Window.window && Window.window.width < 980) + property string appsTag: "" + property string appsQuery: "" + readonly property bool appsFiltered: app.appsBrowse && (app.appsTag !== "" || app.appsQuery !== "") + readonly property var appTabs: StoreLogic.appCategories(Store.items) + readonly property var featured: app.appsBrowse && !app.appsFiltered + ? StoreLogic.featuredApps(collection, 5, app.discoverSeed) : [] readonly property string contentMotionKey: [ view, categoryID, query, providerFilter, + categoryQuery, pluginFilter, - omarchySort + omarchySort, + appSort, + appsTag ].join("|") readonly property var currentCategory: categoryID === "subshells" ? app.navigationCategories.find(category => category.id === "subshells") @@ -193,6 +211,13 @@ Rectangle { { "key": "new", "label": I18n.tr("NEW") }, { "key": "verified", "label": I18n.tr("VERIFIED") } ] + readonly property var appSortTabs: [ + { "key": "trending", "label": I18n.tr("TRENDING") }, + { "key": "popular", "label": I18n.tr("POPULAR") }, + { "key": "new", "label": I18n.tr("NEW") }, + { "key": "updated", "label": I18n.tr("UPDATED") }, + { "key": "a-z", "label": I18n.tr("A TO Z") } + ] readonly property var themeProviders: { var seen = ({}); var out = []; @@ -310,6 +335,10 @@ Rectangle { app.providerFilter = ""; app.pluginFilter = ""; app.omarchySort = "popular"; + app.appSort = "trending"; + app.appsTag = ""; + app.appsQuery = ""; + app.categoryQuery = ""; detailClear.stop(); detailOpen = false; detailItem = null; @@ -329,6 +358,41 @@ Rectangle { Qt.callLater(function() { productGrid.forceActiveFocus(); }); } + function setAppSort(key) { + app.appSort = key; + app.selectedKey = app.collection.length > 0 + ? StoreLogic.itemKey(app.collection[0]) : ""; + productGrid.restoreOffset(0); + Qt.callLater(function() { productGrid.forceActiveFocus(); }); + } + + function setAppTag(key) { + app.appsTag = key; + app.reconcileSelection(0); + productGrid.restoreOffset(0); + Qt.callLater(function() { productGrid.forceActiveFocus(); }); + } + + function setAppsQuery(value) { + app.appsQuery = value; + app.reconcileSelection(0); + productGrid.restoreOffset(0); + } + + function setCategoryQuery(value) { + app.categoryQuery = value; + app.reconcileSelection(0); + productGrid.restoreOffset(0); + } + + function clearAppFilter() { + app.appsTag = ""; + app.appsQuery = ""; + app.reconcileSelection(0); + productGrid.restoreOffset(0); + Qt.callLater(function() { productGrid.forceActiveFocus(); }); + } + function selectKey(key) { selectedKey = StoreLogic.selectionKey(collection, key, 0); previewItem = null; @@ -633,10 +697,54 @@ Rectangle { onInstallAll: Store.installAll(app.providerItems(app.providerFilter)) } + Item { + id: categoryTools + objectName: "ryostore-category-tools" + anchors { left: header.right; top: providerTabs.bottom; right: parent.right } + readonly property bool shown: app.categoryBrowse && !app.appsBrowse + height: shown ? 48 : 0 + visible: shown + enabled: !app.detailOpen + + Text { + anchors { left: parent.left; leftMargin: Tokens.s6; verticalCenter: parent.verticalCenter } + text: I18n.tr("A TO Z") + color: Tokens.inkMuted + font.family: Tokens.mono + font.pixelSize: Tokens.fTiny + font.letterSpacing: Tokens.trackLabel + } + + CategorySearch { + id: categorySearch + objectName: "ryostore-category-search" + anchors { + right: parent.right; rightMargin: Tokens.s6 + verticalCenter: parent.verticalCenter + } + width: Tokens.px(260) + height: 36 + value: app.categoryQuery + placeholder: I18n.tr("Search %1").arg(app.pageTitle) + onEdited: value => app.setCategoryQuery(value) + onCleared: { + app.setCategoryQuery(""); + productGrid.forceActiveFocus(); + } + onAccepted: if (app.selectedItem) app.openSelectedDetail() + } + + Rectangle { + anchors { left: parent.left; right: parent.right; bottom: parent.bottom } + height: Tokens.border + color: Tokens.line + } + } + ShowroomStage { id: stage objectName: "ryostore-stage" - anchors { left: header.right; top: providerTabs.bottom; right: parent.right } + anchors { left: header.right; top: categoryTools.bottom; right: parent.right } height: app.showHero ? Math.min(300, Math.max(240, Math.round((actionBar.y - providerTabs.y) * 0.42))) : 0 visible: app.showHero @@ -659,15 +767,97 @@ Rectangle { onRemoveRequested: item => Store.remove(item) } + AppsHero { + id: appsHero + objectName: "ryostore-apps-hero" + anchors { + left: header.right; leftMargin: Tokens.s6 + right: parent.right; rightMargin: Tokens.s6 + top: categoryTools.bottom + } + visible: app.appsBrowse && !app.appsFiltered + enabled: !app.detailOpen + height: visible ? Tokens.px(192) : 0 + items: app.featured + reducedMotion: app.reducedMotion + onActivated: item => { + app.selectKey(StoreLogic.itemKey(item)); + app.openSelectedDetail(); + } + } + + ProviderTabs { + id: appsTabs + objectName: "ryostore-apps-tabs" + anchors { left: header.right; right: parent.right; top: appsHero.bottom } + visible: app.appsBrowse + height: visible ? implicitHeight : 0 + enabled: !app.detailOpen + providers: app.appTabs + active: app.appsTag + allLabel: I18n.tr("ALL") + trailingLabel: "" + installableCount: 0 + reducedMotion: app.reducedMotion + onPicked: key => app.setAppTag(key) + } + + Item { + id: appsTools + objectName: "ryostore-apps-tools" + anchors { left: header.right; right: parent.right; top: appsTabs.bottom } + visible: app.appsBrowse + height: visible ? (app.narrowAppsTools ? 88 : 44) : 0 + enabled: !app.detailOpen + + ProviderTabs { + id: appsSort + objectName: "ryostore-apps-sort" + anchors { + left: parent.left + right: app.narrowAppsTools ? parent.right : appsSearch.left + top: parent.top + } + height: 44 + providers: app.appSortTabs + active: app.appSort + allLabel: "" + trailingLabel: "" + installableCount: 0 + reducedMotion: app.reducedMotion + onPicked: key => app.setAppSort(key) + } + + CategorySearch { + id: appsSearch + objectName: "ryostore-apps-search" + anchors { right: parent.right; rightMargin: Tokens.s6 } + y: app.narrowAppsTools ? 52 : 4 + width: Tokens.px(240) + height: 36 + value: app.appsQuery + placeholder: I18n.tr("Search apps") + fieldObjectName: "ryostore-apps-search-field" + onEdited: value => app.setAppsQuery(value) + onCleared: { + app.setAppsQuery(""); + productGrid.forceActiveFocus(); + } + onAccepted: if (app.selectedItem) app.openSelectedDetail() + } + } + ProductGrid { id: productGrid objectName: "ryostore-grid" anchors { left: header.right; leftMargin: Tokens.s6 - top: stage.bottom + top: app.appsBrowse ? appsTools.bottom : stage.bottom right: parent.right; rightMargin: Tokens.s6 bottom: actionBar.top + topMargin: app.appsBrowse ? Tokens.s3 : 0 } + compact: app.appsScope items: app.collection selectedKey: app.selectedKey reducedMotion: app.reducedMotion @@ -755,6 +945,11 @@ Rectangle { model: 6 delegate: Rectangle { required property int index + readonly property bool appShape: app.appsScope + readonly property real iconSize: Tokens.px(56) + readonly property real textX: appShape ? iconSize + Tokens.s3 * 2 : Tokens.s3 + readonly property real barW: appShape + ? parent.width - iconSize - Tokens.s3 * 3 : parent.width * 0.52 width: productGrid.cellW height: productGrid.cellH color: Tokens.paper @@ -762,6 +957,7 @@ Rectangle { border.color: Tokens.line Rectangle { + visible: !parent.appShape anchors { left: parent.left; right: parent.right; top: parent.top } height: Math.round(parent.height * 0.67) color: Tokens.paperLift @@ -782,16 +978,24 @@ Rectangle { } } Rectangle { + visible: parent.appShape x: Tokens.s3 - y: Math.round(parent.height * 0.67) + Tokens.s3 - width: parent.width * 0.52 + y: Tokens.s3 + width: parent.iconSize + height: parent.iconSize + color: Tokens.paperLift + } + Rectangle { + x: parent.textX + y: parent.appShape ? Tokens.s3 : Math.round(parent.height * 0.67) + Tokens.s3 + width: parent.barW height: Tokens.border * 4 color: Tokens.line } Rectangle { - x: Tokens.s3 - y: Math.round(parent.height * 0.67) + Tokens.s5 - width: parent.width * 0.72 + x: parent.textX + y: parent.appShape ? Tokens.s5 : Math.round(parent.height * 0.67) + Tokens.s5 + width: parent.appShape ? parent.barW : parent.width * 0.72 height: Tokens.border * 3 color: Tokens.lineSoft } @@ -854,20 +1058,22 @@ Rectangle { ? I18n.tr("Searching Flathub.") : (app.remoteScope && Store.remoteError !== "" ? Store.remoteError - : (app.query !== "" - ? I18n.tr("No products match this search.") - : I18n.tr("Nothing has been published to this collection yet.")))) + : (app.appsFiltered + ? I18n.tr("No apps match this filter.") + : (app.query !== "" + ? I18n.tr("No products match this search.") + : I18n.tr("Nothing has been published to this collection yet."))))) } Btn { anchors.horizontalCenter: parent.horizontalCenter - text: I18n.tr("RETURN TO DISCOVER") - visible: app.view === "library" || app.query !== "" + text: app.appsFiltered ? I18n.tr("CLEAR FILTER") : I18n.tr("RETURN TO DISCOVER") + visible: app.view === "library" || app.query !== "" || app.appsFiltered armed: visible - onAct: app.openRoute("discover") + onAct: app.appsFiltered ? app.clearAppFilter() : app.openRoute("discover") Accessible.role: Accessible.Button Accessible.name: text - Accessible.onPressAction: app.openRoute("discover") + Accessible.onPressAction: app.appsFiltered ? app.clearAppFilter() : app.openRoute("discover") } } diff --git a/ryoku/apps/ryostore/quickshell/AppsHero.qml b/ryoku/apps/ryostore/quickshell/AppsHero.qml new file mode 100644 index 0000000000..c108f93010 --- /dev/null +++ b/ryoku/apps/ryostore/quickshell/AppsHero.qml @@ -0,0 +1,218 @@ +import QtQuick +import Ryoku.Ui +import Ryoku.Ui.Singletons +import "lib/store.js" as StoreLogic + +// The featured shelf at the top of the Apps catalogue: one app at a time, with +// arrows and dots as the only controls, so it reads as a shop front. +Item { + id: hero + + property var items: [] + property int currentIndex: 0 + property bool reducedMotion: false + + signal activated(var item) + + readonly property int index: items.length > 0 + ? Math.max(0, Math.min(items.length - 1, currentIndex)) : 0 + readonly property var current: items.length > 0 ? items[index] : ({}) + readonly property bool hasMultiple: items.length > 1 + readonly property bool hovered: hover.hovered + readonly property string title: String(current.name || current.id || "") + readonly property string summary: String(current.summary || "") + readonly property string categoryTag: current.tags && current.tags.length + ? String(current.tags[0]).toUpperCase() : "" + readonly property string statusTag: StoreLogic.statusTag(current) + readonly property bool verified: String(current.verificationStatus || "").toLowerCase() === "verified" + readonly property color fallbackSurface: current.surface || Tokens.paperLift + readonly property string initials: StoreLogic.initials(current, title) + + activeFocusOnTab: true + Accessible.role: Accessible.Button + Accessible.name: [title, summary].filter(Boolean).join(", ") + Accessible.onPressAction: hero.activated(hero.current) + + component Chip: Text { + color: Tokens.inkMuted + font.family: Tokens.mono + font.pixelSize: Tokens.fTiny + font.letterSpacing: Tokens.trackLabel + } + + function step(delta) { + if (items.length === 0) + return; + currentIndex = (index + delta + items.length) % items.length; + } + + Keys.onPressed: event => { + if (event.key === Qt.Key_Left) { + step(-1); + } else if (event.key === Qt.Key_Right) { + step(1); + } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + activated(current); + } else { + return; + } + event.accepted = true; + } + + onItemsChanged: if (currentIndex >= items.length) currentIndex = 0 + + Rectangle { + id: frame + anchors.fill: parent + radius: Tokens.radius + color: hero.hovered ? Tokens.tint5 : Tokens.paperLift + border.width: hero.activeFocus ? Tokens.border * 2 : Tokens.border + border.color: hero.activeFocus ? Tokens.bone + : (hero.hovered ? Tokens.lineStrong : Tokens.line) + scale: tap.pressed ? 0.996 : 1 + + Behavior on color { + enabled: !hero.reducedMotion + ColorAnimation { duration: Tokens.snap } + } + Behavior on border.color { + enabled: !hero.reducedMotion + ColorAnimation { duration: Tokens.snap } + } + Behavior on scale { + enabled: !hero.reducedMotion + NumberAnimation { duration: Tokens.snap; easing.type: Tokens.easeSnap } + } + + Row { + anchors { + left: parent.left; leftMargin: Tokens.s5 + top: parent.top; topMargin: Tokens.s5 + bottom: parent.bottom; bottomMargin: Tokens.s5 + right: arrows.left; rightMargin: Tokens.s4 + } + spacing: Tokens.s5 + + ProductMedia { + width: Tokens.px(104) + height: Tokens.px(104) + anchors.verticalCenter: parent.verticalCenter + source: hero.current.art || "" + mode: "icon" + active: hero.visible && hero.Window.window && hero.Window.window.active + fallbackText: hero.initials + fallbackSurface: hero.fallbackSurface + fallbackInk: Tokens.inkMuted + } + + Column { + width: parent.width - Tokens.px(104) - parent.spacing + anchors.verticalCenter: parent.verticalCenter + spacing: Tokens.s2 + + Text { + text: I18n.tr("FEATURED APPS") + " / " + + String(hero.index + 1).padStart(2, "0") + " / " + + String(hero.items.length).padStart(2, "0") + color: Tokens.inkDim + font.family: Tokens.mono + font.pixelSize: Tokens.fMicro + font.letterSpacing: Tokens.trackMark + } + + Text { + width: parent.width + text: hero.title + color: Tokens.ink + font.family: Tokens.display + font.pixelSize: Tokens.fHero + font.weight: Font.Medium + elide: Text.ElideRight + } + + Text { + width: parent.width + text: hero.summary + visible: text !== "" + color: Tokens.inkDim + font.family: Tokens.ui + font.pixelSize: Tokens.fSmall + maximumLineCount: 2 + wrapMode: Text.Wrap + elide: Text.ElideRight + } + + Row { + spacing: Tokens.s3 + + Chip { + text: hero.categoryTag + visible: text !== "" + } + Chip { + text: I18n.tr("VERIFIED") + visible: hero.verified + } + Chip { + text: hero.statusTag === "" ? "" : I18n.tr(hero.statusTag) + visible: text !== "" + color: StoreLogic.isUnavailable(hero.current) ? Tokens.alert : Tokens.ink + } + } + } + } + + Column { + id: arrows + anchors { + right: parent.right; rightMargin: Tokens.s4 + verticalCenter: parent.verticalCenter + verticalCenterOffset: -Tokens.s3 + } + spacing: Tokens.s2 + + IconBtn { + width: 30 + height: 30 + glyph: "‹" + armed: hero.hasMultiple + onAct: hero.step(-1) + } + IconBtn { + width: 30 + height: 30 + glyph: "›" + armed: hero.hasMultiple + onAct: hero.step(1) + } + } + + Row { + anchors { horizontalCenter: parent.horizontalCenter; bottom: parent.bottom; bottomMargin: Tokens.s3 } + spacing: Tokens.s2 + visible: hero.hasMultiple + + Repeater { + model: hero.items.length + delegate: Rectangle { + required property int index + width: 4 + height: 4 + radius: 2 + color: index === hero.index ? Tokens.ink : Tokens.lineStrong + HoverHandler { cursorShape: Qt.PointingHandCursor } + TapHandler { margin: Tokens.s1; onTapped: hero.currentIndex = index } + } + } + } + + HoverHandler { + id: hover + cursorShape: Qt.PointingHandCursor + } + TapHandler { + id: tap + onTapped: hero.activated(hero.current) + } + } +} diff --git a/ryoku/apps/ryostore/quickshell/AppsTile.qml b/ryoku/apps/ryostore/quickshell/AppsTile.qml new file mode 100644 index 0000000000..911da81fbb --- /dev/null +++ b/ryoku/apps/ryostore/quickshell/AppsTile.qml @@ -0,0 +1,148 @@ +import QtQuick +import Ryoku.Ui.Singletons +import "lib/store.js" as StoreLogic + +// The compact app card: an icon and its name read at a glance, so a marketplace +// row holds several apps instead of one oversized cover. +Item { + id: card + + required property var item + property bool selected: false + property bool focusVisible: false + property bool reducedMotion: false + property bool hovered: hover.hovered + property bool pressed: tap.pressed + + signal activated() + + readonly property string title: String(item && (item.name || item.id) || "") + readonly property string summary: String(item && item.summary || "") + readonly property bool verified: String(item && item.verificationStatus || "").toLowerCase() === "verified" + readonly property var status: StoreLogic.statusLabels(item) + readonly property string statusTag: StoreLogic.statusTag(item) + readonly property color fallbackSurface: item && item.surface ? item.surface : Tokens.paperLift + readonly property string initials: StoreLogic.initials(item, title) + + Accessible.role: Accessible.Button + Accessible.name: [title, summary, status.map(s => I18n.tr(s)).join(", ")].filter(Boolean).join(", ") + Accessible.onPressAction: card.activated() + + Rectangle { + id: frame + anchors.fill: parent + anchors.margins: Tokens.s2 + radius: Tokens.radius + color: card.selected ? Tokens.tint5 + : (card.pressed ? Tokens.tint16 : (card.hovered ? Tokens.tint5 : Tokens.paper)) + border.width: card.focusVisible || card.selected ? Tokens.border * 2 : Tokens.border + border.color: card.focusVisible ? Tokens.bone + : (card.selected || card.hovered ? Tokens.lineStrong : Tokens.line) + scale: card.pressed ? 0.994 : 1 + + Behavior on color { + enabled: !card.reducedMotion + ColorAnimation { duration: Tokens.snap } + } + Behavior on border.color { + enabled: !card.reducedMotion + ColorAnimation { duration: Tokens.snap } + } + Behavior on scale { + enabled: !card.reducedMotion + NumberAnimation { duration: Tokens.snap; easing.type: Tokens.easeSnap } + } + + Row { + anchors.fill: parent + anchors.margins: Tokens.s3 + spacing: Tokens.s3 + + ProductMedia { + width: Tokens.px(56) + height: Tokens.px(56) + anchors.verticalCenter: parent.verticalCenter + source: card.item && card.item.art || "" + mode: "icon" + active: card.enabled && card.Window.window && card.Window.window.active + fallbackText: card.initials + fallbackSurface: card.fallbackSurface + fallbackInk: Tokens.inkMuted + } + + Column { + width: parent.width - Tokens.px(56) - parent.spacing + anchors.verticalCenter: parent.verticalCenter + spacing: Tokens.s1 + + Item { + width: parent.width + height: Math.max(nameText.implicitHeight, statusText.implicitHeight) + + Text { + id: nameText + anchors { + left: parent.left + right: card.verified ? verifiedMark.left : statusText.left + rightMargin: Tokens.s2 + verticalCenter: parent.verticalCenter + } + text: card.title + color: Tokens.ink + font.family: Tokens.display + font.pixelSize: Tokens.fRow + font.weight: Font.Medium + elide: Text.ElideRight + } + + Text { + id: verifiedMark + anchors { + right: statusText.left + rightMargin: Tokens.s2 + verticalCenter: parent.verticalCenter + } + text: "✓" + visible: card.verified + color: Tokens.inkDim + font.family: Tokens.ui + font.pixelSize: Tokens.fMicro + } + + Text { + id: statusText + anchors { right: parent.right; verticalCenter: parent.verticalCenter } + text: card.statusTag === "" ? "" : I18n.tr(card.statusTag) + visible: text !== "" + color: StoreLogic.isUnavailable(card.item) ? Tokens.alert : Tokens.inkMuted + font.family: Tokens.mono + font.pixelSize: Tokens.fTiny + font.letterSpacing: Tokens.trackLabel + } + } + + Text { + width: parent.width + text: card.summary + visible: text !== "" + color: Tokens.inkDim + font.family: Tokens.ui + font.pixelSize: Tokens.fSmall + maximumLineCount: 2 + wrapMode: Text.Wrap + elide: Text.ElideRight + } + } + } + } + + HoverHandler { + id: hover + cursorShape: Qt.PointingHandCursor + } + + TapHandler { + id: tap + onTapped: card.activated() + } +} diff --git a/ryoku/apps/ryostore/quickshell/CategorySearch.qml b/ryoku/apps/ryostore/quickshell/CategorySearch.qml new file mode 100644 index 0000000000..e7f0057035 --- /dev/null +++ b/ryoku/apps/ryostore/quickshell/CategorySearch.qml @@ -0,0 +1,79 @@ +import QtQuick +import Ryoku.Ui.Singletons + +// A local category filter. Typing narrows the loaded category in place; the +// header search still reaches the whole store or Flathub. +Item { + id: field + + property string value: "" + property string placeholder: I18n.tr("Search this category") + property string fieldObjectName: "ryostore-category-search-field" + + signal edited(string value) + signal cleared() + signal accepted() + + implicitHeight: 36 + + onValueChanged: if (input.text !== value) input.text = value + + Rectangle { + anchors.fill: parent + radius: Tokens.radius + color: input.activeFocus ? Tokens.tint5 : Tokens.paper + border.width: Tokens.border + border.color: input.activeFocus ? Tokens.lineStrong : Tokens.line + + Text { + id: glyph + anchors { left: parent.left; leftMargin: Tokens.s3; verticalCenter: parent.verticalCenter } + text: "⌕" + color: Tokens.inkDim + font.family: Tokens.ui + font.pixelSize: Tokens.fRow + } + + TextInput { + id: input + objectName: field.fieldObjectName + anchors { + left: glyph.right; leftMargin: Tokens.s2 + right: parent.right; rightMargin: Tokens.s3 + verticalCenter: parent.verticalCenter + } + color: Tokens.ink + selectionColor: Tokens.tint16 + selectedTextColor: Tokens.ink + font.family: Tokens.ui + font.pixelSize: Tokens.fBody + clip: true + activeFocusOnTab: true + Accessible.role: Accessible.EditableText + Accessible.name: field.placeholder + onTextEdited: field.edited(text) + Keys.onEscapePressed: event => { + input.text = ""; + field.cleared(); + event.accepted = true; + } + Keys.onReturnPressed: event => { + field.accepted(); + event.accepted = true; + } + + Text { + anchors.fill: parent + visible: input.text === "" + text: field.placeholder + color: Tokens.inkMuted + font: input.font + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + } + } + + HoverHandler { cursorShape: Qt.IBeamCursor } + TapHandler { onTapped: input.forceActiveFocus() } +} diff --git a/ryoku/apps/ryostore/quickshell/ProductCover.qml b/ryoku/apps/ryostore/quickshell/ProductCover.qml index ffedc30d85..d57ab0bc0a 100644 --- a/ryoku/apps/ryostore/quickshell/ProductCover.qml +++ b/ryoku/apps/ryostore/quickshell/ProductCover.qml @@ -35,20 +35,13 @@ Item { readonly property bool hasIdentity: Boolean(item && (item.id || item.name)) readonly property string coverTitle: String(item && (item.name || item.id) || I18n.tr("Untitled")) readonly property color coverSurface: item && item.surface ? item.surface : Tokens.paperLift - readonly property string coverInitials: { - const explicit = String(item && item.initials || "").trim(); - if (explicit !== "") - return explicit.slice(0, 3).toUpperCase(); - const words = coverTitle.trim().split(/\s+/); - return (words.length > 1 ? words[0][0] + words[words.length - 1][0] - : coverTitle.slice(0, 2)).toUpperCase(); - } + readonly property string coverInitials: StoreLogic.initials(item, coverTitle) readonly property color identitySurface: identityColor() readonly property real identityLuma: identitySurface.r * 0.299 + identitySurface.g * 0.587 + identitySurface.b * 0.114 readonly property color identityInk: Qt.hsla(0, 0, identityLuma > 0.56 ? 0.08 : 0.94, 1) readonly property var status: StoreLogic.statusLabels(item) - readonly property string statusTag: status.length > 0 && status[0] !== "AVAILABLE" ? status[0] : "" + readonly property string statusTag: StoreLogic.statusTag(item) readonly property bool unavailable: StoreLogic.isUnavailable(item) readonly property color frameSurface: mode === "plate" || mode === "view" ? Tokens.paper : coverSurface diff --git a/ryoku/apps/ryostore/quickshell/ProductDetail.qml b/ryoku/apps/ryostore/quickshell/ProductDetail.qml index 8bfa1a5dfa..6fd1f6938f 100644 --- a/ryoku/apps/ryostore/quickshell/ProductDetail.qml +++ b/ryoku/apps/ryostore/quickshell/ProductDetail.qml @@ -41,6 +41,7 @@ FocusScope { readonly property var actionItem: item || ({}) readonly property string actionKey: StoreLogic.itemKey(actionItem) + readonly property string competingLabel: StoreLogic.competingActionLabel(busyKey, actionKey, installStage) readonly property var screenshots: Array.isArray(actionItem.screenshots) ? actionItem.screenshots : [] readonly property int screenshotCount: screenshots.length // The gallery and lightbox present whatever previews the product ships: @@ -830,14 +831,16 @@ FocusScope { visible: detail.isBundle text: detail.busyKey === detail.actionKey && detail.installStage !== "" ? I18n.tr(detail.installStage) - : (detail.actionItem.updateAvailable === true - ? I18n.tr("UPDATE SELECTED") : I18n.tr("INSTALL SELECTED")) + : (detail.competingLabel !== "" ? I18n.tr(detail.competingLabel) + : (detail.actionItem.updateAvailable === true + ? I18n.tr("UPDATE SELECTED") : I18n.tr("INSTALL SELECTED"))) primary: true busy: detail.busyKey === detail.actionKey reducedMotion: detail.reducedMotion progressDone: detail.installProgressDone progressTotal: detail.installProgressTotal - armed: detail.item !== null && detail.selectedNames.length > 0 + armed: detail.item !== null && detail.busyKey === "" + && detail.selectedNames.length > 0 && !StoreLogic.isDownloadPaused(detail.actionItem) && !StoreLogic.isUnavailable(detail.actionItem) onAct: detail.triggerInstallSelected() @@ -846,7 +849,8 @@ FocusScope { InstallAction { objectName: "ryostore-detail-install-all" visible: detail.isBundle - text: detail.actionItem.updateAvailable === true ? I18n.tr("UPDATE ALL") : I18n.tr("INSTALL ALL") + text: detail.competingLabel !== "" ? I18n.tr(detail.competingLabel) + : (detail.actionItem.updateAvailable === true ? I18n.tr("UPDATE ALL") : I18n.tr("INSTALL ALL")) armed: detail.item !== null && detail.busyKey === "" && !StoreLogic.isDownloadPaused(detail.actionItem) && !StoreLogic.isUnavailable(detail.actionItem) @@ -859,7 +863,8 @@ FocusScope { visible: !detail.isBundle text: detail.busyKey === detail.actionKey && detail.installStage !== "" ? I18n.tr(detail.installStage) - : I18n.tr(StoreLogic.primaryAction(detail.actionItem)) + : (detail.competingLabel !== "" ? I18n.tr(detail.competingLabel) + : I18n.tr(StoreLogic.primaryAction(detail.actionItem))) primary: true busy: detail.busyKey === detail.actionKey installed: StoreLogic.primaryAction(detail.actionItem) === "INSTALLED" @@ -867,6 +872,7 @@ FocusScope { progressDone: detail.installProgressDone progressTotal: detail.installProgressTotal armed: detail.item !== null + && detail.busyKey === "" && !installed && !StoreLogic.isDownloadPaused(detail.actionItem) && !StoreLogic.isUnavailable(detail.actionItem) diff --git a/ryoku/apps/ryostore/quickshell/ProductGrid.qml b/ryoku/apps/ryostore/quickshell/ProductGrid.qml index 5273e4428e..4e3fab5d23 100644 --- a/ryoku/apps/ryostore/quickshell/ProductGrid.qml +++ b/ryoku/apps/ryostore/quickshell/ProductGrid.qml @@ -16,6 +16,7 @@ FocusScope { property bool reducedMotion: false property string contentKey: "" property int entranceEpoch: 0 + property bool compact: false signal previewRequested(var item) signal selectionRequested(var item) @@ -23,9 +24,12 @@ FocusScope { activeFocusOnTab: true - readonly property int columns: Math.max(2, Math.floor(view.width / 300)) + readonly property int columns: grid.compact + ? Math.max(2, Math.min(4, Math.floor(view.width / 270))) + : Math.max(2, Math.floor(view.width / 300)) readonly property real cellW: view.width / grid.columns - readonly property real cellH: Math.round(grid.cellW * 0.84) + readonly property real cellH: grid.compact + ? Tokens.px(96) : Math.round(grid.cellW * 0.84) property alias contentY: view.contentY property bool restoring: false @@ -145,6 +149,7 @@ FocusScope { required property int index width: grid.cellW height: grid.cellH + readonly property bool isSelected: StoreLogic.itemKey(modelData) === grid.selectedKey property real entranceProgress: 1 opacity: entranceProgress @@ -190,20 +195,37 @@ FocusScope { Component.onCompleted: reveal() - ProductCard { + Loader { anchors.fill: parent - item: tile.modelData - selected: StoreLogic.itemKey(tile.modelData) === grid.selectedKey - focusVisible: grid.activeFocus - && StoreLogic.itemKey(tile.modelData) === grid.selectedKey - reducedMotion: grid.reducedMotion - onHoverChanged: hovered => { - if (hovered) { - grid.pendingPreview = tile.modelData; - dwell.restart(); + sourceComponent: grid.compact ? appTile : coverTile + } + + Component { + id: coverTile + ProductCard { + item: tile.modelData + selected: tile.isSelected + focusVisible: grid.activeFocus && tile.isSelected + reducedMotion: grid.reducedMotion + onHoverChanged: hovered => { + if (hovered) { + grid.pendingPreview = tile.modelData; + dwell.restart(); + } } + onActivated: grid.activated(tile.modelData) + } + } + + Component { + id: appTile + AppsTile { + item: tile.modelData + selected: tile.isSelected + focusVisible: grid.activeFocus && tile.isSelected + reducedMotion: grid.reducedMotion + onActivated: grid.activated(tile.modelData) } - onActivated: grid.activated(tile.modelData) } } } diff --git a/ryoku/apps/ryostore/quickshell/ProductMedia.qml b/ryoku/apps/ryostore/quickshell/ProductMedia.qml index fcc9f4a586..e1efbbc269 100644 --- a/ryoku/apps/ryostore/quickshell/ProductMedia.qml +++ b/ryoku/apps/ryostore/quickshell/ProductMedia.qml @@ -6,7 +6,7 @@ Item { id: media property url source: "" - property string mode: "cover" // cover | hero | plate | view + property string mode: "cover" // cover | icon | hero | plate | view property bool active: true property bool actualPixels: false property color surface: Tokens.paper @@ -19,7 +19,7 @@ Item { readonly property bool video: /\.(mp4|webm|mkv|mov|m4v)$/.test(cleanSource) readonly property bool animated: !video && /\.(gif|webp)$/.test(cleanSource) readonly property bool framed: mode === "plate" || mode === "view" - readonly property bool fitted: framed || mode === "hero" + readonly property bool fitted: framed || mode === "hero" || mode === "icon" readonly property bool nativeDecode: actualPixels || fitted || animated readonly property int imageFit: fitted ? Image.PreserveAspectFit : Image.PreserveAspectCrop readonly property int videoFit: fitted ? VideoOutput.PreserveAspectFit : VideoOutput.PreserveAspectCrop diff --git a/ryoku/apps/ryostore/quickshell/ProviderTabs.qml b/ryoku/apps/ryostore/quickshell/ProviderTabs.qml index a85de1e7a3..325e442b17 100644 --- a/ryoku/apps/ryostore/quickshell/ProviderTabs.qml +++ b/ryoku/apps/ryostore/quickshell/ProviderTabs.qml @@ -26,9 +26,11 @@ Item { component Plate: Rectangle { id: plate property string label: "" + property string note: "" property bool on: false signal chose() - width: plateText.implicitWidth + Tokens.s4 + width: plateText.implicitWidth + (noteText.visible ? noteText.implicitWidth + Tokens.s2 : 0) + + Tokens.s4 height: 30 radius: Tokens.radius color: plate.on ? Tokens.bone @@ -58,18 +60,31 @@ Item { event.accepted = true; } } - Text { - id: plateText + Row { anchors.centerIn: parent - text: I18n.tr(plate.label) - color: plate.on ? Tokens.inkOnBone : (plate.activeFocus ? Tokens.ink : Tokens.inkDim) - font.family: Tokens.ui - font.pixelSize: 11 - font.weight: Font.Medium - font.letterSpacing: Tokens.trackLabel - Behavior on color { - enabled: !tabs.reducedMotion - ColorAnimation { duration: Tokens.snap } + spacing: Tokens.s2 + + Text { + id: plateText + text: I18n.tr(plate.label) + color: plate.on ? Tokens.inkOnBone : (plate.activeFocus ? Tokens.ink : Tokens.inkDim) + font.family: Tokens.ui + font.pixelSize: 11 + font.weight: Font.Medium + font.letterSpacing: Tokens.trackLabel + Behavior on color { + enabled: !tabs.reducedMotion + ColorAnimation { duration: Tokens.snap } + } + } + Text { + id: noteText + visible: plate.note !== "" + text: plate.note + color: plate.on ? Tokens.inkOnBone : Tokens.inkMuted + font.family: Tokens.mono + font.pixelSize: Tokens.fTiny + font.letterSpacing: Tokens.trackLabel } } HoverHandler { id: ph; cursorShape: Qt.PointingHandCursor } @@ -112,6 +127,7 @@ Item { ? String(modelData.label) : String(modelData) objectName: "ryostore-provider-" + entryKey label: entryLabel.toUpperCase() + note: modelData && modelData.note !== undefined ? String(modelData.note) : "" on: tabs.active === entryKey onChose: tabs.picked(entryKey) } @@ -124,6 +140,16 @@ Item { onChose: tabs.picked(tabs.trailingKey) } } + + WheelHandler { + acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad + onWheel: event => { + const maxX = Math.max(0, strip.contentWidth - strip.width); + strip.contentX = Math.max(0, Math.min(maxX, + strip.contentX - event.angleDelta.y - event.angleDelta.x)); + event.accepted = true; + } + } } Btn { @@ -131,6 +157,7 @@ Item { objectName: "ryostore-provider-install-all" anchors { right: parent.right; rightMargin: Tokens.s6; verticalCenter: parent.verticalCenter } visible: tabs.active !== "" && tabs.active !== "__mine__" && tabs.installableCount > 0 + width: visible ? implicitWidth : 0 text: tabs.busy ? I18n.tr("INSTALLING") : (I18n.tr("INSTALL ALL / ") + tabs.installableCount) armed: !tabs.busy onAct: tabs.installAll() diff --git a/ryoku/apps/ryostore/quickshell/ShowroomStage.qml b/ryoku/apps/ryostore/quickshell/ShowroomStage.qml index 5784130e28..62ea5c6877 100644 --- a/ryoku/apps/ryostore/quickshell/ShowroomStage.qml +++ b/ryoku/apps/ryostore/quickshell/ShowroomStage.qml @@ -44,9 +44,10 @@ Item { readonly property var actionItem: item || ({}) readonly property int motionDuration: reducedMotion ? 0 : Tokens.swap readonly property string actionKey: StoreLogic.itemKey(actionItem) + readonly property string competingLabel: StoreLogic.competingActionLabel(busyKey, actionKey, installStage) readonly property string primaryLabel: busyKey === actionKey && installStage !== "" ? installStage - : StoreLogic.primaryAction(actionItem) + : (competingLabel !== "" ? competingLabel : StoreLogic.primaryAction(actionItem)) readonly property string secondaryLabel: StoreLogic.secondaryAction(actionItem) readonly property bool hasActionItem: item !== null && item !== undefined readonly property color stageSurface: displayItem.surface || Tokens.paper @@ -345,6 +346,7 @@ Item { progressDone: stage.installProgressDone progressTotal: stage.installProgressTotal armed: stage.hasActionItem + && stage.busyKey === "" && !installed && !StoreLogic.isDownloadPaused(stage.actionItem) && !StoreLogic.isUnavailable(stage.actionItem) diff --git a/ryoku/apps/ryostore/quickshell/lib/store.js b/ryoku/apps/ryostore/quickshell/lib/store.js index fb94ac9173..7ab0ec2dbc 100644 --- a/ryoku/apps/ryostore/quickshell/lib/store.js +++ b/ryoku/apps/ryostore/quickshell/lib/store.js @@ -230,6 +230,112 @@ function sortOmarchyPlugins(items, mode) { return sorted; } +function appMetric(item, mode) { + var meta = (item && item.metadata) || {}; + if (mode === "popular") + return Number(meta.installsLastMonth || 0); + if (mode === "trending") + return Number(meta.trending || 0); + if (mode === "new") + return String(item && item.listedAt || ""); + if (mode === "updated") + return String(meta.updatedAt || (item && item.listedAt) || ""); + return 0; +} + +function sortApps(items, mode) { + var sorted = (Array.isArray(items) ? items : []).slice(); + var key = mode || "trending"; + if (key === "a-z") + return sortAlphabetical(sorted); + sorted.sort(function(a, b) { + var av = appMetric(a, key); + var bv = appMetric(b, key); + var cmp = typeof av === "number" && typeof bv === "number" + ? bv - av + : String(bv).localeCompare(String(av)); + return cmp !== 0 ? cmp + : String(a.name || a.id || "").localeCompare(String(b.name || b.id || "")); + }); + return sorted; +} + +function featuredApps(items, count, seed) { + var source = Array.isArray(items) ? items : []; + var size = Math.max(1, Number(count) || 1); + var pool = source.filter(function(item) { return item && item.art && !isInstalled(item); }); + if (!pool.length) + pool = source.filter(function(item) { return item && item.art; }); + if (!pool.length) + pool = source; + // A wider pool than the shelf keeps the daily rotation on the strongest apps. + var top = sortApps(pool, "trending").slice(0, Math.max(size, 5)); + if (!top.length) + return []; + var start = (Number(seed) || 0) % top.length; + if (start < 0) + start += top.length; + return top.slice(start).concat(top.slice(0, start)).slice(0, size); +} + +// AVAILABLE is the absence of state, so it never becomes a chip. +function statusTag(item) { + var labels = statusLabels(item); + return labels.length > 0 && labels[0] !== "AVAILABLE" ? labels[0] : ""; +} + +function initials(item, title) { + var explicit = String(item && item.initials || "").trim(); + if (explicit !== "") + return explicit.slice(0, 3).toUpperCase(); + var name = String(title || (item && (item.name || item.id)) || "").trim(); + var words = name.split(/\s+/); + return (words.length > 1 ? words[0][0] + words[words.length - 1][0] + : name.slice(0, 2)).toUpperCase(); +} + +// Flathub's main categories, in the API's own order. An app carries them as +// tags, so the market tabs need no backend field. +var appCategoryLabels = { + "audiovideo": "AUDIO & VIDEO", + "development": "DEVELOPMENT", + "education": "EDUCATION", + "game": "GAMES", + "graphics": "GRAPHICS", + "network": "NETWORK", + "office": "OFFICE", + "science": "SCIENCE", + "system": "SYSTEM", + "utility": "UTILITY" +}; + +function appCategories(items) { + var counts = {}; + (Array.isArray(items) ? items : []).forEach(function(item) { + if (!item || item.category !== "flatpak") + return; + var tags = item.tags || []; + Object.keys(appCategoryLabels).forEach(function(key) { + if (tags.indexOf(key) !== -1) + counts[key] = (counts[key] || 0) + 1; + }); + }); + return Object.keys(appCategoryLabels).filter(function(key) { + return counts[key] > 0; + }).map(function(key) { + return { key: key, label: appCategoryLabels[key], note: String(counts[key]) }; + }); +} + +function sortAlphabetical(items) { + return (Array.isArray(items) ? items : []).slice().sort(function(a, b) { + var an = String(a && (a.name || a.id) || "").toLocaleLowerCase(); + var bn = String(b && (b.name || b.id) || "").toLocaleLowerCase(); + var byName = an.localeCompare(bn); + return byName || itemKey(a).localeCompare(itemKey(b)); + }); +} + function collection(items, options) { var opts = options || {}; var filtered = filter(items, { @@ -237,10 +343,15 @@ function collection(items, options) { installedOnly: opts.view === "library" || opts.installedOnly === true, provider: opts.provider || "", pluginKind: opts.pluginKind || "", + tag: opts.tag || "", query: opts.query || "" }); - if (opts.categoryID === "omarchy-plugins") + if (opts.appSort) + filtered = sortApps(filtered, opts.appSort); + else if (opts.categoryID === "omarchy-plugins") filtered = sortOmarchyPlugins(filtered, opts.omarchySort || "popular"); + else if (opts.categoryID && opts.categoryID !== "flatpak") + filtered = sortAlphabetical(filtered); if (opts.view !== "library" && !opts.categoryID && !opts.query) { var seed = Number(opts.seed) || 0; var lead = featured(filtered, seed); @@ -285,6 +396,17 @@ function primaryAction(item) { return isInstalled(item) ? "INSTALLED" : "INSTALL"; } +function competingActionLabel(busyKey, actionKey, installStage) { + if (String(busyKey || "") === "" || String(busyKey) === String(actionKey || "")) + return ""; + var stage = String(installStage || "").toUpperCase(); + if (stage.indexOf("REMOV") !== -1) + return "REMOVING ANOTHER ITEM"; + if (stage.indexOf("ENABL") !== -1 || stage.indexOf("DISABL") !== -1) + return "CHANGING ANOTHER ITEM"; + return "DOWNLOADING ANOTHER ITEM"; +} + function secondaryAction(item) { return (item && item.hasSettings && isInstalled(item)) ? "OPEN IN SETTINGS" : ""; } @@ -301,4 +423,4 @@ function sortCategories(categories) { } if (typeof module !== "undefined" && module.exports) - module.exports = { statusLabels, isInstalled, isDownloadPaused, downloadPauseReason, isUnavailable, unavailableLabel, unavailableReason, pluginKind, searchText, matchesQuery, filter, groupSearch, featured, installed, itemKey, sortOmarchyPlugins, collection, selectionKey, categoryPlates, primaryAction, secondaryAction, sortCategories, shuffleSeeded, isSubshell, categoryName, barCategories }; + module.exports = { statusLabels, statusTag, initials, isInstalled, isDownloadPaused, downloadPauseReason, isUnavailable, unavailableLabel, unavailableReason, pluginKind, searchText, matchesQuery, filter, groupSearch, featured, installed, itemKey, sortOmarchyPlugins, sortApps, sortAlphabetical, featuredApps, appCategories, collection, selectionKey, categoryPlates, primaryAction, competingActionLabel, secondaryAction, sortCategories, shuffleSeeded, isSubshell, categoryName, barCategories }; diff --git a/ryoku/apps/ryostore/quickshell/lib/store.test.mjs b/ryoku/apps/ryostore/quickshell/lib/store.test.mjs index 8605b30002..a19bc00eb7 100644 --- a/ryoku/apps/ryostore/quickshell/lib/store.test.mjs +++ b/ryoku/apps/ryostore/quickshell/lib/store.test.mjs @@ -25,6 +25,15 @@ eq(Store.collection(products, { view: "discover", query: "hero" }).map(Store.ite eq(Store.selectionKey(products, "lockscreens:lock", 0), "lockscreens:lock", "preserve valid selection"); eq(Store.selectionKey(products, "missing:item", 1), "lockscreens:lock", "fallback by bounded index"); eq(Store.selectionKey([], "missing:item", 0), "", "empty selection"); +eq(Store.collection([ + { id: "z", category: "lockscreens", name: "Zen" }, + { id: "a", category: "lockscreens", name: "Amber" }, + { id: "m", category: "lockscreens", name: "Moss" } +], { view: "discover", categoryID: "lockscreens" }).map(item => item.name), ["Amber", "Moss", "Zen"], "category collections sort A to Z"); +eq(Store.collection([ + { id: "z", category: "lockscreens", name: "Zen" }, + { id: "a", category: "lockscreens", name: "Amber" } +], { view: "discover", categoryID: "lockscreens", query: "zen" }).map(item => item.name), ["Zen"], "category search stays scoped to the open category"); const activeUpdate = { active: true, installed: true, updateAvailable: true }; eq(Store.statusLabels(activeUpdate), ["UPDATE", "ACTIVE"], "update and active are both explicit"); @@ -83,6 +92,10 @@ eq(Store.primaryAction({ installed: false, busy: true }), "INSTALLING", "busy ac eq(Store.secondaryAction({ installed: true, hasSettings: true }), "OPEN IN SETTINGS", "management handoff when the product has a settings page"); eq(Store.secondaryAction({ installed: true }), "", "installed product without a settings page has no handoff"); eq(Store.secondaryAction({ installed: false }), "", "available item has no management handoff"); +eq(Store.competingActionLabel("lockscreens:clock", "lockscreens:broken", "FETCHING"), "DOWNLOADING ANOTHER ITEM", "another product explains the single-flight download"); +eq(Store.competingActionLabel("lockscreens:clock", "lockscreens:clock", "FETCHING"), "", "the active product keeps its own progress stage"); +eq(Store.competingActionLabel("lockscreens:clock", "lockscreens:broken", "REMOVING"), "REMOVING ANOTHER ITEM", "another product explains a removal"); +eq(Store.competingActionLabel("", "lockscreens:broken", ""), "", "idle actions keep their ordinary label"); eq( Store.filter(items, { query: "plugin update" }).map(item => item.id), ["market"], @@ -182,13 +195,13 @@ eq( ); eq( Store.collection(plugins, { view: "discover", categoryID: "plugins", pluginKind: "bar" }).map(Store.itemKey), - ["plugins:vpn", "plugins:clockbar"], - "plugins BAR collection filters through the pluginKind option" + ["plugins:clockbar", "plugins:vpn"], + "plugins BAR collection filters and sorts A to Z" ); eq( Store.collection(plugins, { view: "discover", categoryID: "plugins" }).map(item => item.id), - ["vpn", "clockbar", "widget", "popout", "bare"], - "plugins ALL collection keeps every plugin in source order" + ["bare", "clockbar", "popout", "vpn", "widget"], + "plugins ALL collection sorts A to Z" ); const omarchyPlugins = [ @@ -213,6 +226,36 @@ eq( ); eq(Store.filter(omarchyPlugins, { query: "widgets bar mit" }).map(item => item.id), ["clock", "weather"], "Omarchy metadata participates in search"); +const apps = [ + { id: "graphics", category: "flatpak", name: "Graphics", art: "a.png", tags: ["graphics", "photography"], listedAt: "2026-01-02T00:00:00Z", metadata: { trending: 3, installsLastMonth: 900, updatedAt: "2026-01-05T00:00:00Z" } }, + { id: "browser", category: "flatpak", name: "Browser", art: "b.png", tags: ["network"], listedAt: "2026-01-05T00:00:00Z", metadata: { trending: 9, installsLastMonth: 100, updatedAt: "2026-01-03T00:00:00Z" } }, + { id: "player", category: "flatpak", name: "Player", art: "c.png", tags: ["audiovideo", "player"], listedAt: "2026-01-03T00:00:00Z", metadata: { trending: 6, installsLastMonth: 500, updatedAt: "2026-01-04T00:00:00Z" } }, + { id: "bare", category: "flatpak", name: "Bare" } +]; +eq(Store.collection(apps, { categoryID: "flatpak", appSort: "trending" }).map(i => i.id), ["browser", "player", "graphics", "bare"], "Apps Trending sorts by the Flathub trending score"); +eq(Store.collection(apps, { categoryID: "flatpak", appSort: "popular" }).map(i => i.id), ["graphics", "player", "browser", "bare"], "Apps Popular sorts by monthly installs"); +eq(Store.collection(apps, { categoryID: "flatpak", appSort: "new" }).map(i => i.id), ["browser", "player", "graphics", "bare"], "Apps New sorts by listing date"); +eq(Store.collection(apps, { categoryID: "flatpak", appSort: "updated" }).map(i => i.id), ["graphics", "player", "browser", "bare"], "Apps Updated sorts by update date"); +eq(Store.collection(apps, { categoryID: "flatpak", appSort: "a-z" }).map(i => i.id), ["bare", "browser", "graphics", "player"], "Apps A to Z sorts by display name"); +eq(Store.collection(apps, { categoryID: "flatpak", query: "a" }).map(i => i.id), ["graphics", "browser", "player", "bare"], "a search keeps Flathub relevance order"); +eq(Store.appCategories(apps).map(c => c.key), ["audiovideo", "graphics", "network"], "market tabs list the categories the catalogue carries"); +eq(Store.appCategories(apps)[1].note, "1", "a tab carries its app count"); +eq(Store.collection(apps, { categoryID: "flatpak", appSort: "trending", tag: "network" }).map(i => i.id), ["browser"], "a tab narrows the market to its tag"); +eq(Store.collection(apps, { categoryID: "flatpak", appSort: "popular", tag: "audiovideo", query: "play" }).map(i => i.id), ["player"], "a tab and a search combine"); +eq(Store.featuredApps(apps, 2, 0).map(i => i.id), ["browser", "player"], "featured apps take the top trending entries"); +eq(Store.featuredApps(apps, 2, 1).map(i => i.id), ["player", "graphics"], "the daily seed rotates the featured set"); +eq(Store.featuredApps(apps.concat([{ id: "owned", category: "flatpak", name: "Owned", art: "o.png", installed: true, metadata: { trending: 99 } }]), 2, 0).map(i => i.id), ["browser", "player"], "installed apps are not featured"); +const appsSnapshot = JSON.stringify(apps); +Store.sortApps(apps, "popular"); +Store.featuredApps(apps, 3, 4); +eq(JSON.stringify(apps), appsSnapshot, "app sorting and featured picks do not mutate the source"); + +eq(Store.statusTag({ id: "x", installed: true }), "INSTALLED", "a compact tile chips installed state"); +eq(Store.statusTag({ id: "x" }), "", "an available app carries no chip"); +eq(Store.initials({ name: "Google Chrome" }), "GC", "initials take the outer words of a name"); +eq(Store.initials({ name: "VLC", initials: "vlc" }), "VLC", "an explicit monogram wins"); +eq(Store.initials({ name: "" }), "", "a nameless item has no monogram"); + console.log("RYOSTORE-STORE-HELPERS-PASS"); // Classification affects discovery only; receipts, library and actions keep @@ -228,10 +271,10 @@ const shellProducts = [ { id: "launcher", category: "launchers", installed: true } ]; eq(Store.collection(shellProducts, { categoryID: "subshells" }).map(i => i.id), - ["python", "gokko", "tansu", "caelestia"], "subshell browse tab"); + ["caelestia", "gokko", "python", "tansu"], "subshell browse tab sorts A to Z"); eq(Store.collection(shellProducts, { categoryID: "barstyles" }).map(i => i.id), - ["python", "gokko", "tansu", "caelestia", "obi", "chroma", "nacre"], - "barstyle browse tab includes subshells"); + ["caelestia", "chroma", "gokko", "nacre", "obi", "python", "tansu"], + "barstyle browse tab includes subshells and sorts A to Z"); eq(Store.collection(shellProducts, { view: "library" }).map(i => i.id), ["python", "tansu", "obi", "chroma", "launcher"], "library unchanged"); eq(Store.barCategories([{ id: "barstyles", name: "Bar styles", count: 7, group: "wear" }], shellProducts) diff --git a/ryoku/cli/CHANGELOG.md b/ryoku/cli/CHANGELOG.md index 3f2d59bc68..f82cd48eed 100644 --- a/ryoku/cli/CHANGELOG.md +++ b/ryoku/cli/CHANGELOG.md @@ -1,6 +1,28 @@ # Ryoku CLI Changelog ## Unreleased +- **Doctor repairs a missing Zsh startup file without replacing custom config.** + It seeds `.zshrc` only when Zsh is the account login shell, the packaged Ryoku + template exists and the user has no startup file of their own. +- **`ryoku update` no longer installs the stock kernel on a converted CachyOS + box.** A box converted in place by `ryoku-shell-install` never got the + provisioned-ledger entries the ISO writes for `cachyos.replaces`, so the + manifest reconciler treated `linux` and `yay-bin` as never delivered and + installed them next to `linux-cachyos` and CachyOS's `yay`. The manifest now + carries `cachyosReplaces`, and a CachyOS host reads the base, dev, hardware + and AUR lanes without those names (`internal/ryokumanifest/manifest.go`, + `internal/updater/manifest.go`, `cmd/ryoku-manifest`). +- **Doctor no longer mistakes Settings helpers for memory-heavy user apps.** It + recognizes the Hub's wrappers and ignores its own process tree when System + Check runs inside the desktop service (`internal/doctor/memory.go`). +- **`mpv` and `mpv-mpris` are delivered once instead of forced back.** Doctor + installs both on boxes that have never received them, records the delivery, + and honours a later removal (`internal/ryokumanifest/manifest.go`, + `internal/doctor/reconcile_shipped_apps.go`). +- **Doctor installs headers for the kernels that are actually installed.** + On Arch and CachyOS it reads each installed kernel's `pkgbase`, checks the + matching `-headers` package, and installs only missing matches that + pacman's enabled repositories provide (`internal/doctor/reconcile_kernel_headers.go`). - **Phone installs use headers for the installed kernels, not driver modules.** Pacman reads each kernel's pkgbase and no longer adds stock headers on systems without the stock kernel (`internal/host/phone.go`, `internal/host/phone_test.go`). @@ -13,6 +35,11 @@ report lists RAM, swap, zram, OOM activity and the shell cgroup's largest processes, and warns only about memory kills or user apps left in that cgroup (`internal/doctor/memory.go`, `internal/doctor/report.go`). +- **Doctor names the installed Hermes agent version without claiming it is + current.** Hermes is installed once from its official installer, then remains + user-owned, so the new note points users back to that install's update method + instead of fetching release state during a health check + (`internal/doctor/reconcile_hermes_agent.go`). - **`ryoku-host users` manages local accounts on every edition.** The typed JSON API lists human users, roles, curated permissions, sessions and Btrfs home usage; privileged verbs add, remove and edit accounts without putting diff --git a/ryoku/cli/cmd/ryoku-manifest/main.go b/ryoku/cli/cmd/ryoku-manifest/main.go index 483e566212..466deb6b93 100644 --- a/ryoku/cli/cmd/ryoku-manifest/main.go +++ b/ryoku/cli/cmd/ryoku-manifest/main.go @@ -97,6 +97,9 @@ func readInputs(repo, release, version, commit, channel string) (ryokumanifest.I if in.AUR, err = read("system/packages/aur.packages"); err != nil { return in, fmt.Errorf("aur.packages: %w", err) } + if in.CachyOSReplaces, err = read("system/packages/cachyos.replaces"); err != nil { + return in, fmt.Errorf("cachyos.replaces: %w", err) + } if in.FirstParty, err = readDirBodies(filepath.Join(repo, "release/packages"), "PKGBUILD"); err != nil { return in, fmt.Errorf("release/packages: %w", err) } diff --git a/ryoku/cli/internal/doctor/doctor.go b/ryoku/cli/internal/doctor/doctor.go index 34965eed4d..449f7c8403 100644 --- a/ryoku/cli/internal/doctor/doctor.go +++ b/ryoku/cli/internal/doctor/doctor.go @@ -301,6 +301,7 @@ func reconcilers() []reconciler { {i18n.T("shell config schema"), reconcileShellConfig}, {i18n.T("Nomarchy command activation"), reconcileNomarchyCommands}, {i18n.T("login shell source"), reconcileLoginShell}, + {i18n.T("zsh login config"), reconcileZshrc}, {i18n.T("shell style knobs"), reconcileLegacyStyleKnobs}, {i18n.T("sumi bar simplification"), reconcileSumiBar}, {i18n.T("dock config store"), reconcileDockStore}, @@ -322,6 +323,7 @@ func reconcilers() []reconciler { {i18n.T("retired Shima frame music key"), reconcileShimaFrameMusic}, {i18n.T("retired Shima battery warning thresholds"), reconcileIrisBatteryThresholds}, {i18n.T("shipped app packages"), reconcileShippedApps}, + {i18n.T("kernel header packages"), reconcileKernelHeaders}, {i18n.T("retired app packages"), reconcileRetiredApps}, {i18n.T("retired compositor"), reconcileRetiredCompositor}, {i18n.T("release control manifest"), reconcileManifest}, @@ -361,6 +363,7 @@ func reconcilers() []reconciler { {i18n.T("ryoku shell daemon"), reconcileShellDaemon}, {i18n.T("duplicate desktop instances"), reconcileShellInstances}, {i18n.T("rashin agent daemon"), reconcileRashinDaemon}, + {i18n.T("hermes agent"), reconcileHermesAgent}, {i18n.T("AI usage collector timer"), reconcileAiUsageTimer}, {i18n.T("prowl for rashin"), reconcileProwl}, {i18n.T("Prowl gateway for rashin"), reconcileProwlGateway}, diff --git a/ryoku/cli/internal/doctor/memory.go b/ryoku/cli/internal/doctor/memory.go index 463b0fc24a..094a8228f5 100644 --- a/ryoku/cli/internal/doctor/memory.go +++ b/ryoku/cli/internal/doctor/memory.go @@ -39,8 +39,9 @@ type swapDevice struct { } type memoryProcess struct { - name string - rssKiB uint64 + pid, ppid int + name string + rssKiB uint64 } type memoryDiagnostics struct { @@ -122,6 +123,10 @@ func parseProcessStatus(raw string) (memoryProcess, bool) { switch key { case "Name": process.name = strings.TrimSpace(value) + case "Pid": + process.pid, _ = strconv.Atoi(strings.TrimSpace(value)) + case "PPid": + process.ppid, _ = strconv.Atoi(strings.TrimSpace(value)) case "VmRSS": fields := strings.Fields(value) if len(fields) > 0 { @@ -139,7 +144,8 @@ func cgroupProcesses(cgroupDir, procRoot string) []memoryProcess { } var processes []memoryProcess for _, pid := range strings.Fields(string(raw)) { - if _, err := strconv.ParseUint(pid, 10, 64); err != nil { + pidValue, err := strconv.Atoi(pid) + if err != nil { continue } status, err := os.ReadFile(filepath.Join(procRoot, pid, "status")) @@ -147,6 +153,7 @@ func cgroupProcesses(cgroupDir, procRoot string) []memoryProcess { continue } if process, ok := parseProcessStatus(string(status)); ok { + process.pid = pidValue processes = append(processes, process) } } @@ -320,15 +327,40 @@ func memoryReportLines(diagnostic memoryDiagnostics) []string { func looksLikeUserApp(name string) bool { name = strings.ToLower(name) - return name != "qs" && name != "adb" && name != "cliphist" && - !strings.HasPrefix(name, "ryoku-") && !strings.HasPrefix(name, "ryogami") && + switch name { + case "qs", "adb", "cliphist", "flock", "bash", "sh", "dbus-monitor", + "ryoku", "lspci", "grep": + return false + } + return !strings.HasPrefix(name, "ryoku-") && !strings.HasPrefix(name, "ryogami") && !strings.HasPrefix(name, "wl-") && !strings.HasPrefix(name, "python3") } -func userAppsInShell(processes []memoryProcess) []memoryProcess { +func inProcessTree(process memoryProcess, rootPID int, processes map[int]memoryProcess) bool { + if rootPID <= 0 || process.pid <= 0 { + return false + } + for range len(processes) + 1 { + if process.pid == rootPID || process.ppid == rootPID { + return true + } + parent, ok := processes[process.ppid] + if !ok { + return false + } + process = parent + } + return false +} + +func userAppsInShell(processes []memoryProcess, doctorPID int) []memoryProcess { + byPID := make(map[int]memoryProcess, len(processes)) + for _, process := range processes { + byPID[process.pid] = process + } apps := make([]memoryProcess, 0, len(processes)) for _, process := range processes { - if looksLikeUserApp(process.name) { + if looksLikeUserApp(process.name) && !inProcessTree(process, doctorPID, byPID) { apps = append(apps, process) } } @@ -344,7 +376,7 @@ func reconcileMemoryHealth(_ bool) recResult { } else if diagnostic.cgroupOOMKills > 0 { problems = append(problems, fmt.Sprintf(i18n.T("the shell service recorded %d memory kill(s)"), diagnostic.cgroupOOMKills)) } - if apps := userAppsInShell(diagnostic.processes); len(apps) > 0 { + if apps := userAppsInShell(diagnostic.processes, os.Getpid()); len(apps) > 0 { problems = append(problems, fmt.Sprintf(i18n.T("user apps are still inside the shell service: %s"), memoryProcessList(apps, 5))) } if len(problems) > 0 { diff --git a/ryoku/cli/internal/doctor/memory_test.go b/ryoku/cli/internal/doctor/memory_test.go index 3938e0afa6..a36b7b2d8a 100644 --- a/ryoku/cli/internal/doctor/memory_test.go +++ b/ryoku/cli/internal/doctor/memory_test.go @@ -63,15 +63,15 @@ func TestCgroupProcessesFixture(t *testing.T) { root := memoryFixture(t, "") processes := cgroupProcesses(filepath.Join(root, "cgroup"), filepath.Join(root, "proc")) want := []memoryProcess{ - {name: "qs", rssKiB: 921600}, - {name: "electron", rssKiB: 524288}, - {name: "python3", rssKiB: 65536}, - {name: "ryoku-shell", rssKiB: 16384}, + {pid: 101, name: "qs", rssKiB: 921600}, + {pid: 202, name: "electron", rssKiB: 524288}, + {pid: 303, name: "python3", rssKiB: 65536}, + {pid: 404, name: "ryoku-shell", rssKiB: 16384}, } if !reflect.DeepEqual(processes, want) { t.Fatalf("processes = %#v, want %#v", processes, want) } - apps := userAppsInShell(processes) + apps := userAppsInShell(processes, 0) if len(apps) != 1 || apps[0].name != "electron" { t.Fatalf("user apps = %#v, want electron only", apps) } @@ -81,15 +81,67 @@ func TestCgroupProcessesFixture(t *testing.T) { } func TestLooksLikeUserApp(t *testing.T) { - for _, name := range []string{"qs", "ryoku-shell", "ryogami", "ryogami-live", "wl-clip-persist", "python3", "python3.13", "adb", "cliphist"} { - if looksLikeUserApp(name) { - t.Errorf("%s should be treated as a shell helper", name) - } + tests := []struct { + name string + userApp bool + }{ + {name: "qs"}, + {name: "ryoku-shell"}, + {name: "ryogami"}, + {name: "ryogami-live"}, + {name: "wl-clip-persist"}, + {name: "python3"}, + {name: "python3.13"}, + {name: "adb"}, + {name: "cliphist"}, + {name: "flock"}, + {name: "bash"}, + {name: "sh"}, + {name: "dbus-monitor"}, + {name: "ryoku"}, + {name: "lspci"}, + {name: "grep"}, + {name: "electron", userApp: true}, + {name: "firefox", userApp: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := looksLikeUserApp(test.name); got != test.userApp { + t.Errorf("looksLikeUserApp(%q) = %t, want %t", test.name, got, test.userApp) + } + }) } - for _, name := range []string{"electron", "firefox", "dbus-monitor"} { - if !looksLikeUserApp(name) { - t.Errorf("%s should be treated as a user app", name) - } +} + +func TestUserAppsInShellExcludesDoctorProcessTree(t *testing.T) { + processes := []memoryProcess{ + {pid: 100, ppid: 10, name: "ryoku"}, + {pid: 200, ppid: 100, name: "electron"}, + {pid: 300, ppid: 200, name: "kitty"}, + {pid: 400, ppid: 10, name: "firefox"}, + } + tests := []struct { + name string + doctorPID int + want []memoryProcess + }{ + { + name: "doctor outside cgroup", + doctorPID: 999, + want: processes[1:], + }, + { + name: "doctor inside cgroup", + doctorPID: 100, + want: processes[3:], + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := userAppsInShell(processes, test.doctorPID); !reflect.DeepEqual(got, test.want) { + t.Fatalf("user apps = %#v, want %#v", got, test.want) + } + }) } } diff --git a/ryoku/cli/internal/doctor/reconcile_hermes_agent.go b/ryoku/cli/internal/doctor/reconcile_hermes_agent.go new file mode 100644 index 0000000000..5ee292a57a --- /dev/null +++ b/ryoku/cli/internal/doctor/reconcile_hermes_agent.go @@ -0,0 +1,79 @@ +package doctor + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "ryoku-cli/internal/sys" + + i18n "ryoku-i18n" +) + +const hermesAgentInstallDir = ".hermes/hermes-agent" + +func reconcileHermesAgent(_ bool) recResult { + version, installed := installedHermesAgentVersion() + if !installed { + return okRes(i18n.T("Hermes agent not installed")) + } + + result := noteRes(i18n.T("Hermes agent is installed; its version could not be read")) + if version != "" { + result = noteRes(i18n.T("Hermes agent %s is installed; Ryoku setup leaves existing installs untouched"), version) + } + return result.withFix(i18n.T("update Hermes through its own installer or checkout, then run `ryoku-rashin setup` to refresh its Ryoku wiring")) +} + +func installedHermesAgentVersion() (string, bool) { + if bin := findHermesAgentBinary(); bin != "" { + return probeHermesAgentVersion(bin), true + } + info, err := os.Stat(filepath.Join(sys.Home(), hermesAgentInstallDir)) + return "", err == nil && info.IsDir() +} + +func findHermesAgentBinary() string { + if bin, err := exec.LookPath("hermes"); err == nil { + return bin + } + for _, bin := range []string{ + filepath.Join(sys.Home(), ".hermes", "bin", "hermes"), + filepath.Join(sys.Home(), ".local", "bin", "hermes"), + } { + if info, err := os.Stat(bin); err == nil && !info.IsDir() { + return bin + } + } + return "" +} + +func probeHermesAgentVersion(bin string) string { + out := runBoundedProbe(exec.Command(bin, "--version"), 2*time.Second, func(out string) bool { + return strings.Contains(out, "\n") + }) + line, _, _ := strings.Cut(strings.TrimSpace(out), "\n") + for _, field := range strings.Fields(line) { + if version := normalizeHermesAgentVersion(strings.Trim(field, "(),;[]")); version != "" { + return version + } + } + return "" +} + +func normalizeHermesAgentVersion(value string) string { + version := strings.TrimPrefix(strings.TrimSpace(value), "v") + if version == "" || version[0] < '0' || version[0] > '9' || !strings.Contains(version, ".") { + return "" + } + for _, r := range version { + if (r >= '0' && r <= '9') || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || + r == '.' || r == '-' || r == '+' { + continue + } + return "" + } + return version +} diff --git a/ryoku/cli/internal/doctor/reconcile_hermes_agent_test.go b/ryoku/cli/internal/doctor/reconcile_hermes_agent_test.go new file mode 100644 index 0000000000..94c04bcece --- /dev/null +++ b/ryoku/cli/internal/doctor/reconcile_hermes_agent_test.go @@ -0,0 +1,77 @@ +package doctor + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func hermesAgentTestEnv(t *testing.T) string { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("PATH", filepath.Join(t.TempDir(), "bin")) + return home +} + +func TestInstalledHermesAgentVersionReadsInstalledBinary(t *testing.T) { + home := hermesAgentTestEnv(t) + if err := os.MkdirAll(filepath.Join(home, ".hermes", "hermes-agent"), 0o755); err != nil { + t.Fatal(err) + } + binDir := filepath.Join(home, ".hermes", "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(binDir, "hermes"), []byte("#!/bin/sh\necho 'Hermes Agent v0.21.3 (2026.9.1)'\n"), 0o755); err != nil { + t.Fatal(err) + } + + version, installed := installedHermesAgentVersion() + if !installed || version != "0.21.3" { + t.Fatalf("installedHermesAgentVersion = %q, %v", version, installed) + } + + result := reconcileHermesAgent(true) + if result.status != recNote || !strings.Contains(result.detail, "0.21.3") { + t.Fatalf("reconcileHermesAgent = %+v", result) + } + if !strings.Contains(result.remedy, "own installer or checkout") { + t.Fatalf("Hermes note has no owner-safe update advice: %+v", result) + } +} + +func TestInstalledHermesAgentVersionUsesPathBinary(t *testing.T) { + hermesAgentTestEnv(t) + binDir := t.TempDir() + t.Setenv("PATH", binDir) + bin := filepath.Join(binDir, "hermes") + if err := os.WriteFile(bin, []byte("#!/bin/sh\necho 'Hermes Agent v0.22.5 (2026.10.1)'\n"), 0o755); err != nil { + t.Fatal(err) + } + + version, installed := installedHermesAgentVersion() + if !installed || version != "0.22.5" { + t.Fatalf("installedHermesAgentVersion = %q, %v", version, installed) + } +} + +func TestHermesAgentInstallWithoutBinaryReportsUnknownVersion(t *testing.T) { + home := hermesAgentTestEnv(t) + if err := os.MkdirAll(filepath.Join(home, ".hermes", "hermes-agent"), 0o755); err != nil { + t.Fatal(err) + } + result := reconcileHermesAgent(true) + if result.status != recNote || !strings.Contains(result.detail, "could not be read") { + t.Fatalf("reconcileHermesAgent = %+v", result) + } +} + +func TestHermesAgentFindingIsQuietWhenAbsent(t *testing.T) { + hermesAgentTestEnv(t) + result := reconcileHermesAgent(true) + if result.status != recOK { + t.Fatalf("reconcileHermesAgent = %+v", result) + } +} diff --git a/ryoku/cli/internal/doctor/reconcile_kernel_headers.go b/ryoku/cli/internal/doctor/reconcile_kernel_headers.go new file mode 100644 index 0000000000..687f8c08b9 --- /dev/null +++ b/ryoku/cli/internal/doctor/reconcile_kernel_headers.go @@ -0,0 +1,106 @@ +package doctor + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + + "ryoku-cli/internal/host" + + i18n "ryoku-i18n" +) + +var ( + kernelHeadersModulesDir = "/usr/lib/modules" + kernelHeadersReadDir = os.ReadDir + kernelHeadersReadFile = os.ReadFile + kernelHeadersPackageInstalled = func(name string) bool { return doctorPackageInstalled(name) } + kernelHeadersPackageAvailable = func(name string) bool { + return exec.Command("pacman", "-Si", name).Run() == nil + } +) + +func installedKernelHeaderPackages() ([]string, error) { + entries, err := kernelHeadersReadDir(kernelHeadersModulesDir) + if err != nil { + return nil, err + } + seen := map[string]bool{} + var headers []string + for _, entry := range entries { + data, err := kernelHeadersReadFile(filepath.Join(kernelHeadersModulesDir, entry.Name(), "pkgbase")) + if err != nil { + if os.IsNotExist(err) { + continue + } + return nil, fmt.Errorf("read kernel metadata for %s: %w", entry.Name(), err) + } + kernel := strings.TrimSpace(string(data)) + if !strings.HasPrefix(kernel, "linux") || strings.ContainsAny(kernel, " \t\r\n/") || + !kernelHeadersPackageInstalled(kernel) { + continue + } + header := kernel + "-headers" + if !seen[header] { + seen[header] = true + headers = append(headers, header) + } + } + sort.Strings(headers) + return headers, nil +} + +func reconcileKernelHeaders(checkOnly bool) recResult { + if result, ok := pacmanHost(i18n.T("kernel header packages")); !ok { + return result + } + headers, err := installedKernelHeaderPackages() + if err != nil { + return warnRes(i18n.T("could not inspect installed kernels: %v"), err) + } + if len(headers) == 0 { + return noteRes(i18n.T("no installed Arch kernel packages were found")) + } + missing := make([]string, 0, len(headers)) + unavailable := make([]string, 0, len(headers)) + for _, name := range headers { + if kernelHeadersPackageInstalled(name) { + continue + } + if !kernelHeadersPackageAvailable(name) { + unavailable = append(unavailable, name) + continue + } + missing = append(missing, name) + } + if len(missing) == 0 { + if len(unavailable) > 0 { + return noteRes(i18n.T("matching kernel headers are not available in enabled repositories, so skipped: %s"), + strings.Join(unavailable, ", ")) + } + return okRes(i18n.T("every installed kernel has matching headers")) + } + advice := doctorInstallAdvice(missing...) + if checkOnly { + result := wouldRes(i18n.T("matching kernel headers are missing: %s"), strings.Join(missing, ", ")).withFix("%s", advice) + return withUnavailableKernelHeaders(result, unavailable) + } + if doctorPackageInstall(missing...) != host.ExitOK { + result := warnRes(i18n.T("could not install matching kernel headers: %s"), strings.Join(missing, ", ")).withFix("%s", advice) + return withUnavailableKernelHeaders(result, unavailable) + } + return withUnavailableKernelHeaders( + fixedRes(i18n.T("installed matching kernel headers: %s"), strings.Join(missing, ", ")), + unavailable) +} + +func withUnavailableKernelHeaders(result recResult, unavailable []string) recResult { + if len(unavailable) > 0 { + result.detail += i18n.Tf("; not available in enabled repositories, so skipped: %s", + strings.Join(unavailable, ", ")) + } + return result +} diff --git a/ryoku/cli/internal/doctor/reconcile_kernel_headers_test.go b/ryoku/cli/internal/doctor/reconcile_kernel_headers_test.go new file mode 100644 index 0000000000..34caf3a0f9 --- /dev/null +++ b/ryoku/cli/internal/doctor/reconcile_kernel_headers_test.go @@ -0,0 +1,136 @@ +package doctor + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "ryoku-cli/internal/host" +) + +func kernelHeadersTree(t *testing.T, kernels map[string]string) string { + t.Helper() + root := t.TempDir() + for version, pkgbase := range kernels { + dir := filepath.Join(root, version) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if pkgbase != "" { + if err := os.WriteFile(filepath.Join(dir, "pkgbase"), []byte(pkgbase+"\n"), 0o644); err != nil { + t.Fatal(err) + } + } + } + return root +} + +func withKernelHeaderTestState(t *testing.T, kernels map[string]string, installed, available map[string]bool) { + t.Helper() + oldDir := kernelHeadersModulesDir + oldReadDir, oldReadFile := kernelHeadersReadDir, kernelHeadersReadFile + oldInstalled := kernelHeadersPackageInstalled + oldAvailable := kernelHeadersPackageAvailable + oldHasPacman := hasPacman + oldInstall, oldAdvice := doctorPackageInstall, doctorInstallAdvice + kernelHeadersModulesDir = kernelHeadersTree(t, kernels) + kernelHeadersReadDir, kernelHeadersReadFile = os.ReadDir, os.ReadFile + kernelHeadersPackageInstalled = func(name string) bool { return installed[name] } + kernelHeadersPackageAvailable = func(name string) bool { + return available == nil || available[name] + } + hasPacman = func() bool { return true } + doctorPackageInstall = func(...string) int { return host.ExitOK } + doctorInstallAdvice = func(names ...string) string { return "install " + strings.Join(names, " ") } + t.Cleanup(func() { + kernelHeadersModulesDir = oldDir + kernelHeadersReadDir, kernelHeadersReadFile = oldReadDir, oldReadFile + kernelHeadersPackageInstalled = oldInstalled + kernelHeadersPackageAvailable = oldAvailable + hasPacman = oldHasPacman + doctorPackageInstall, doctorInstallAdvice = oldInstall, oldAdvice + }) +} + +func TestInstalledKernelHeaderPackagesUsesInstalledPkgbases(t *testing.T) { + installed := map[string]bool{ + "linux": true, + "linux-cachyos-bore-lto": true, + } + withKernelHeaderTestState(t, map[string]string{ + "7.2.11-arch1-1": "linux", + "7.3.0-cachyos-bore-lto": "linux-cachyos-bore-lto", + "stale": "linux-zen", + "module-only": "", + "invalid": "linux bad", + }, installed, nil) + + got, err := installedKernelHeaderPackages() + if err != nil { + t.Fatal(err) + } + want := []string{"linux-cachyos-bore-lto-headers", "linux-headers"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("headers = %v, want %v", got, want) + } +} + +func TestReconcileKernelHeadersInstallsOnlyMissingMatches(t *testing.T) { + installed := map[string]bool{ + "linux": true, + "linux-headers": true, + "linux-cachyos-bore-lto": true, + "linux-cachyos-bore-lto-headers": false, + } + withKernelHeaderTestState(t, map[string]string{ + "7.2.11-arch1-1": "linux", + "7.3.0-cachyos-bore-lto": "linux-cachyos-bore-lto", + }, installed, nil) + var installedNow []string + doctorPackageInstall = func(names ...string) int { + installedNow = append(installedNow, names...) + return host.ExitOK + } + + got := reconcileKernelHeaders(false) + if got.status != recFixed { + t.Fatalf("result = %+v, want fixed", got) + } + want := []string{"linux-cachyos-bore-lto-headers"} + if !reflect.DeepEqual(installedNow, want) { + t.Fatalf("installed = %v, want %v", installedNow, want) + } +} + +func TestReconcileKernelHeadersCheckOnlyDoesNotInstall(t *testing.T) { + installed := map[string]bool{"linux-cachyos": true} + withKernelHeaderTestState(t, map[string]string{"7.3.0-cachyos": "linux-cachyos"}, installed, nil) + doctorPackageInstall = func(names ...string) int { + t.Fatalf("check-only installed %v", names) + return host.ExitOK + } + + got := reconcileKernelHeaders(true) + if got.status != recWouldFix || !strings.Contains(got.detail, "linux-cachyos-headers") || + got.remedy != "install linux-cachyos-headers" { + t.Fatalf("result = %+v", got) + } +} + +func TestReconcileKernelHeadersNotesUnavailableMatch(t *testing.T) { + installed := map[string]bool{"linux-custom": true} + available := map[string]bool{"linux-custom-headers": false} + withKernelHeaderTestState(t, map[string]string{"7.3.0-custom": "linux-custom"}, installed, available) + doctorPackageInstall = func(names ...string) int { + t.Fatalf("installed unavailable headers %v", names) + return host.ExitOK + } + + got := reconcileKernelHeaders(false) + if got.status != recNote || !strings.Contains(got.detail, "linux-custom-headers") || + !strings.Contains(got.detail, "not available") || got.remedy != "" { + t.Fatalf("result = %+v", got) + } +} diff --git a/ryoku/cli/internal/doctor/reconcile_shipped_apps.go b/ryoku/cli/internal/doctor/reconcile_shipped_apps.go index 105c5b6770..062b3cf8ca 100644 --- a/ryoku/cli/internal/doctor/reconcile_shipped_apps.go +++ b/ryoku/cli/internal/doctor/reconcile_shipped_apps.go @@ -14,11 +14,11 @@ import ( ) // shippedApp is one deliver-once package: installed once, then left alone if -// the user removes it. Membership rule: a standalone application whose absence -// costs only itself. Tools the shell calls by name (grim, playerctl, matugen, -// cava, mpv for the launcher's radio, the pill's OCR/capture backends) stay hard -// depends, because losing them breaks a Ryoku surface the user never touched. -// Ryotunes has its own official-release install/reconciliation path. +// the user removes it. Membership rule: a standalone application or optional +// surface whose absence can be explained where it is used. Tools required by +// an unguarded shell surface (grim, playerctl, matugen, cava, and the pill's +// OCR/capture backends) stay hard dependencies. Ryotunes has its own +// official-release install/reconciliation path. type shippedApp struct { pkg string what string diff --git a/ryoku/cli/internal/doctor/reconcile_zshrc.go b/ryoku/cli/internal/doctor/reconcile_zshrc.go new file mode 100644 index 0000000000..43e58899d6 --- /dev/null +++ b/ryoku/cli/internal/doctor/reconcile_zshrc.go @@ -0,0 +1,57 @@ +package doctor + +import ( + "errors" + "os" + "path/filepath" + + "ryoku-cli/internal/sys" + + i18n "ryoku-i18n" +) + +var zshrcTemplatePath = "/usr/share/ryoku/zsh/zshrc" + +func reconcileZshrc(checkOnly bool) recResult { + if filepath.Base(accountLoginShell()) != "zsh" { + return okRes(i18n.T("the account login shell does not need a .zshrc")) + } + + path := filepath.Join(sys.Home(), ".zshrc") + if _, err := os.Lstat(path); err == nil { + return okRes(i18n.T("the zsh login config already exists")) + } else if !errors.Is(err, os.ErrNotExist) { + return failRes(i18n.T("could not inspect the zsh login config: %v"), err) + } + + template, err := os.ReadFile(zshrcTemplatePath) + if errors.Is(err, os.ErrNotExist) { + return warnRes(i18n.T("the packaged zsh login template is missing")). + withFix(i18n.T("ryoku update restores the template, then ryoku doctor seeds ~/.zshrc")) + } + if err != nil { + return failRes(i18n.T("could not read the packaged zsh login template: %v"), err) + } + if checkOnly { + return wouldRes(i18n.T("the zsh account has no .zshrc")). + withFix(i18n.T("ryoku doctor seeds it from the packaged Ryoku template")) + } + + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if errors.Is(err, os.ErrExist) { + return okRes(i18n.T("the zsh login config already exists")) + } + if err != nil { + return failRes(i18n.T("could not create the zsh login config: %v"), err) + } + if _, err = file.Write(template); err != nil { + _ = file.Close() + _ = os.Remove(path) + return failRes(i18n.T("could not seed the zsh login config: %v"), err) + } + if err = file.Close(); err != nil { + _ = os.Remove(path) + return failRes(i18n.T("could not finish the zsh login config: %v"), err) + } + return fixedRes(i18n.T("seeded the missing zsh login config from the packaged Ryoku template")) +} diff --git a/ryoku/cli/internal/doctor/reconcile_zshrc_test.go b/ryoku/cli/internal/doctor/reconcile_zshrc_test.go new file mode 100644 index 0000000000..c84cc83e58 --- /dev/null +++ b/ryoku/cli/internal/doctor/reconcile_zshrc_test.go @@ -0,0 +1,92 @@ +package doctor + +import ( + "os" + "path/filepath" + "testing" +) + +func zshrcFixture(t *testing.T, shell string) (home, template string) { + t.Helper() + home = t.TempDir() + t.Setenv("HOME", home) + stubAccountShell(t, shell) + template = filepath.Join(t.TempDir(), "zshrc") + oldTemplate := zshrcTemplatePath + zshrcTemplatePath = template + t.Cleanup(func() { zshrcTemplatePath = oldTemplate }) + return home, template +} + +func TestReconcileZshrcSeedsMissingFile(t *testing.T) { + home, template := zshrcFixture(t, "/usr/bin/zsh") + want := "[ -r \"$HOME/.config/zsh/ryoku.zsh\" ] && . \"$HOME/.config/zsh/ryoku.zsh\"\n" + if err := os.WriteFile(template, []byte(want), 0o644); err != nil { + t.Fatal(err) + } + path := filepath.Join(home, ".zshrc") + + if got := reconcileZshrc(true); got.status != recWouldFix { + t.Fatalf("check-only status = %s, want todo", got.status.label()) + } + if _, err := os.Lstat(path); !os.IsNotExist(err) { + t.Fatalf("check-only created .zshrc: %v", err) + } + if got := reconcileZshrc(false); got.status != recFixed { + t.Fatalf("repair status = %s, detail = %q", got.status.label(), got.detail) + } + body, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(body) != want { + t.Fatalf(".zshrc = %q, want %q", body, want) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o644 { + t.Fatalf(".zshrc mode = %o, want 644", info.Mode().Perm()) + } + if got := reconcileZshrc(false); got.status != recOK { + t.Fatalf("second repair status = %s, want ok", got.status.label()) + } +} + +func TestReconcileZshrcLeavesExistingFileAlone(t *testing.T) { + home, template := zshrcFixture(t, "/usr/bin/zsh") + if err := os.WriteFile(template, []byte("shipped\n"), 0o644); err != nil { + t.Fatal(err) + } + path := filepath.Join(home, ".zshrc") + if err := os.WriteFile(path, []byte("custom\n"), 0o600); err != nil { + t.Fatal(err) + } + + if got := reconcileZshrc(false); got.status != recOK { + t.Fatalf("status = %s, want ok", got.status.label()) + } + body, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(body) != "custom\n" { + t.Fatalf("existing .zshrc was overwritten: %q", body) + } +} + +func TestReconcileZshrcSkipsOtherShellsAndMissingTemplate(t *testing.T) { + home, _ := zshrcFixture(t, "/usr/bin/fish") + if got := reconcileZshrc(false); got.status != recOK { + t.Fatalf("fish status = %s, want ok", got.status.label()) + } + if _, err := os.Lstat(filepath.Join(home, ".zshrc")); !os.IsNotExist(err) { + t.Fatalf("fish account got .zshrc: %v", err) + } + + stubAccountShell(t, "/usr/bin/zsh") + if got := reconcileZshrc(false); got.status != recWarn { + t.Fatalf("missing-template status = %s, want warning", got.status.label()) + } +} diff --git a/ryoku/cli/internal/ryokumanifest/generate.go b/ryoku/cli/internal/ryokumanifest/generate.go index 7cb090c32a..c94a0ecce8 100644 --- a/ryoku/cli/internal/ryokumanifest/generate.go +++ b/ryoku/cli/internal/ryokumanifest/generate.go @@ -118,6 +118,8 @@ type Inputs struct { Dev string // system/packages/dev.packages Hardware string // system/packages/hardware.packages AUR string // system/packages/aur.packages + // CachyOSReplaces is system/packages/cachyos.replaces. + CachyOSReplaces string // Choices names packages selected per box by the installer. Choices map[string]bool // FirstParty maps a release/packages/ name to its PKGBUILD contents. @@ -152,6 +154,8 @@ func Build(in Inputs) Manifest { AUR: withoutPackages(PackageSet(in.AUR), in.Choices), Compositor: compositorPackages(in.Compositor), Provisioned: AppNames(), + + CachyOSReplaces: PackageSet(in.CachyOSReplaces), } declared := map[string]bool{} for _, pkgs := range m.Compositor { diff --git a/ryoku/cli/internal/ryokumanifest/manifest.go b/ryoku/cli/internal/ryokumanifest/manifest.go index 580cb66b1d..8cbbe7cfc5 100644 --- a/ryoku/cli/internal/ryokumanifest/manifest.go +++ b/ryoku/cli/internal/ryokumanifest/manifest.go @@ -32,6 +32,44 @@ type Manifest struct { FirstParty []string `json:"firstParty"` // release/packages/*, pinned to Version Compositor map[string][]string `json:"compositor,omitempty"` // provider -> its packages, from its caps Provisioned []string `json:"provisioned"` // apps doctor delivers once and never re-adds + // CachyOSReplaces names what the CachyOS layer replaces + // (system/packages/cachyos.replaces). Older manifests omit it. + CachyOSReplaces []string `json:"cachyosReplaces,omitempty"` +} + +// ForCachyOS is the manifest a CachyOS box converges to: the base, dev, +// hardware and AUR lanes without the names its own layer replaces (the stock +// kernel, the AUR yay-bin). The ISO keeps them off a CachyOS install through +// the provisioned ledger, but a box converted in place has no such entries, so +// without this `ryoku update` delivers them as "never delivered" names. +func (m Manifest) ForCachyOS() Manifest { + if len(m.CachyOSReplaces) == 0 { + return m + } + drop := map[string]bool{} + for _, n := range m.CachyOSReplaces { + drop[n] = true + } + keep := func(names []string) []string { + var out []string + for _, n := range names { + if !drop[n] { + out = append(out, n) + } + } + return out + } + m.Base = keep(m.Base) + m.Dev = keep(m.Dev) + m.AUR = keep(m.AUR) + if m.Hardware != nil { + hardware := make(map[string][]string, len(m.Hardware)) + for section, names := range m.Hardware { + hardware[section] = keep(names) + } + m.Hardware = hardware + } + return m } // Names returns the manifest's full package-name set: every lane's names plus @@ -160,11 +198,11 @@ type App struct { What string `json:"what"` } -// Membership rule: a standalone application whose absence costs only itself. -// Tools the shell calls by name (grim, playerctl, matugen, cava, mpv for the -// launcher's radio, the pill's OCR/capture backends) stay hard depends, because -// losing them breaks a Ryoku surface the user never touched. Ryotunes has its -// own official-release install/reconciliation path. +// Membership rule: a standalone application or optional surface whose absence +// can be explained where it is used. Tools required by an unguarded shell +// surface (grim, playerctl, matugen, cava, and the pill's OCR/capture backends) +// stay hard dependencies. Ryotunes has its own official-release +// install/reconciliation path. func Apps() []App { return []App{ {"kitty", "the default terminal (Settings > App Overrides repoints the terminal role)"}, @@ -177,6 +215,8 @@ func Apps() []App { {"nautilus", "the graphical file manager"}, {"nautilus-python", "the Ryoku stash actions in Nautilus' right-click menu"}, {"waifu2x-ncnn-vulkan", "AI upscale behind ryoshot Beautify HD and ryowalls Enhance"}, + {"mpv", "playback for the launcher's radio"}, + {"mpv-mpris", "media controls for the launcher's radio"}, {"ryoku-rashin", "Rashin, the AI agent: Ask, the Rashin window and the bar's AI usage pill"}, {"ryomanager", "system monitor and task manager"}, {"pavucontrol", "the GUI mixer the bar's Open audio button launches"}, diff --git a/ryoku/cli/internal/ryokumanifest/manifest_test.go b/ryoku/cli/internal/ryokumanifest/manifest_test.go new file mode 100644 index 0000000000..ff6852bff4 --- /dev/null +++ b/ryoku/cli/internal/ryokumanifest/manifest_test.go @@ -0,0 +1,62 @@ +package ryokumanifest + +import ( + "reflect" + "testing" +) + +func TestBuildCarriesCachyOSReplaces(t *testing.T) { + m := Build(Inputs{ + Base: "base\nlinux\nlinux-headers\n", + AUR: "yay-bin\n", + CachyOSReplaces: "# the stock kernel\nlinux\n\nyay-bin\n", + }) + if !reflect.DeepEqual(m.CachyOSReplaces, []string{"linux", "yay-bin"}) { + t.Fatalf("CachyOSReplaces = %v, want [linux yay-bin]", m.CachyOSReplaces) + } + // The lanes themselves stay whole: an Arch box still needs the stock kernel. + if !reflect.DeepEqual(m.Base, []string{"base", "linux", "linux-headers"}) { + t.Errorf("Base = %v, want the full base set", m.Base) + } +} + +func TestForCachyOSDropsReplacedNamesFromEveryLane(t *testing.T) { + m := Manifest{ + Base: []string{"base", "linux", "linux-headers"}, + Dev: []string{"go", "linux"}, + AUR: []string{"paru-bin", "yay-bin"}, + Hardware: map[string][]string{"intel": {"linux", "thermald"}}, + FirstParty: []string{"ryoku-desktop"}, + Provisioned: []string{"kitty"}, + CachyOSReplaces: []string{"linux", "yay-bin"}, + } + got := m.ForCachyOS() + if !reflect.DeepEqual(got.Base, []string{"base", "linux-headers"}) { + t.Errorf("Base = %v, want [base linux-headers]", got.Base) + } + if !reflect.DeepEqual(got.Dev, []string{"go"}) { + t.Errorf("Dev = %v, want [go]", got.Dev) + } + if !reflect.DeepEqual(got.AUR, []string{"paru-bin"}) { + t.Errorf("AUR = %v, want [paru-bin]", got.AUR) + } + if !reflect.DeepEqual(got.Hardware["intel"], []string{"thermald"}) { + t.Errorf("Hardware[intel] = %v, want [thermald]", got.Hardware["intel"]) + } + if !reflect.DeepEqual(got.FirstParty, m.FirstParty) || !reflect.DeepEqual(got.Provisioned, m.Provisioned) { + t.Errorf("first-party or provisioned lanes changed: %+v", got) + } + // The served manifest is cached and saved as the baseline; filtering must + // not write through to it. + if !reflect.DeepEqual(m.Base, []string{"base", "linux", "linux-headers"}) || + !reflect.DeepEqual(m.Hardware["intel"], []string{"linux", "thermald"}) { + t.Errorf("ForCachyOS modified its receiver: %+v", m) + } +} + +func TestForCachyOSKeepsAnOlderManifestUnchanged(t *testing.T) { + m := Manifest{Base: []string{"base", "linux"}} + if got := m.ForCachyOS(); !reflect.DeepEqual(got, m) { + t.Errorf("a manifest without cachyosReplaces changed: %+v", got) + } +} diff --git a/ryoku/cli/internal/updater/console_test.go b/ryoku/cli/internal/updater/console_test.go index c39def1d04..af60e340ab 100644 --- a/ryoku/cli/internal/updater/console_test.go +++ b/ryoku/cli/internal/updater/console_test.go @@ -113,18 +113,26 @@ func TestVerdictThresholds(t *testing.T) { } // The watchdog names the work a child is doing, not the wrapper around it, and -// flags a package manager in the tree. +// flags a package manager in the tree. The fake package manager is a shell +// under the name pacman (comm follows the exec'd path's basename) blocked on a +// read from a pipe the test holds open, so it is alive with no child of its +// own. A renamed sleep would not do: on uutils coreutils (Ubuntu 26.04 +// runners) sleep is a multicall binary that dispatches on argv[0]. func TestWatchdogNamesTheWorkInItsTree(t *testing.T) { dir := t.TempDir() pacman := filepath.Join(dir, "pacman") - if err := os.Symlink("/usr/bin/sleep", pacman); err != nil { + if err := os.Symlink("/bin/sh", pacman); err != nil { t.Skip(err) } - cmd := exec.Command("sh", "-c", pacman+" 5") + cmd := exec.Command("sh", "-c", pacman+" -c 'read _' 5") + stdin, err := cmd.StdinPipe() + if err != nil { + t.Fatal(err) + } if err := cmd.Start(); err != nil { t.Skip(err) } - t.Cleanup(func() { _ = cmd.Process.Kill(); _ = cmd.Wait() }) + t.Cleanup(func() { _ = stdin.Close(); _ = cmd.Process.Kill(); _ = cmd.Wait() }) deadline := time.Now().Add(2 * time.Second) var ws watchState for time.Now().Before(deadline) { @@ -134,7 +142,7 @@ func TestWatchdogNamesTheWorkInItsTree(t *testing.T) { } time.Sleep(50 * time.Millisecond) } - if !ws.Pkg || !strings.HasPrefix(ws.Current, "pacman 5") { + if !ws.Pkg || !strings.HasPrefix(ws.Current, "pacman ") { t.Errorf("sample = %+v, want the pacman child named and flagged", ws) } if ws.State != "working" { diff --git a/ryoku/cli/internal/updater/manifest.go b/ryoku/cli/internal/updater/manifest.go index 6c53558836..7a6036bcc1 100644 --- a/ryoku/cli/internal/updater/manifest.go +++ b/ryoku/cli/internal/updater/manifest.go @@ -60,7 +60,37 @@ func FetchManifest() (ryokumanifest.Manifest, error) { if m.Schema > ryokumanifest.Schema { return m, fmt.Errorf("manifest schema %d is newer than this ryoku understands (%d); run a full update first", m.Schema, ryokumanifest.Schema) } - return m, nil + return ForHost(m), nil +} + +// ForHost is the served manifest as this box should read it. A CachyOS box +// (os-release ID=cachyos) never carries what its layer replaces, however it was +// installed, so every reader -- the reconciler, `ryoku verify` -- sees the same +// set and none of them offers the stock kernel back. +func ForHost(m ryokumanifest.Manifest) ryokumanifest.Manifest { + if osReleaseID() == "cachyos" { + return m.ForCachyOS() + } + return m +} + +// osReleaseID is os-release's ID, unquoted; RYOKU_OS_RELEASE points tests at a +// fixture, as it does for host's repository URL expansion. +func osReleaseID() string { + path := strings.TrimSpace(os.Getenv("RYOKU_OS_RELEASE")) + if path == "" { + path = "/etc/os-release" + } + body, err := os.ReadFile(path) + if err != nil { + return "" + } + for _, line := range strings.Split(string(body), "\n") { + if value, ok := strings.CutPrefix(strings.TrimSpace(line), "ID="); ok { + return strings.Trim(value, `"'`) + } + } + return "" } // Applied is the box's saved baseline: the manifest it last converged to, and diff --git a/ryoku/cli/internal/updater/manifest_test.go b/ryoku/cli/internal/updater/manifest_test.go index 9de265de9c..60e49e0a28 100644 --- a/ryoku/cli/internal/updater/manifest_test.go +++ b/ryoku/cli/internal/updater/manifest_test.go @@ -1,6 +1,7 @@ package updater import ( + "os" "path/filepath" "reflect" "testing" @@ -222,3 +223,48 @@ func TestPlanManifestDoesNotInstallChoiceAbsentFromNext(t *testing.T) { t.Errorf("install = %v; a package absent from the new manifest must stay absent", plan.Install) } } + +func TestForHostDropsCachyOSReplacedNamesOnlyOnCachyOS(t *testing.T) { + served := ryokumanifest.Manifest{ + Base: []string{"base", "linux"}, + AUR: []string{"yay-bin"}, + CachyOSReplaces: []string{"linux", "yay-bin"}, + } + for _, tc := range []struct { + osRelease string + wantBase []string + wantAUR []string + }{ + {"NAME=\"CachyOS Linux\"\nID=cachyos\n", []string{"base"}, nil}, + {"NAME=\"Arch Linux\"\nID=arch\n", []string{"base", "linux"}, []string{"yay-bin"}}, + } { + path := filepath.Join(t.TempDir(), "os-release") + if err := os.WriteFile(path, []byte(tc.osRelease), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("RYOKU_OS_RELEASE", path) + got := ForHost(served) + if !reflect.DeepEqual(got.Base, tc.wantBase) || !reflect.DeepEqual(got.AUR, tc.wantAUR) { + t.Errorf("%q: base=%v aur=%v, want base=%v aur=%v", + tc.osRelease, got.Base, got.AUR, tc.wantBase, tc.wantAUR) + } + } +} + +func TestForHostKeepsAConvertedCachyOSBoxOffTheStockKernel(t *testing.T) { + // A box converted in place by ryoku-shell-install has no ledger entry for + // the stock kernel. Before ForHost, the reconciler read linux as "never + // delivered" and installed it next to linux-cachyos. + path := filepath.Join(t.TempDir(), "os-release") + if err := os.WriteFile(path, []byte("ID=cachyos\n"), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("RYOKU_OS_RELEASE", path) + m := ryokumanifest.Manifest{Base: []string{"base", "linux"}, CachyOSReplaces: []string{"linux"}} + next := ForHost(m) + installed := map[string]bool{"base": true, "linux-cachyos": true} + plan := PlanManifest(&next, map[string]bool{"base": true}, &next, installed) + if len(plan.Install) != 0 || len(plan.UserGone) != 0 { + t.Errorf("plan = %+v, want nothing to install or report", plan) + } +} diff --git a/ryoku/cli/internal/updater/update_test.go b/ryoku/cli/internal/updater/update_test.go index 33291d4a15..fe716ce877 100644 --- a/ryoku/cli/internal/updater/update_test.go +++ b/ryoku/cli/internal/updater/update_test.go @@ -239,6 +239,43 @@ func TestPromptAnswerRoundTrip(t *testing.T) { } } +func TestPackagedBuildVersionComparison(t *testing.T) { + tests := []struct { + name string + installed string + latest string + wantSame bool + }{ + { + name: "reporter stable package matches channel build", + installed: "1.0.6.r5626.ge24972a-3", + latest: "1.0.6.r5626.ge24972a", + wantSame: true, + }, + { + name: "fresh stable box", + installed: "1.0.6.r5626.ge24972a-3", + latest: "1.0.6.r5626.ge24972a-3", + wantSame: true, + }, + { + name: "new channel build", + installed: "1.0.6.r5626.ge24972a-3", + latest: "1.0.7.r5639.g4042923-1", + wantSame: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotSame := shortCommit(tt.installed) == shortCommit(tt.latest) + if gotSame != tt.wantSame { + t.Errorf("builds match = %v, want %v (%q vs %q)", + gotSame, tt.wantSame, tt.installed, tt.latest) + } + }) + } +} + // An up-to-date packaged box (installed == latest) reports nothing pending but // lists the recent history the installed commit contains, so the Hub's Updates // page still has content. packagedStatus is the pure core of buildStatus, so diff --git a/ryoku/cli/vendor/ryoku-wm/state.go b/ryoku/cli/vendor/ryoku-wm/state.go index 87f0e49a5d..dbd350123f 100644 --- a/ryoku/cli/vendor/ryoku-wm/state.go +++ b/ryoku/cli/vendor/ryoku-wm/state.go @@ -67,7 +67,10 @@ type Workspace struct { ID string `json:"id"` Name string `json:"name"` Output string `json:"output,omitempty"` - Active bool `json:"active,omitempty"` + // Index is the one-based presentation rank inside Output. Zero means the + // provider cannot report an order, so consumers preserve frame order. + Index int `json:"index,omitempty"` + Active bool `json:"active,omitempty"` // Windows is a count, which is all the wallpaper occupancy gate needs. Windows int `json:"windows"` Fullscreen bool `json:"fullscreen,omitempty"` diff --git a/ryoku/hub/CHANGELOG.md b/ryoku/hub/CHANGELOG.md index deda5235a3..6cf36f14b4 100644 --- a/ryoku/hub/CHANGELOG.md +++ b/ryoku/hub/CHANGELOG.md @@ -3,6 +3,28 @@ ## Unreleased ### Fixed +- Fastfetch renderer and recolor markers survive Settings saves. Renderer based + styles keep their readout, and wallpaper recoloring keeps its palette roles. + Rows are locked for styles that provide their own renderer. +- **Fix with AI no longer leaves its terminal inside the desktop service.** The + repair session launches in its own application scope + (`quickshell/pages/UpdatesPage.qml`). +- **Window Manager > Display can control automatic HDR.** The new switch starts + off and saves through the active provider, so fullscreen HDR content cannot + unexpectedly blank a panel; users whose display handles the switch can opt in + (`../wm/hyprland/schema.json`, `../wm/hyprland/config_settings.go`). +- **Glass windows is active from login.** The saved plugin settings are + reconciled once Hyprland finishes loading its startup plugins, so the glass + effect no longer needs to be switched off and on after each login + (`../wm/hyprland/config_plugins.go`). +- **Updates does not offer an empty run.** A current box labels the action + `UP TO DATE` and disables it; selecting `INCLUDE SYSTEM` enables `UPDATE NOW` + only when system packages are waiting (`quickshell/pages/UpdatesPage.qml`). +- **Plugin failures tell the truth.** A stopped or replaced Hyprland instance is + shown as offline instead of making every enabled plugin look broken, stale + session handles are rebound before commands run, and plugin setup errors are + written to the compositor log (`quickshell/pages/PluginsPage.qml`, + `../wm/hyprland/`). - **Bluetooth can recover when rfkill hides the adapter.** Connections shows the software-blocked state and keeps its power switch available so the shared helper can bring the controller back (`quickshell/pages/ConnectionsPage.qml`). @@ -46,12 +68,33 @@ `../shell/matugen/config.toml`). ### Added +- **Rashin shows the installed Hermes agent version and its update owner.** + Existing installs stay untouched, so the page points users to their Hermes + installer or checkout instead of offering an updater that Ryoku does not own + (`quickshell/pages/RashinPage.qml`). +- **Choose your profile picture.** Global > Users shows each person's picture + instead of a letter when they have one, and your own card has a PROFILE PICTURE + row to choose, change or remove it. The image is cropped to a 512 px square, + kept in `~/.face`, and set through AccountsService so login managers and other + settings apps show the same one (`quickshell/pages/users/`). - **Update now can include the system packages.** Updates has an INCLUDE SYSTEM switch, off by default, saved as `update_system` in the Hub config. When it is on, Update now runs `ryoku update --gui --system`, so the distribution upgrade, kernel included, rides along with the Ryoku packages (`backend/config.go`, `quickshell/pages/UpdatesPage.qml`, `quickshell/schema/UpdatesPage.js`). +- **The settings search is navigable by keyboard.** Typing in the rail search + now keeps a cursor over its results: Up and Down walk the hits (wrapping at + the ends), Enter opens the selected one instead of always the top hit, and + the header shows `N / TOTAL` so a long list is legible. The cursor resets on + every edit and on dismissal, so refining a query or reopening the field + never leaves a stale row selected. Moving the pointer over a row also moves + the cursor, and the keyboard and pointer highlights share one state, so a + click after a key press looks like a continuation rather than a jump + (`quickshell/Hub.ml`). +- **`Ctrl + F` also opens the settings search.** The rail finder answered only + to `Ctrl + K`; it now accepts `Ctrl + F` as well, the shortcut a user + instinctively reaches for in a settings app (`quickshell/Hub.ml`). - **General manages people on this computer.** The Users tab lists local accounts, sign-in state, roles, permissions and home storage, and lets an administrator add or remove people, reset passwords and set storage limits diff --git a/ryoku/hub/README.md b/ryoku/hub/README.md index 0f9fc05912..98f669376a 100644 --- a/ryoku/hub/README.md +++ b/ryoku/hub/README.md @@ -144,7 +144,8 @@ edits stage in the canvas and only touch the live screens on Apply. ## Search -A global fuzzy finder lives in the sidebar (focus it with `Ctrl + K`). Typing +A global fuzzy finder lives in the sidebar (focus it with `Ctrl + K`, or the +more familiar `Ctrl + F`). Typing searches content across every section: it ranks matching keybinds (each tagged with its category) and lists matching section names you can jump to. The matcher is a small subsequence scorer in `quickshell/fuzzy.js`. diff --git a/ryoku/hub/backend/fastfetch.go b/ryoku/hub/backend/fastfetch.go index db8827b707..b962f4a6d1 100644 --- a/ryoku/hub/backend/fastfetch.go +++ b/ryoku/hub/backend/fastfetch.go @@ -36,14 +36,15 @@ const ( ) var ( - ffAnsiRe = regexp.MustCompile("\x1b\\[[0-9;]*m") - ffTanRe = regexp.MustCompile("\x1b\\[38;2;" + regexp.QuoteMeta(ffTan) + "m(.*?)\x1b\\[0m") - ffLabelRe = regexp.MustCompile("\x1b\\[1;38;2;" + regexp.QuoteMeta(ffBright) + "m(.*?)\x1b\\[0m") - ffKeysColor = regexp.MustCompile(`(?:38;2;)?([0-9]+;[0-9]+;[0-9]+)`) - ffHexColor = regexp.MustCompile(`^#[0-9A-Fa-f]{6}$`) - ffTrueColorRe = regexp.MustCompile("\x1b\\[((?:1;)?)38;2;([0-9]+;[0-9]+;[0-9]+)m") - ffRecolorLineRe = regexp.MustCompile(`(?m)^[\t ]*//[\t ]*ryoku:recolor[\t ]+([^\r\n]+)`) - ffRecolorPairRe = regexp.MustCompile(`([A-Za-z][A-Za-z0-9_-]*)=([A-Za-z][A-Za-z0-9_-]*)`) + ffAnsiRe = regexp.MustCompile("\x1b\\[[0-9;]*m") + ffTanRe = regexp.MustCompile("\x1b\\[38;2;" + regexp.QuoteMeta(ffTan) + "m(.*?)\x1b\\[0m") + ffLabelRe = regexp.MustCompile("\x1b\\[1;38;2;" + regexp.QuoteMeta(ffBright) + "m(.*?)\x1b\\[0m") + ffKeysColor = regexp.MustCompile(`(?:38;2;)?([0-9]+;[0-9]+;[0-9]+)`) + ffHexColor = regexp.MustCompile(`^#[0-9A-Fa-f]{6}$`) + ffTrueColorRe = regexp.MustCompile("\x1b\\[((?:1;)?)38;2;([0-9]+;[0-9]+;[0-9]+)m") + ffRecolorLineRe = regexp.MustCompile(`(?m)^[\t ]*//[\t ]*ryoku:recolor[\t ]+([^\r\n]+)`) + ffRendererLineRe = regexp.MustCompile(`(?m)^//[\t ]*ryoku:renderer[\t ]+(\S+)[\t ]*$`) + ffRecolorPairRe = regexp.MustCompile(`([A-Za-z][A-Za-z0-9_-]*)=([A-Za-z][A-Za-z0-9_-]*)`) // ffSlotTrueColorRe matches a rice's inline fastfetch colour placeholder // "{#38;2;R;G;B}", whose R;G;B is a fixed brand tone baked into the format // (fastfetch resolves it to literal ANSI). The wallpaper pass rewrites the @@ -51,6 +52,31 @@ var ( ffSlotTrueColorRe = regexp.MustCompile(`\{#((?:1;)?38;2;)([0-9]+;[0-9]+;[0-9]+)\}`) ) +func ffRendererLine(config []byte) string { + if match := ffRendererLineRe.FindSubmatch(config); match != nil { + return string(match[1]) + } + return "" +} + +func ffRecolorLine(config []byte) string { + if match := ffRecolorLineRe.FindSubmatch(config); match != nil { + return strings.TrimSpace(string(match[1])) + } + return "" +} + +func ffWithMarkers(config []byte, renderer, recolor string) []byte { + var head []byte + if renderer != "" { + head = append(head, "// ryoku:renderer "+renderer+"\n"...) + } + if recolor != "" { + head = append(head, "// ryoku:recolor "+recolor+"\n"...) + } + return append(head, config...) +} + func ffBaseOS() string { if base, ok := os.LookupEnv("RYOKU_BASE_OS"); ok { return base @@ -195,12 +221,14 @@ type ffRow struct { } type ffModel struct { - Logo ffLogo `json:"logo"` - Accent string `json:"accent"` - Palette string `json:"palette"` - Rows []ffRow `json:"rows"` - Display json.RawMessage `json:"display,omitempty"` - Schema string `json:"schema,omitempty"` + Logo ffLogo `json:"logo"` + Accent string `json:"accent"` + Palette string `json:"palette"` + Renderer string `json:"renderer,omitempty"` + Recolor string `json:"recolor,omitempty"` + Rows []ffRow `json:"rows"` + Display json.RawMessage `json:"display,omitempty"` + Schema string `json:"schema,omitempty"` } type ffPalette struct { @@ -342,6 +370,16 @@ func runFastfetch(args []string) error { return err } } + if m.Renderer == "" || m.Recolor == "" { + if current, err := os.ReadFile(fastfetchConfigPath()); err == nil { + if m.Renderer == "" { + m.Renderer = ffRendererLine(current) + } + if m.Recolor == "" { + m.Recolor = ffRecolorLine(current) + } + } + } b, err := buildFastfetch(m) if err != nil { return err @@ -434,7 +472,7 @@ func loadFastfetch() (ffModel, error) { if err := json.Unmarshal(stripJSONC(raw), &doc); err != nil { return ffModel{}, fmt.Errorf("parse config.jsonc: %w", err) } - m := ffModel{Schema: doc.Schema, Display: doc.Display, Accent: ffDisplayAccent(doc.Display), Palette: readFastfetchPaletteMode(), Logo: ffNormalizeLogo(doc.Logo)} + m := ffModel{Schema: doc.Schema, Display: doc.Display, Accent: ffDisplayAccent(doc.Display), Palette: readFastfetchPaletteMode(), Logo: ffNormalizeLogo(doc.Logo), Renderer: ffRendererLine(raw), Recolor: ffRecolorLine(raw)} baseOS := ffBaseOS() for _, rm := range doc.Modules { m.Rows = append(m.Rows, ffNormalizeModule(rm, baseOS)) @@ -605,7 +643,7 @@ func buildFastfetch(m ffModel) ([]byte, error) { if err != nil { return nil, err } - return append(b, '\n'), nil + return ffWithMarkers(append(b, '\n'), m.Renderer, m.Recolor), nil } func ffApplyPaletteToRow(raw json.RawMessage, palette ffPalette) (json.RawMessage, error) { @@ -823,7 +861,7 @@ func buildFastfetchEffectiveWithRoles(m ffModel, recolor map[string]string) ([]b if err != nil { return nil, err } - return append(b, '\n'), nil + return ffWithMarkers(append(b, '\n'), m.Renderer, m.Recolor), nil } func fastfetchEffectiveFresh(path string, mode string, japaneseGloss bool) bool { diff --git a/ryoku/hub/backend/fastfetch_test.go b/ryoku/hub/backend/fastfetch_test.go index 429dd1ccca..22cef4ef9e 100644 --- a/ryoku/hub/backend/fastfetch_test.go +++ b/ryoku/hub/backend/fastfetch_test.go @@ -685,3 +685,128 @@ func TestShippedRiceTaglinesNameTheRunningDistribution(t *testing.T) { } } } + +func TestFastfetchRendererMarkerSurvivesSave(t *testing.T) { + loadSample(t) + if err := os.WriteFile(fastfetchPalettePath(), []byte(sampleWallpaperPalette), 0o644); err != nil { + t.Fatal(err) + } + const marker = "// ryoku:renderer ryoku-filigree/bin/ryoku-fetch" + path := fastfetchConfigPath() + if err := os.WriteFile(path, []byte(marker+"\n"+sampleFF), 0o644); err != nil { + t.Fatal(err) + } + m, err := loadFastfetch() + if err != nil { + t.Fatal(err) + } + if m.Renderer != "ryoku-filigree/bin/ryoku-fetch" { + t.Fatalf("renderer = %q, want ryoku-filigree/bin/ryoku-fetch", m.Renderer) + } + + built, err := buildFastfetch(m) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(string(built), marker+"\n") { + t.Fatalf("fixed rebuild dropped the renderer marker:\n%s", built) + } + effective, err := buildFastfetchEffective(m) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(string(effective), marker+"\n") { + t.Fatalf("wallpaper rebuild dropped the renderer marker:\n%s", effective) + } + + m.Renderer = "" + payload, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + if err := runFastfetch([]string{"save", string(payload)}); err != nil { + t.Fatalf("save: %v", err) + } + saved, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(string(saved), marker+"\n") { + t.Fatalf("save dropped the renderer marker:\n%s", saved) + } + + if err := os.WriteFile(path, []byte(sampleFF), 0o644); err != nil { + t.Fatal(err) + } + m, err = loadFastfetch() + if err != nil { + t.Fatal(err) + } + saved, err = buildFastfetch(m) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(saved), "ryoku:renderer") { + t.Fatalf("plain preset gained a renderer marker:\n%s", saved) + } +} + +func TestFastfetchRecolorLineSurvivesSave(t *testing.T) { + loadSample(t) + if err := os.WriteFile(fastfetchPalettePath(), []byte(sampleWallpaperPalette), 0o644); err != nil { + t.Fatal(err) + } + const line = "// ryoku:recolor keys=primary title=foreground output=onSurface" + path := fastfetchConfigPath() + if err := os.WriteFile(path, []byte(line+"\n"+sampleFF), 0o644); err != nil { + t.Fatal(err) + } + m, err := loadFastfetch() + if err != nil { + t.Fatal(err) + } + if want := "keys=primary title=foreground output=onSurface"; m.Recolor != want { + t.Fatalf("recolor = %q, want %q", m.Recolor, want) + } + built, err := buildFastfetch(m) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(string(built), line+"\n") { + t.Fatalf("rebuild dropped the recolor line:\n%s", built) + } + + m.Recolor = "" + payload, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + if err := runFastfetch([]string{"save", string(payload)}); err != nil { + t.Fatalf("save: %v", err) + } + saved, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(string(saved), line+"\n") { + t.Fatalf("save dropped the recolor line:\n%s", saved) + } + if roles := ffRecolorRoles(saved); roles["keys"] != "primary" || roles["output"] != "onSurface" { + t.Fatalf("roles no longer parse from the saved config: %v", roles) + } + + if err := os.WriteFile(path, []byte(sampleFF), 0o644); err != nil { + t.Fatal(err) + } + plain, err := loadFastfetch() + if err != nil { + t.Fatal(err) + } + rebuilt, err := buildFastfetch(plain) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(rebuilt), "ryoku:recolor") { + t.Fatalf("plain config gained a recolor line:\n%s", rebuilt) + } +} diff --git a/ryoku/hub/backend/shellpref.go b/ryoku/hub/backend/shellpref.go index d139d8bc53..41f3ca0b09 100644 --- a/ryoku/hub/backend/shellpref.go +++ b/ryoku/hub/backend/shellpref.go @@ -1,6 +1,7 @@ package main import ( + _ "embed" "encoding/json" "fmt" "os" @@ -10,10 +11,10 @@ import ( "strings" ) -const ( - bashLoader = `[ -r "$HOME/.config/bash/ryoku.bash" ] && . "$HOME/.config/bash/ryoku.bash"` - zshLoader = `[ -r "$HOME/.config/zsh/ryoku.zsh" ] && . "$HOME/.config/zsh/ryoku.zsh"` -) +const bashLoader = `[ -r "$HOME/.config/bash/ryoku.bash" ] && . "$HOME/.config/bash/ryoku.bash"` + +//go:embed zshrc +var zshLoaderTemplate string var accountShells = map[string]string{ "fish": "/usr/bin/fish", @@ -65,7 +66,7 @@ func ensureShellLoaders(home, key string) (bool, error) { filepath.Join(home, ".bash_profile"): bashLoader, } case "zsh": - files = map[string]string{filepath.Join(home, ".zshrc"): zshLoader} + files = map[string]string{filepath.Join(home, ".zshrc"): strings.TrimSpace(zshLoaderTemplate)} default: return false, fmt.Errorf("unknown shell %q", key) } diff --git a/ryoku/hub/backend/vendor/ryoku-wm/state.go b/ryoku/hub/backend/vendor/ryoku-wm/state.go index 87f0e49a5d..dbd350123f 100644 --- a/ryoku/hub/backend/vendor/ryoku-wm/state.go +++ b/ryoku/hub/backend/vendor/ryoku-wm/state.go @@ -67,7 +67,10 @@ type Workspace struct { ID string `json:"id"` Name string `json:"name"` Output string `json:"output,omitempty"` - Active bool `json:"active,omitempty"` + // Index is the one-based presentation rank inside Output. Zero means the + // provider cannot report an order, so consumers preserve frame order. + Index int `json:"index,omitempty"` + Active bool `json:"active,omitempty"` // Windows is a count, which is all the wallpaper occupancy gate needs. Windows int `json:"windows"` Fullscreen bool `json:"fullscreen,omitempty"` diff --git a/ryoku/hub/backend/zshrc b/ryoku/hub/backend/zshrc new file mode 100644 index 0000000000..6a3706b6e3 --- /dev/null +++ b/ryoku/hub/backend/zshrc @@ -0,0 +1 @@ +[ -r "$HOME/.config/zsh/ryoku.zsh" ] && . "$HOME/.config/zsh/ryoku.zsh" diff --git a/ryoku/hub/quickshell/Hub.qml b/ryoku/hub/quickshell/Hub.qml index 80ad6eff5a..9d909f4cbd 100644 --- a/ryoku/hub/quickshell/Hub.qml +++ b/ryoku/hub/quickshell/Hub.qml @@ -1080,11 +1080,11 @@ Rectangle { Keys.onEscapePressed: { if (diffPop.open) diffPop.open = false; - else if (hub.query !== "") { hub.query = ""; searchField.clear(); } + else if (hub.query !== "") { hub.query = ""; searchField.clear(); hub.searchCursor = 0; } else hub.requestQuit(); } Keys.onPressed: (e) => { - if (e.key === Qt.Key_K && (e.modifiers & Qt.ControlModifier)) { + if ((e.key === Qt.Key_K || e.key === Qt.Key_F) && (e.modifiers & Qt.ControlModifier)) { searchField.grabFocus(); e.accepted = true; } @@ -1139,8 +1139,20 @@ Rectangle { width: parent.width toolbar: true placeholder: I18n.tr("Search settings…") - onEdited: (t) => hub.query = t - onAccepted: hub.activateSearch(0) + onEdited: (t) => { hub.query = t; hub.searchCursor = 0 } + onAccepted: hub.activateSearch(hub.searchCursor) + Keys.onUpPressed: (e) => { + if (hub.searchResults.length === 0) return; + hub.searchCursor = (hub.searchCursor - 1 + hub.searchResults.length) % hub.searchResults.length; + searchList.positionViewAtIndex(hub.searchCursor, ListView.Contain); + e.accepted = true; + } + Keys.onDownPressed: (e) => { + if (hub.searchResults.length === 0) return; + hub.searchCursor = (hub.searchCursor + 1) % hub.searchResults.length; + searchList.positionViewAtIndex(hub.searchCursor, ListView.Contain); + e.accepted = true; + } } } @@ -1688,8 +1700,12 @@ Rectangle { // The rail search opens a results card under the field (not a cramped rail // list). Picking a result navigates to its page and, for a real setting, // scrolls it into view and flashes it -- the jump-to-setting the old search - // never did. Enter takes the top hit; Escape (the field clears) dismisses. + // never did. Enter takes the selected hit; Escape (the field clears) + // dismisses; Up/Down walk the hits while the field keeps focus. property string pendingFocusKey: "" + // Which hit the keyboard is on. Resets to 0 on every edit and on dismissal, + // so a refined query or a reopened search never selects a stale row. + property int searchCursor: 0 function activateSearch(i) { var r = hub.searchResults[i]; if (!r) return; @@ -1700,6 +1716,7 @@ Rectangle { hub.pendingFocusKey = (r.isPage || !r.key) ? "" : r.key; hub.query = ""; searchField.clear(); + hub.searchCursor = 0; if (hub.pendingFocusKey !== "") { focusTimer.tries = 0; focusTimer.restart(); } } // bold the matched run in a result label (StyledText). @@ -1729,7 +1746,7 @@ Rectangle { x: rail.x + railHead.x + searchField.x y: rail.y + railHead.y + searchField.y + searchField.height + Tokens.s2 width: Math.min(480, hub.width - x - Tokens.s5) - height: Math.min(440, list.contentHeight + head.height + Tokens.s3 * 2 + Tokens.s1) + height: Math.min(440, searchList.contentHeight + head.height + Tokens.s3 * 2 + Tokens.s1) Rectangle { anchors.fill: parent @@ -1749,7 +1766,12 @@ Rectangle { font.family: Tokens.mono; font.pixelSize: 9; font.letterSpacing: 1.2 anchors.verticalCenter: parent.verticalCenter } - Item { width: Math.max(0, parent.width - 170); height: 1 } + Item { width: Math.max(0, parent.width - 210); height: 1 } + Text { + text: (hub.searchCursor + 1) + " / " + hub.searchResults.length + color: Tokens.ink; font.family: Tokens.mono; font.pixelSize: 9; font.weight: Font.Medium + anchors.verticalCenter: parent.verticalCenter + } Text { text: hub.searchResults.length + (hub.searchResults.length === 1 ? I18n.tr(" HIT") : I18n.tr(" HITS")) color: Tokens.inkFaint; font.family: Tokens.mono; font.pixelSize: 9 @@ -1757,7 +1779,7 @@ Rectangle { } } ListView { - id: list + id: searchList anchors { left: parent.left; right: parent.right; top: head.bottom; bottom: parent.bottom } anchors.margins: Tokens.s2; anchors.topMargin: Tokens.s1 clip: true @@ -1772,7 +1794,11 @@ Rectangle { width: ListView.view.width height: 46 radius: Tokens.radius - color: rrh.containsMouse ? Tokens.tint10 : "transparent" + // the keyboard cursor and the pointer hover share one highlight: + // a result is active when either of them is on it, and the + // behavior eases between the two so a click after a key press + // looks like a continuation, not a jump. + color: (rrh.containsMouse || rr.index === hub.searchCursor) ? Tokens.tint10 : "transparent" Behavior on color { ColorAnimation { duration: Tokens.snap } } Column { anchors { left: parent.left; right: kseal.left; verticalCenter: parent.verticalCenter } @@ -1803,6 +1829,7 @@ Rectangle { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor + onEntered: hub.searchCursor = rr.index onClicked: hub.activateSearch(rr.index) } } diff --git a/ryoku/hub/quickshell/pages/FastfetchPage.qml b/ryoku/hub/quickshell/pages/FastfetchPage.qml index 613345cb1b..ea6aa62bf1 100644 --- a/ryoku/hub/quickshell/pages/FastfetchPage.qml +++ b/ryoku/hub/quickshell/pages/FastfetchPage.qml @@ -92,6 +92,7 @@ Item { void pg.rev; return pg.ready && JSON.stringify(pg.model) !== JSON.stringify(pg.committed); } + readonly property bool compositionLocked: !!(pg.model && pg.model.renderer) // fastfetch's own config path; the head's EDIT CONFIG opens it raw, the same // file the Hub writes on Save so hand-edits and the GUI share one source. @@ -220,6 +221,8 @@ Item { function setAccent(v) { var m = pg.clone(); m.accent = v; pg.commitModel(m); } function setPalette(v) { var m = pg.clone(); m.palette = v; pg.commitModel(m); } function setRow(i, k, v) { + if (pg.compositionLocked) + return; var m = pg.clone(); m.rows[i][k] = v; if (k === "text" && m.rows[i].kind === "tagline") @@ -227,6 +230,8 @@ Item { pg.commitModel(m); } function moveRow(i, d) { + if (pg.compositionLocked) + return; var j = i + d; if (j < 0 || j >= pg.model.rows.length) return; @@ -234,8 +239,14 @@ Item { var t = m.rows[i]; m.rows[i] = m.rows[j]; m.rows[j] = t; pg.commitModel(m); } - function removeRow(i) { var m = pg.clone(); m.rows.splice(i, 1); pg.commitModel(m); } + function removeRow(i) { + if (pg.compositionLocked) + return; + var m = pg.clone(); m.rows.splice(i, 1); pg.commitModel(m); + } function addFromMenu(key) { + if (pg.compositionLocked) + return; var m = pg.clone(); if (key === "__header") m.rows.push({ "kind": "header", "enabled": true, "text": "SECTION", "label": "Section header" }); @@ -658,10 +669,12 @@ Item { id: rt property string glyph: "" property color tint: Tokens.inkDim + property bool armed: true signal tapped() width: 26 height: 26 radius: Tokens.radius + opacity: armed ? 1 : 0.3 color: rh.hovered ? Tokens.tint10 : "transparent" border.width: Tokens.border border.color: rh.hovered ? Tokens.lineStrong : Tokens.line @@ -673,8 +686,8 @@ Item { font.family: Tokens.ui font.pixelSize: 12 // glyph, sized to the button, no matching token } - HoverHandler { id: rh; cursorShape: Qt.PointingHandCursor } - TapHandler { onTapped: rt.tapped() } + HoverHandler { id: rh; enabled: rt.armed; cursorShape: Qt.PointingHandCursor } + TapHandler { enabled: rt.armed; onTapped: rt.tapped() } } // ── head: eyebrow, Fraunces title with EDIT CONFIG, subtitle ───────────── @@ -1291,6 +1304,7 @@ Item { id: addBtn anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter glyph: "+" + armed: !pg.compositionLocked onAct: pg.addOpen = true } Rectangle { @@ -1301,6 +1315,14 @@ Item { } } + Text { + width: parent.width + visible: pg.compositionLocked + text: I18n.tr("This style draws its own composition in the terminal, so its rows are fixed here.") + color: Tokens.inkFaint; font.family: Tokens.ui; font.pixelSize: Tokens.fSmall + wrapMode: Text.WordWrap + } + Repeater { model: pg.model.rows delegate: Rectangle { @@ -1320,8 +1342,8 @@ Item { anchors.left: parent.left; anchors.leftMargin: Tokens.s2 anchors.verticalCenter: parent.verticalCenter spacing: Tokens.s1 - RowTool { glyph: "\u2191"; onTapped: pg.moveRow(rowItem.index, -1) } - RowTool { glyph: "\u2193"; onTapped: pg.moveRow(rowItem.index, 1) } + RowTool { glyph: "\u2191"; armed: !pg.compositionLocked; onTapped: pg.moveRow(rowItem.index, -1) } + RowTool { glyph: "\u2193"; armed: !pg.compositionLocked; onTapped: pg.moveRow(rowItem.index, 1) } } Text { @@ -1342,6 +1364,7 @@ Item { anchors.verticalCenter: parent.verticalCenter visible: pg.rowEditable(rowItem.modelData.kind) placeholder: pg.rowPlaceholder(rowItem.modelData.kind) + readOnly: pg.compositionLocked text: rowItem.modelData.kind === "module" ? (rowItem.modelData.key || "") : (rowItem.modelData.text || "") onCommitted: (v) => { var f = rowItem.modelData.kind === "module" ? "key" : "text"; @@ -1358,9 +1381,10 @@ Item { RowTool { glyph: rowItem.modelData.enabled ? "\u25cf" : "\u25cb" tint: rowItem.modelData.enabled ? Tokens.ink : Tokens.inkFaint + armed: !pg.compositionLocked onTapped: pg.setRow(rowItem.index, "enabled", !rowItem.modelData.enabled) } - RowTool { glyph: "\u2715"; onTapped: pg.removeRow(rowItem.index) } + RowTool { glyph: "\u2715"; armed: !pg.compositionLocked; onTapped: pg.removeRow(rowItem.index) } } } } diff --git a/ryoku/hub/quickshell/pages/PluginsPage.qml b/ryoku/hub/quickshell/pages/PluginsPage.qml index 65d837881f..c5bd41e03c 100644 --- a/ryoku/hub/quickshell/pages/PluginsPage.qml +++ b/ryoku/hub/quickshell/pages/PluginsPage.qml @@ -270,6 +270,7 @@ Item { case "ready": return p.enabled ? I18n.tr("ON AT START") : I18n.tr("READY"); case "stale": return I18n.tr("NEEDS REBUILD"); case "failed": return I18n.tr("FAILED TO LOAD"); + case "unreachable": return I18n.tr("COMPOSITOR OFFLINE"); case "missing": return I18n.tr("NOT INSTALLED"); } return ""; @@ -388,7 +389,7 @@ Item { width: 8; height: 8; radius: 4 color: !card.p ? Tokens.inkFaint : card.p.status === "running" ? Tokens.sun - : (card.p.status === "stale" || card.p.status === "failed") ? Tokens.alert + : (card.p.status === "stale" || card.p.status === "failed" || card.p.status === "unreachable") ? Tokens.alert : card.p.status === "missing" ? Tokens.inkFaint : Tokens.inkMuted } Text { @@ -408,7 +409,7 @@ Item { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter readonly property bool on: card.p && card.p.status === "running" - readonly property bool bad: card.p && (card.p.status === "stale" || card.p.status === "failed") + readonly property bool bad: card.p && (card.p.status === "stale" || card.p.status === "failed" || card.p.status === "unreachable") width: pill.implicitWidth + 16 height: 20 radius: Tokens.radius @@ -437,7 +438,7 @@ Item { width: parent.width text: card.p ? card.p.detail : "" wrapMode: Text.WordWrap - color: card.p && (card.p.status === "stale" || card.p.status === "failed") ? Tokens.alert : Tokens.ink + color: card.p && (card.p.status === "stale" || card.p.status === "failed" || card.p.status === "unreachable") ? Tokens.alert : Tokens.ink font.family: Tokens.ui; font.pixelSize: Tokens.fSmall } // provenance is file-truth, so mono diff --git a/ryoku/hub/quickshell/pages/RashinPage.qml b/ryoku/hub/quickshell/pages/RashinPage.qml index 73789bc4dc..fc83e5f46f 100644 --- a/ryoku/hub/quickshell/pages/RashinPage.qml +++ b/ryoku/hub/quickshell/pages/RashinPage.qml @@ -44,6 +44,7 @@ Item { property bool vaultExists: false property int vaultFiles: 0 property bool hermesInstalled: false + property string hermesVersion: "" property bool hermesConfigured: false property int agentsPresent: 0 property int agentsWired: 0 @@ -108,6 +109,7 @@ Item { pg.vaultFiles = (typeof v.files === "number") ? v.files : 0; var h = o.hermes || ({}); pg.hermesInstalled = h.installed === true; + pg.hermesVersion = h.version || ""; pg.hermesConfigured = h.configured === true; var ags = o.agents || []; var present = 0, wired = 0; @@ -828,7 +830,9 @@ Item { Text { width: parent.width - text: I18n.tr("Set up Hermes once: it installs the agent if you don't have it, wires it to the vault, and points your other coding agents at the same map. An existing Hermes install is left untouched.") + text: pg.hermesInstalled && pg.hermesVersion !== "" + ? I18n.tr("Hermes v%1 is installed. Ryoku leaves it untouched; update it through its own installer or checkout, then re-run setup to refresh the vault wiring.").arg(pg.hermesVersion) + : I18n.tr("Set up Hermes once: it installs the agent if you don't have it, wires it to the vault, and points your other coding agents at the same map. An existing Hermes install is left untouched.") color: Tokens.inkDim; font.family: Tokens.mono; font.pixelSize: 12; wrapMode: Text.WordWrap; lineHeight: 1.4 } diff --git a/ryoku/hub/quickshell/pages/UpdatesPage.qml b/ryoku/hub/quickshell/pages/UpdatesPage.qml index 73c2e95f70..cea9c86671 100644 --- a/ryoku/hub/quickshell/pages/UpdatesPage.qml +++ b/ryoku/hub/quickshell/pages/UpdatesPage.qml @@ -84,6 +84,8 @@ Item { Process { id: saveInterval } property bool systemFull: false + readonly property bool pendingAction: Updates.available + || (pg.systemFull && Updates.packages.length > 0) property bool systemTouched: false property bool systemSavePending: false @@ -247,9 +249,10 @@ Item { } // hand the fixing to Rashin: with no TTY here `ryoku-rashin fix doctor` opens - // the agent in a terminal that investigates and repairs. detached, as always. + // the agent in a terminal that investigates and repairs. runApp keeps that + // terminal outside the Settings process tree. function fixWithAI() { - Spawn.run(["ryoku-rashin", "fix", "doctor"]); + Spawn.runApp(["ryoku-rashin", "fix", "doctor"], { label: "ryoku-fix" }); } // the fill fraction for the shared progress track: the run's own reading @@ -453,6 +456,8 @@ Item { // in the background and returns, and the run reports through the // run-state, asking this page for its password and its questions. function startUpdate() { + if (!pg.pendingAction) + return; pg.starting = true; pg.actionError = ""; pg.keepWaiting = false; @@ -1251,10 +1256,10 @@ Item { Btn { id: updateBtn anchors.fill: parent - enabled: Updates.buildState !== "building" + enabled: pg.pendingAction && Updates.buildState !== "building" opacity: enabled ? 1 : 0.5 - text: I18n.tr("UPDATE NOW") - primary: true + text: pg.pendingAction ? I18n.tr("UPDATE NOW") : I18n.tr("UP TO DATE") + primary: pg.pendingAction onAct: pg.startUpdate() } @@ -1266,7 +1271,9 @@ Item { } ToolTip.visible: updateDisabledHover.containsMouse - ToolTip.text: I18n.tr("Updates are held until the channel build finishes.") + ToolTip.text: pg.pendingAction + ? I18n.tr("Updates are held until the channel build finishes.") + : I18n.tr("No updates are waiting.") } Btn { anchors.verticalCenter: parent.verticalCenter diff --git a/ryoku/hub/quickshell/pages/users/UserCard.qml b/ryoku/hub/quickshell/pages/users/UserCard.qml index 2c1d41ba86..f9d36e07b4 100644 --- a/ryoku/hub/quickshell/pages/users/UserCard.qml +++ b/ryoku/hub/quickshell/pages/users/UserCard.qml @@ -14,7 +14,11 @@ Item { required property int adminCount property bool canManage: false property var selectedPermissions: [] + property int avatarRevision: 0 + property bool avatarBusy: false + signal avatarChooseRequested() + signal avatarRemoveRequested() signal roleRequested(string name, string role) signal permissionsRequested(string name, var permissions) signal passwordRequested(string name) @@ -101,11 +105,20 @@ Item { Text { anchors.centerIn: parent + visible: !avatarImage.hasImage text: cardRoot.initialFor(cardRoot.userData) color: Tokens.inkOnBone font.family: Tokens.display font.pixelSize: 24 } + + AvatarImage { + id: avatarImage + anchors.fill: parent + userName: String(cardRoot.userData.name || "") + home: String(cardRoot.userData.home || "") + revision: cardRoot.avatarRevision + } } Column { @@ -189,6 +202,49 @@ Item { } } + Rectangle { visible: cardRoot.isSelf; width: parent.width; height: 1; color: Tokens.lineSoft } + + Item { + visible: cardRoot.isSelf + width: parent.width + implicitHeight: visible ? photoRow.implicitHeight + Tokens.s4 * 2 : 0 + + Row { + id: photoRow + anchors { left: parent.left; right: parent.right; margins: Tokens.s4; verticalCenter: parent.verticalCenter } + spacing: Tokens.s3 + + Text { + anchors.verticalCenter: parent.verticalCenter + width: parent.width - photoButtons.width - Tokens.s3 + text: I18n.tr("PROFILE PICTURE") + color: Tokens.inkDim + font.family: Tokens.ui + font.pixelSize: Tokens.fMicro + font.weight: Font.Medium + font.letterSpacing: Tokens.trackLabel + } + Row { + id: photoButtons + anchors.verticalCenter: parent.verticalCenter + spacing: Tokens.s2 + Btn { + compact: true + text: avatarImage.hasImage ? I18n.tr("CHANGE") : I18n.tr("CHOOSE") + armed: !cardRoot.avatarBusy + onAct: cardRoot.avatarChooseRequested() + } + Btn { + visible: avatarImage.hasImage + compact: true + text: I18n.tr("REMOVE") + armed: !cardRoot.avatarBusy + onAct: cardRoot.avatarRemoveRequested() + } + } + } + } + Rectangle { width: parent.width; height: 1; color: Tokens.lineSoft } Item { diff --git a/ryoku/hub/quickshell/pages/users/UsersTab.qml b/ryoku/hub/quickshell/pages/users/UsersTab.qml index 2566f009f8..b31196f4f7 100644 --- a/ryoku/hub/quickshell/pages/users/UsersTab.qml +++ b/ryoku/hub/quickshell/pages/users/UsersTab.qml @@ -1,6 +1,7 @@ pragma ComponentBehavior: Bound import QtQuick +import Quickshell import Quickshell.Io import Ryoku.Ui import Ryoku.Ui.Singletons @@ -20,6 +21,7 @@ Column { property bool refreshPending: false property string loadError: "" property string actionError: "" + property int avatarRevision: 0 readonly property bool supported: Host.supports("users") readonly property string unsupportedReason: Host.reason("users") !== "" @@ -128,6 +130,36 @@ Column { root.runMutation(["delete", name, eraseFiles ? "--erase-files" : "--keep-files"]); } + // The picture lives in ~/.face (any user can write it) and is pushed to + // AccountsService too, so login managers and other settings apps show the + // same one. The second step is best effort: it needs the accounts daemon. + function setAvatar(path) { + if (avatarProc.running) + return; + root.actionError = ""; + avatarProc.command = ["sh", "-c", [ + 'src=$1; face="$HOME/.face"; tmp="$face.new"', + 'if command -v magick >/dev/null 2>&1; then', + ' magick "$src[0]" -auto-orient -thumbnail 512x512^ -gravity center -extent 512x512 -strip "PNG:$tmp" || { echo "Could not read that image."; exit 1; }', + 'else cp -- "$src" "$tmp" || { echo "Could not read that image."; exit 1; }; fi', + '[ "$(stat -c %s "$tmp")" -gt 1000000 ] && magick "$tmp" -quality 90 "JPEG:$tmp"', + 'mv -f -- "$tmp" "$face" || exit 1', + 'busctl --system call org.freedesktop.Accounts "/org/freedesktop/Accounts/User$(id -u)" org.freedesktop.Accounts.User SetIconFile s "$face" >/dev/null 2>&1 || true' + ].join("\n"), "sh", path]; + avatarProc.running = true; + } + + function removeAvatar() { + if (avatarProc.running) + return; + root.actionError = ""; + avatarProc.command = ["sh", "-c", [ + 'rm -f -- "$HOME/.face" "$HOME/.face.icon"', + 'busctl --system call org.freedesktop.Accounts "/org/freedesktop/Accounts/User$(id -u)" org.freedesktop.Accounts.User SetIconFile s "" >/dev/null 2>&1 || true' + ].join("\n")]; + avatarProc.running = true; + } + function closeDialogs() { addDialog.close(); passwordDialog.close(); @@ -229,6 +261,27 @@ Column { } } + Process { + id: avatarProc + environment: Spawn.env + stdout: StdioCollector { id: avatarOut } + stderr: StdioCollector { } + onExited: function(exitCode) { + if (exitCode === 0) + root.avatarRevision++; + else + root.actionError = root.parseError(avatarOut.text, I18n.tr("The profile picture could not be changed.")); + } + } + + FileDialogHost { + id: avatarPicker + title: I18n.tr("Choose a profile picture") + nameFilters: [I18n.tr("Images") + " (*.png *.jpg *.jpeg *.webp *.bmp *.gif)", I18n.tr("All files") + " (*)"] + currentFolder: "file://" + (Quickshell.env("XDG_PICTURES_DIR") || ((Quickshell.env("HOME") || "/") + "/Pictures")) + onAccepted: root.setAvatar(decodeURIComponent(("" + avatarPicker.selectedFile).replace(/^file:\/\//, ""))) + } + SettingCard { visible: Host.ready && !root.supported width: parent.width @@ -364,6 +417,10 @@ Column { permissionDefs: root.permissionDefs storageCaps: root.storageCaps canManage: root.canManage + avatarRevision: root.avatarRevision + avatarBusy: avatarProc.running + onAvatarChooseRequested: avatarPicker.open() + onAvatarRemoveRequested: root.removeAvatar() onRoleRequested: (name, role) => root.setRole(name, role) onPermissionsRequested: (name, permissions) => root.setPermissions(name, permissions) onPasswordRequested: name => { diff --git a/ryoku/hyprland/CHANGELOG.md b/ryoku/hyprland/CHANGELOG.md index c3e76ab6b3..fd74620361 100644 --- a/ryoku/hyprland/CHANGELOG.md +++ b/ryoku/hyprland/CHANGELOG.md @@ -25,6 +25,10 @@ (`modules/binds.lua`, `modules/ryoshot.lua`). ### Fixed +- **Fullscreen browsers no longer switch HDR on automatically.** Automatic HDR + starts off because the display-mode switch can blank some AMD DisplayPort + panels; users with a working HDR path can turn it back on in Settings + (`modules/render.lua`, `../wm/hyprland/`). - **Game Mode keeps compositor blur, shadows and animations off across reloads.** The live action and config both load one strip; tiled windows keep their faint tint without blurring the moving desktop, while floating glass still blurs diff --git a/ryoku/hyprland/hyprland.lua b/ryoku/hyprland/hyprland.lua index bc10f8cc90..3e6636d348 100644 --- a/ryoku/hyprland/hyprland.lua +++ b/ryoku/hyprland/hyprland.lua @@ -33,6 +33,7 @@ optional("monitors") -- wins. see monitors_user.lua.example. optional("monitors_user") require("modules.displays") +require("modules.render") require("modules.input") require("modules.touchpad") require("modules.misc") diff --git a/ryoku/hyprland/modules/binds.lua b/ryoku/hyprland/modules/binds.lua index a1e327675d..314f76e423 100644 --- a/ryoku/hyprland/modules/binds.lua +++ b/ryoku/hyprland/modules/binds.lua @@ -41,16 +41,24 @@ local function focus_direction(dir) local win = hl.get_active_window() local mon = win and win.fullscreen ~= 0 and hl.get_active_monitor() or nil if mon then - local x = mon.position.x + mon.size.x / 2 - local y = mon.position.y + mon.size.y / 2 - if dir == "left" then x = mon.position.x - 1 - elseif dir == "right" then x = mon.position.x + mon.size.x + 1 - elseif dir == "up" then y = mon.position.y - 1 - else y = mon.position.y + mon.size.y + 1 end - local next_mon = hl.get_monitor_at(x, y) - if next_mon and next_mon.id ~= mon.id then - hl.dispatch(hl.dsp.focus({ monitor = dir:sub(1, 1) })) - return + local position = type(mon.position) == "table" and mon.position or {} + local size = type(mon.size) == "table" and mon.size or {} + local mon_x = mon.x or position.x + local mon_y = mon.y or position.y + local mon_width = mon.width or size.width or size.x + local mon_height = mon.height or size.height or size.y + if mon_x and mon_y and mon_width and mon_height then + local x = mon_x + mon_width / 2 + local y = mon_y + mon_height / 2 + if dir == "left" then x = mon_x - 1 + elseif dir == "right" then x = mon_x + mon_width + 1 + elseif dir == "up" then y = mon_y - 1 + else y = mon_y + mon_height + 1 end + local next_mon = hl.get_monitor_at(x, y) + if next_mon and next_mon.id ~= mon.id then + hl.dispatch(hl.dsp.focus({ monitor = dir:sub(1, 1) })) + return + end end end hl.dispatch(hl.dsp.focus({ direction = dir })) diff --git a/ryoku/hyprland/modules/misc.lua b/ryoku/hyprland/modules/misc.lua index abf854ff80..260285600b 100644 --- a/ryoku/hyprland/modules/misc.lua +++ b/ryoku/hyprland/modules/misc.lua @@ -4,6 +4,7 @@ hl.config({ -- off the focused monitor comes up un-typeable (Discord, Vivaldi) focus_on_activate = true, disable_hyprland_logo = true, + disable_splash_rendering = true, force_default_wallpaper = 0, -- Input is the compositor-native way out of DPMS. hypridle still runs -- the neutral output.power on action when its idle cycle resumes, but diff --git a/ryoku/hyprland/modules/render.lua b/ryoku/hyprland/modules/render.lua new file mode 100644 index 0000000000..ddd6e69fd4 --- /dev/null +++ b/ryoku/hyprland/modules/render.lua @@ -0,0 +1,6 @@ +hl.config({ + render = { + -- Fullscreen browsers can trigger an HDR mode switch that blanks some panels. + cm_auto_hdr = 0, + }, +}) diff --git a/ryoku/lockscreen/CHANGELOG.md b/ryoku/lockscreen/CHANGELOG.md index b4c85f4255..8febb2bf5c 100644 --- a/ryoku/lockscreen/CHANGELOG.md +++ b/ryoku/lockscreen/CHANGELOG.md @@ -20,6 +20,11 @@ - The built-in in-session clockwork lockscreen follows the global 12/24-hour preference, including AM/PM. ### Fixed +- **The password field is ready after waking a locked display.** The shared + session-lock host restores theme focus when an output reconnects, not only + when the lock first appears, so suspending with the display powered down no + longer leaves the password field waiting for a click + (`qylock/quickshell-lockscreen/lock_shell.qml`). - **A saved Shima clock choice reaches the lock screen after updating.** Shima now retires its duplicate `inir.time.format` value into the global clock preference once, then every bar style and the lock read that one setting diff --git a/ryoku/lockscreen/qylock/quickshell-lockscreen/lock_shell.qml b/ryoku/lockscreen/qylock/quickshell-lockscreen/lock_shell.qml index a4bd0f5bd6..f61cca2ab5 100644 --- a/ryoku/lockscreen/qylock/quickshell-lockscreen/lock_shell.qml +++ b/ryoku/lockscreen/qylock/quickshell-lockscreen/lock_shell.qml @@ -367,6 +367,9 @@ ShellRoot { focusTheme(); } + onScreenChanged: focusTheme() + Component.onCompleted: focusTheme() + // Absorb unhandled gestures (scroll, pinch) so they // don't leak through to the desktop underneath. PinchHandler { target: null } diff --git a/ryoku/lockscreen/sddm/ryoku-greeter b/ryoku/lockscreen/sddm/ryoku-greeter index c6aebd3c58..1b35b8c59c 100755 --- a/ryoku/lockscreen/sddm/ryoku-greeter +++ b/ryoku/lockscreen/sddm/ryoku-greeter @@ -64,6 +64,15 @@ card_has_connected() { return 1 } +card_connected_names() { + local card_name="$1" conn name + for conn in "$drm_root/${card_name}-"*; do + [ -r "$conn/status" ] && [ "$(cat "$conn/status")" = connected ] || continue + name=${conn##*/} + printf '%s ' "${name#"$card_name"-}" + done +} + select_drm_card() { local card card_name number class boot for card in "$drm_root"/card[0-9]*; do @@ -316,13 +325,26 @@ command -v weston >/dev/null 2>&1 || { echo "ryoku-greeter: weston not found" >& # Output: " " lines. Refresh is kept verbatim from # modetest so weston matches the exact modelist entry. modetest holds no DRM # master, so this is safe to poll before weston takes the card. +# +# modetest's -D is a legacy bus id, not a device node: a /dev/dri path never +# opens anything. -M opens the first card bound to the named DRM driver, so a +# second card on the same driver could answer instead; only connectors sysfs +# reports connected on the selected card are kept. Fails when the card cannot +# be probed at all, so the caller stops polling instead of waiting out the +# deadline in front of a black screen. probe_modes() { - [ -n "$selected_card" ] || return 0 - modetest -D "/dev/dri/$selected_card" -c 2>/dev/null | awk ' + local driver connected out + [ -n "$selected_card" ] || return 1 + driver="$(sed -n 's/^DRIVER=//p' "$drm_root/$selected_card/device/uevent" 2>/dev/null)" + [ -n "$driver" ] || return 1 + out="$(modetest -M "$driver" -c 2>/dev/null)" || return 1 + connected="$(card_connected_names "$selected_card")" + printf '%s\n' "$out" | awk -v connected="$connected" ' + BEGIN { n = split(connected, names, " "); for (i = 1; i <= n; i++) live[names[i]] = 1 } /^Connectors:/ { insec = 1; next } /^(CRTCs|Encoders|Planes|Framebuffers):/ { insec = 0 } !insec { next } - $3 == "connected" { conn = $4; next } + $3 == "connected" { conn = ($4 in live) ? $4 : ""; next } $3 == "disconnected" { conn = ""; next } conn != "" && $1 ~ /^#[0-9]+$/ { split($2, wh, "x"); w = wh[1] + 0; h = wh[2] + 0; rr = $3 + 0 @@ -385,7 +407,10 @@ compose_and_start() { modes="" last=-1 while :; do - modes="$(probe_modes)" + if ! modes="$(probe_modes)"; then + modes="" + break + fi n=$(printf '%s' "$modes" | awk 'NF { c++ } END { print c + 0 }') elapsed=$(( SECONDS - start )) [ "$elapsed" -ge "$deadline" ] && break diff --git a/ryoku/rashin/backend/vendor/ryoku-wm/state.go b/ryoku/rashin/backend/vendor/ryoku-wm/state.go index 87f0e49a5d..dbd350123f 100644 --- a/ryoku/rashin/backend/vendor/ryoku-wm/state.go +++ b/ryoku/rashin/backend/vendor/ryoku-wm/state.go @@ -67,7 +67,10 @@ type Workspace struct { ID string `json:"id"` Name string `json:"name"` Output string `json:"output,omitempty"` - Active bool `json:"active,omitempty"` + // Index is the one-based presentation rank inside Output. Zero means the + // provider cannot report an order, so consumers preserve frame order. + Index int `json:"index,omitempty"` + Active bool `json:"active,omitempty"` // Windows is a count, which is all the wallpaper occupancy gate needs. Windows int `json:"windows"` Fullscreen bool `json:"fullscreen,omitempty"` diff --git a/ryoku/shell/CHANGELOG.md b/ryoku/shell/CHANGELOG.md index 4f0ed571ae..a9d23be13d 100644 --- a/ryoku/shell/CHANGELOG.md +++ b/ryoku/shell/CHANGELOG.md @@ -1,6 +1,51 @@ # Changelog: ryoku/shell/ ## Unreleased +- **A failed graphics process cannot leave the login cover on screen forever.** + The cover launcher now removes a wedged surface after one minute even when + its own animation timer cannot run (`scripts/ryoku-reload-cover`). +- **The auto-hidden Ryoku dock reveals at the screen edge over open windows.** + With auto-hide on it sits above maximized, tiled, and floating apps, so the + pointer reaches its edge; a dock without auto-hide stays below them + (`quickshell/shell/modules/dock/DockSurface.qml`). +- **Wallpaper previews recolour desktop widgets reliably.** Preview palettes now + replace their runtime file in one step, so the shell never catches an empty + file while a downloaded wallpaper is hovered (`ryogami/daemon/preview.go`). +- **Dock designs can sit closer to the screen edge.** The Stage Editor's edge + gap now accepts negative values while every design keeps its hover and reserved + regions inside the screen (`quickshell/{stage,shell/modules/dock,inir/modules/iris/dock}/`). +- **Wallpaper Engine scenes show their real preview and name in Workshop.** + Animated previews reduce to a still, unsupported project textures fall back + to a bundled image, and scenes without artwork keep a labelled colour card + (`ryogami/{daemon,picker}/`). +- **QS Bar keeps its saved widgets on a cold login.** Blocking file reads now + wait until Quickshell's reader is ready, retry brief startup races, and apply + the stored layout when the widget catalogue arrives + (`quickshell/shell/modules/bar/barstyles/qsbar/`, `../ui/DeferredFileView.qml`). +- **Ryoshot opens image searches directly in Google Lens again.** Captures now + use a private local form, honour the chosen browser before system fallbacks, + and clean up after the browser has read it. Contributed by Ronin-CK + (`quickshell/ryoshot/shell.qml`, `scripts/ryoku-cmd-google-lens`). +- **The launcher radio explains when its player is absent.** The `@` provider + offers an Install mpv row instead of starting a silent, doomed broadcast + (`quickshell/shell/modules/launcher/shared/{Singletons,providers}/`, + `scripts/ryoku-cmd-radio`). +- **Bluetooth controls follow the active radio when it appears or is replaced.** + The shared radio state now follows adapter-model and BlueZ lifecycle events, + so a controller that registers after shell startup or a service restart no + longer stays marked missing (`quickshell/shell/{services,modules/sidebar}/`). +- **The desktop visualizer stays behind apps and the dock after Super+M.** Its + ordinary on/off toggle no longer creates the separate Above windows surface + while the desktop renderer catches up (`quickshell/shell/shell.qml`). +- **Opening an Orpheus card leaves the bar steady.** Cards now open on their own + dismiss surface instead of resizing and briefly invalidating the bar surface + (`quickshell/shell/modules/bar/barstyles/orpheus/Scene.qml`). +- **Long Orpheus tray menus can be scrolled to their last row.** Tray cards now + stay within the display and scroll only when their contents exceed that space + (`quickshell/shell/modules/bar/barstyles/orpheus/{kit/Sheet,cards/TrayCard}.qml`). +- **Orpheus workspace marks stay in workspace order.** The strip now follows + each output's numeric workspace positions when providers report them out of + sequence (`quickshell/shell/modules/bar/barstyles/orpheus/modules/Workspaces.qml`). - **Shima and Python dock icons stay visible and clickable with the Shima bar.** Their icon surfaces now stay above Shima's full-output chassis, and Python reads each pinned app's bound entry before launching through the shared service @@ -78,6 +123,24 @@ - **Desktop frost blurs the wallpaper and widgets with a visualizer on.** The plain visualizer stays sharp above the glass in the same desktop surface (`quickshell/shell/modules/desktop/Desktop.qml`). +- **The Orpheus AI usage module can leave out coding agents.** Modules > AI usage + picks which of Claude, Codex and OpenCode the module counts (`aiTools`), as QS + Bar's AI widget already does; none selected hides the module + (`quickshell/shell/modules/bar/barstyles/orpheus/{modules/Ai.qml,settings/}`). +- **The Orpheus workspace strip can drop its spare empty workspace.** With Show + empty on, Modules > Workspaces has a new "Spare workspace at the end" switch + (`workspaceSpare`, default on, so nothing changes): off ends the strip at the last + workspace that exists instead of adding one more + (`quickshell/shell/modules/bar/barstyles/orpheus/{modules/Workspaces.qml,settings/}`). +- **The window title no longer keeps showing a window from the workspace you left.** + After a switch to an empty workspace nothing is activated, and the + last-focused fallback in `Wm.focusedWindow` returned the previous workspace's + window; it now only accepts a window on the workspace its output is showing + (`ui/Singletons/Wm.qml`). +- **The sidebar shows your profile picture.** The round avatar in the Controls + header draws the AccountsService icon (or `~/.face`) instead of the first + letter of your user name, and keeps the letter when there is none + (`quickshell/shell/modules/sidebar/{ControlsHero,AvatarImage}.qml`). - **Game Mode reports a failed compositor strip instead of showing On.** It persists the request before the strip, and a failed restore no longer skips the power, tune or Wi-Fi cleanup (`scripts/ryoku-cmd-game-mode`). diff --git a/ryoku/shell/ipc/nightlight_watch_test.go b/ryoku/shell/ipc/nightlight_watch_test.go index bf8df8a9c1..3a9d99bb0c 100644 --- a/ryoku/shell/ipc/nightlight_watch_test.go +++ b/ryoku/shell/ipc/nightlight_watch_test.go @@ -12,6 +12,23 @@ import ( wm "ryoku-wm" ) +// writeNlFakeBackend writes the fake night-light backend: a script that stays +// alive under its own name for the requested seconds. Its comm is the script's +// basename, the name the watcher greps for. It is not a renamed copy of sleep: +// on uutils coreutils (Ubuntu 26.04 runners) sleep is a multicall binary that +// dispatches on argv[0], so a copy under another name exits at once. +func writeNlFakeBackend(t *testing.T, path string) { + t.Helper() + script := "#!/bin/sh\n" + + "sleep \"${1:-600}\" &\n" + + "child=$!\n" + + "trap 'kill \"$child\" 2>/dev/null; exit 0' TERM INT HUP\n" + + "wait \"$child\"\n" + if err := os.WriteFile(path, []byte(script), 0o755); err != nil { + t.Fatal(err) + } +} + // TestNightlightWatcherEndToEnd drives the real watcher: a fake backend binary, // a fake provider that names it in caps, a PATH-shimmed ryoku-cmd-nightlight that // starts/stops it and writes the marker and temp files, and the inotify loop @@ -26,17 +43,7 @@ func TestNightlightWatcherEndToEnd(t *testing.T) { bin := t.TempDir() fake := filepath.Join(bin, backend) - sleep, err := exec.LookPath("sleep") - if err != nil { - t.Skip("no sleep binary") - } - raw, err := os.ReadFile(sleep) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(fake, raw, 0o755); err != nil { - t.Fatal(err) - } + writeNlFakeBackend(t, fake) // The daemon's caps probe runs ryoku-wm- caps; this fake reports the // backend name the watcher must grep for, injected through the same wmc the @@ -183,20 +190,10 @@ func buildNlFake(t *testing.T, backend string, capsFailFirst bool) nlFake { state := t.TempDir() bin := t.TempDir() - // A renamed copy of sleep is the fake backend: its comm is , the - // name the watcher greps for and the provider reports. - sleep, err := exec.LookPath("sleep") - if err != nil { - t.Skip("no sleep binary") - } - raw, err := os.ReadFile(sleep) - if err != nil { - t.Fatal(err) - } + // The fake backend stays alive under , the name the watcher greps + // for and the provider reports. fake := filepath.Join(bin, backend) - if err := os.WriteFile(fake, raw, 0o755); err != nil { - t.Fatal(err) - } + writeNlFakeBackend(t, fake) fail := "0" if capsFailFirst { diff --git a/ryoku/shell/ipc/vendor/ryoku-wm/state.go b/ryoku/shell/ipc/vendor/ryoku-wm/state.go index 87f0e49a5d..dbd350123f 100644 --- a/ryoku/shell/ipc/vendor/ryoku-wm/state.go +++ b/ryoku/shell/ipc/vendor/ryoku-wm/state.go @@ -67,7 +67,10 @@ type Workspace struct { ID string `json:"id"` Name string `json:"name"` Output string `json:"output,omitempty"` - Active bool `json:"active,omitempty"` + // Index is the one-based presentation rank inside Output. Zero means the + // provider cannot report an order, so consumers preserve frame order. + Index int `json:"index,omitempty"` + Active bool `json:"active,omitempty"` // Windows is a count, which is all the wallpaper occupancy gate needs. Windows int `json:"windows"` Fullscreen bool `json:"fullscreen,omitempty"` diff --git a/ryoku/shell/quickshell/inir/modules/iris/dock/IrisDock.qml b/ryoku/shell/quickshell/inir/modules/iris/dock/IrisDock.qml index b102e5ef97..a5cde2090f 100644 --- a/ryoku/shell/quickshell/inir/modules/iris/dock/IrisDock.qml +++ b/ryoku/shell/quickshell/inir/modules/iris/dock/IrisDock.qml @@ -44,7 +44,7 @@ Item { readonly property bool atRight: root.edge === "right" readonly property bool vertical: root.atLeft || root.atRight readonly property real thickness: root.iconSize + 10 * root.d - readonly property real extraEdgeGap: Math.round(Math.max(0, Math.min(64, + readonly property real extraEdgeGap: Math.round(Math.max(-24, Math.min(64, Number(Ryoku.Dock.cfg("edgeGap", 0)))) * root.d) readonly property int edgeOrigin: root.atTop ? Item.Top : root.atLeft ? Item.Left : root.atRight ? Item.Right : Item.Bottom function edgeX(span: real, size: real, margin: real): real { @@ -313,7 +313,7 @@ Item { // Notch: the body rests on the band's inner line and melts into it. // Plain: it floats, keeping its own air above the band. readonly property real bodyAir: (root.notch ? 0 : 10 * root.d) + root.extraEdgeGap - readonly property real edgeGap: IrisFrame.band + window.bodyAir + readonly property real edgeGap: Math.max(0, IrisFrame.band + window.bodyAir) readonly property real headroom: root.magnify ? root.iconSize * root.magnifyGain + 30 * root.d : 30 * root.d readonly property string material: { const chosen = String(root.options?.material ?? "inherit") @@ -357,7 +357,7 @@ Item { StageEditor.GlobalStates.setDockInset(root.screen?.name ?? "", root.stageEditing && window.visible ? (root.atTop ? "top" : root.atBottom ? "bottom" : root.atLeft ? "left" : "right") : "", - root.thickness + window.bodyAir) + Math.max(0, root.thickness + window.bodyAir)) } onVisibleChanged: window.publishInset() Connections { diff --git a/ryoku/shell/quickshell/inir/modules/iris/dock/IrisDockSurface.qml b/ryoku/shell/quickshell/inir/modules/iris/dock/IrisDockSurface.qml index b04d7af026..f063d19de1 100644 --- a/ryoku/shell/quickshell/inir/modules/iris/dock/IrisDockSurface.qml +++ b/ryoku/shell/quickshell/inir/modules/iris/dock/IrisDockSurface.qml @@ -82,10 +82,10 @@ Scope { exclusionMode: ExclusionMode.Normal WlrLayershell.namespace: "ryoku-dock-shima-reserve" WlrLayershell.layer: WlrLayer.Bottom - exclusiveZone: visible ? dock.thickness + dock.extraEdgeGap : 0 + exclusiveZone: visible ? Math.max(0, dock.thickness + dock.extraEdgeGap) : 0 mask: emptyRegion - implicitHeight: dock.vertical ? 0 : dock.thickness + dock.extraEdgeGap - implicitWidth: dock.vertical ? dock.thickness + dock.extraEdgeGap : 0 + implicitHeight: dock.vertical ? 0 : Math.max(0, dock.thickness + dock.extraEdgeGap) + implicitWidth: dock.vertical ? Math.max(0, dock.thickness + dock.extraEdgeGap) : 0 anchors { left: dock.atLeft || !dock.vertical right: dock.atRight || !dock.vertical diff --git a/ryoku/shell/quickshell/ryoshot/shell.qml b/ryoku/shell/quickshell/ryoshot/shell.qml index c94d625af1..b35aef890c 100644 --- a/ryoku/shell/quickshell/ryoshot/shell.qml +++ b/ryoku/shell/quickshell/ryoshot/shell.qml @@ -657,7 +657,8 @@ ShellRoot { break; case "search": root.shutter(); - searchProc.run(path); + searchProc.run(path, false); + root.quitSoon(); break; default: root.shutter(); @@ -692,13 +693,15 @@ ShellRoot { runOcr({ x: g.x, y: g.y }, { x: g.x + g.w, y: g.y + g.h }); } - // Crop, upload, then hand the URL to a reverse-image search, matching iRiS. + // Allocate the capture before writing it so its mode and ownership are fixed. function doSearchRegion() { - grabTo("/tmp/ryoshot-search.png", function (ok) { - if (!ok) { Qt.quit(); return; } - root.shutter(); - searchProc.run("/tmp/ryoshot-search.png"); - }); + if (runtimeDir.length === 0) { + root.notifySend(I18n.tr("Image search failed"), + I18n.tr("Private temporary storage is unavailable")); + root.quitSoon(); + return; + } + searchTempProc.run(); } // Hand the region to the shell's recorder; the daemon owns @@ -1360,8 +1363,8 @@ ShellRoot { stdout: StdioCollector { id: uploadOut } function run(file) { command = ["curl", "-sf", "--max-time", "30", "-A", "Mozilla/5.0", "-F", "reqtype=fileupload", - "-F", "time=72h", "-F", "fileToUpload=@" + file, - "https://litterbox.catbox.moe/resources/internals/api.php"]; + "-F", "fileToUpload=@" + file, + "https://catbox.moe/user/api.php"]; running = true; } onExited: (code) => { @@ -1482,28 +1485,44 @@ ShellRoot { } } - // Upload the crop, then open a reverse-image search on the hosted URL. Bing is - // the default engine iRiS settled on (Google and Yandex paste endpoints broke). - Process { + QtObject { id: searchProc - stdout: StdioCollector { id: searchOut } - readonly property string engine: "https://www.bing.com/images/search?view=detailv2&iss=sbi&form=SBIVSP&sbisrc=UrlPaste&q=imgurl:" - function run(file) { - command = ["curl", "-sf", "--max-time", "30", "-A", "Mozilla/5.0", "-F", "reqtype=fileupload", - "-F", "time=1h", "-F", "fileToUpload=@" + file, - "https://litterbox.catbox.moe/resources/internals/api.php"]; + function run(file, removeInput) { + var args = ["ryoku-cmd-google-lens", "--file", file]; + if (removeInput) args.push("--remove-input"); + Spawn.runApp(args, { label: "image-search" }); + } + } + + Process { + id: searchTempProc + stdout: StdioCollector { id: searchTempOut; waitForEnd: true } + stderr: StdioCollector { waitForEnd: true } + + function run() { + command = ["mktemp", "--tmpdir=" + root.runtimeDir, + "ryoshot-search-XXXXXXXX.png"]; running = true; } + onExited: (code) => { - var url = searchOut.text.trim(); - console.log("ryoshot: search upload exit " + code + " url=" + JSON.stringify(url)); - if (code === 0 && url.indexOf("http") === 0) { - root.exported = true; - Spawn.runApp(["xdg-open", searchProc.engine + url], { label: "browser" }); - } else { - root.notifySend(I18n.tr("Image search failed"), I18n.tr("Could not upload the image")); + var path = searchTempOut.text.trim(); + if (code !== 0 || path.length === 0) { + root.notifySend(I18n.tr("Image search failed"), + I18n.tr("Could not prepare a private capture")); + root.quitSoon(); + return; } - root.quitSoon(); + root.grabTo(path, function (ok) { + if (!ok) { + Quickshell.execDetached(["rm", "-f", "--", path]); + root.quitSoon(); + return; + } + root.shutter(); + searchProc.run(path, true); + root.quitSoon(); + }); } } diff --git a/ryoku/shell/quickshell/shell/modules/bar/barstyles/orpheus/Scene.qml b/ryoku/shell/quickshell/shell/modules/bar/barstyles/orpheus/Scene.qml index b4bb8e016f..869e5d4e57 100644 --- a/ryoku/shell/quickshell/shell/modules/bar/barstyles/orpheus/Scene.qml +++ b/ryoku/shell/quickshell/shell/modules/bar/barstyles/orpheus/Scene.qml @@ -3,6 +3,7 @@ pragma ComponentBehavior: Bound import QtQuick import Quickshell import Quickshell.Wayland +import Ryoku.Blobs import Ryoku.Ui.Singletons import shell.services import shell.modules.stage.frame @@ -42,6 +43,8 @@ Item { property bool autoHidden: false property var pendingSurfaceActions: [] property bool settingsSurfaceHeld: false + property bool cardSurfaceHeld: false + property var pendingCardAction: null readonly property bool surfaceOpening: pendingSurfaceActions.length > 0 readonly property real restingSurfaceHeight: Math.ceil(Math.max( K.Style.barH + K.Style.s2 + Math.ceil(K.Style.label * 1.5) @@ -166,6 +169,30 @@ Item { scene.pendingSurfaceActions = []; } + function requestCardSurface(action) { + if (scene.cardSurfaceHeld) { + action(); + return; + } + scene.cardSurfaceHeld = true; + scene.pendingCardAction = action; + cardSurfaceReadyTimer.restart(); + } + + function cancelCardSurfaceRequest() { + cardSurfaceReadyTimer.stop(); + scene.pendingCardAction = null; + if (liquidBody.cardProgress <= 0.001) + scene.cardSurfaceHeld = false; + } + + function finishPendingCardAction() { + const action = scene.pendingCardAction; + scene.pendingCardAction = null; + if (action) + action(); + } + function defaultOwnerForKey(key) { const value = normalKey(key); @@ -300,7 +327,7 @@ Item { if (!scene.validCardKey(value)) return; const nextArgs = args && typeof args === "object" ? args : ({}); - scene.requestFullSurface("card", function() { + scene.requestCardSurface(function() { hoverOpenTimer.stop(); scene.hoverCandidate = ""; scene.hoverCandidateAnchor = null; @@ -317,7 +344,7 @@ Item { return; scene.captureOrigin(anchorItem); const nextArgs = args && typeof args === "object" ? args : ({}); - scene.requestFullSurface("card", function() { + scene.requestCardSurface(function() { hoverOpenTimer.stop(); scene.hoverCandidate = ""; scene.hoverCandidateAnchor = null; @@ -339,7 +366,7 @@ Item { return; scene.markHoverSwitch(value); scene.captureOrigin(anchorItem); - scene.requestFullSurface("card", function() { + scene.requestCardSurface(function() { if (scene.hoverStates[value] !== true) { scene.hoverSwitchedKey = ""; scene.hoverSwitchedAt = 0; @@ -375,7 +402,7 @@ Item { } function closeCard() { - scene.cancelFullSurfaceRequest("card"); + scene.cancelCardSurfaceRequest(); hoverOpenTimer.stop(); hoverCloseTimer.stop(); scene.hoverCandidate = ""; @@ -515,6 +542,13 @@ Item { onTriggered: scene.finishPendingSurfaceActions() } + Timer { + id: cardSurfaceReadyTimer + interval: 16 + repeat: false + onTriggered: scene.finishPendingCardAction() + } + C.Morph { id: morphAnimation target: win @@ -578,8 +612,7 @@ Item { anchors { top: true; left: true; right: true } implicitHeight: frameLayer.extent WlrLayershell.layer: WlrLayer.Top - WlrLayershell.keyboardFocus: scene.cardKey !== "" - ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None WlrLayershell.namespace: "ryoku-bar" property string layoutSpan: "islands" @@ -592,8 +625,6 @@ Item { readonly property bool fused: layoutSpan === "fused" readonly property bool full: layoutSpan === "full" readonly property bool needsFullHeight: scene.surfaceOpening - || scene.cardKey !== "" || cards.retainedKey !== "" - || liquidBody.cardProgress > 0 || scene.settingsSurfaceHeld || morphAnimation.running readonly property real inset: 8 * K.Style.scale @@ -698,17 +729,11 @@ Item { win.width, 4) mask: Region { - Region { - x: 0 - y: 0 - width: scene.cardKey !== "" ? win.width : 0 - height: scene.cardKey !== "" ? win.height : 0 - } Region { intersection: Intersection.Combine x: Math.round(win.framedFullBandRect.x) y: Math.round(win.framedFullBandRect.y) - width: scene.cardKey === "" && !scene.autoHidden && win.full + width: !scene.autoHidden && win.full ? Math.round(win.framedFullBandRect.width) : 0 height: Math.round(win.framedFullBandRect.height) } @@ -716,7 +741,7 @@ Item { intersection: Intersection.Combine x: Math.round(win.framedLeftRect.x) y: Math.round(win.framedLeftRect.y) - width: scene.cardKey === "" && !scene.autoHidden + width: !scene.autoHidden ? Math.round(win.framedLeftRect.width) : 0 height: Math.round(win.framedLeftRect.height) } @@ -724,7 +749,7 @@ Item { intersection: Intersection.Combine x: Math.round(win.framedVoiceRect.x) y: Math.round(win.framedVoiceRect.y) - width: scene.cardKey === "" && !scene.autoHidden + width: !scene.autoHidden ? Math.round(win.framedVoiceRect.width) : 0 height: Math.round(win.framedVoiceRect.height) } @@ -732,7 +757,7 @@ Item { intersection: Intersection.Combine x: Math.round(win.framedRightRect.x) y: Math.round(win.framedRightRect.y) - width: scene.cardKey === "" && !scene.autoHidden + width: !scene.autoHidden ? Math.round(win.framedRightRect.width) : 0 height: Math.round(win.framedRightRect.height) } @@ -740,8 +765,7 @@ Item { intersection: Intersection.Combine x: Math.round(win.framedLeftBubbleRect.x) y: Math.round(win.framedLeftBubbleRect.y) - width: scene.cardKey === "" && !scene.autoHidden - && win.leftBubbleVisible + width: !scene.autoHidden && win.leftBubbleVisible ? Math.round(win.framedLeftBubbleRect.width) : 0 height: Math.round(win.framedLeftBubbleRect.height) } @@ -749,8 +773,7 @@ Item { intersection: Intersection.Combine x: Math.round(win.framedRightBubbleRect.x) y: Math.round(win.framedRightBubbleRect.y) - width: scene.cardKey === "" && !scene.autoHidden - && win.rightBubbleVisible + width: !scene.autoHidden && win.rightBubbleVisible ? Math.round(win.framedRightBubbleRect.width) : 0 height: Math.round(win.framedRightBubbleRect.height) } @@ -758,34 +781,19 @@ Item { intersection: Intersection.Combine x: Math.round(win.framedRevealRect.x) y: Math.round(win.framedRevealRect.y) - width: scene.cardKey === "" && scene.prefs.autoHide !== "off" + width: scene.prefs.autoHide !== "off" ? Math.round(win.framedRevealRect.width) : 0 - height: scene.cardKey === "" && scene.prefs.autoHide !== "off" + height: scene.prefs.autoHide !== "off" ? Math.round(win.framedRevealRect.height) : 0 } } - Shortcut { - sequences: [StandardKey.Cancel] - enabled: scene.cardKey !== "" - onActivated: scene.closeCard() - } FocusScope { id: focusSurface anchors.fill: parent focus: scene.cardKey !== "" - MouseArea { - z: 0 - anchors.fill: parent - enabled: scene.cardKey !== "" - acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton - onPressed: mouse => { - mouse.accepted = true; - Qt.callLater(scene.closeCard); - } - } StageFramedLayer { id: frameLayer @@ -1003,15 +1011,6 @@ Item { HoverHandler { id: rightBubbleHover } } - C.CardHost { - id: cards - parent: focusSurface - z: 3 - scene: scene - body: liquidBody - cardKey: scene.cardKey - cardArgs: scene.cardArgs - } } Item { @@ -1045,6 +1044,113 @@ Item { } } + // Cards use their own full-output dismiss surface so opening one never + // resizes the bar's layer surface and invalidates its visible buffer. + PanelWindow { + id: cardWindow + + visible: scene.cardSurfaceHeld + screen: scene.screen + color: "transparent" + exclusionMode: ExclusionMode.Ignore + exclusiveZone: 0 + anchors { top: true; bottom: true; left: true; right: true } + WlrLayershell.layer: WlrLayer.Top + WlrLayershell.keyboardFocus: scene.cardKey !== "" + ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None + WlrLayershell.namespace: "ryoku-bar-card" + + mask: Region { + Region { + width: scene.cardKey !== "" ? cardWindow.width : 0 + height: scene.cardKey !== "" ? cardWindow.height : 0 + } + Region { + intersection: Intersection.Subtract + width: cardWindow.width + height: K.Style.barH + } + } + + FocusScope { + anchors.fill: parent + focus: scene.cardKey !== "" + + Shortcut { + sequences: [StandardKey.Cancel] + enabled: scene.cardKey !== "" + onActivated: scene.closeCard() + } + + MouseArea { + z: 0 + anchors.fill: parent + enabled: scene.cardKey !== "" + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + onPressed: mouse => { + mouse.accepted = true; + Qt.callLater(scene.closeCard); + } + } + + Item { + id: cardPaper + + z: 1 + x: liquidBody.cardX + y: liquidBody.cardY + width: liquidBody.cardCurW + height: liquidBody.cardCurH + visible: liquidBody.cardActive + opacity: liquidBody.opacity + + BlobGroup { + id: cardLiquid + color: K.Style.base + smoothing: liquidBody.smoothing + borderWidth: scene.prefs.rim ? 1 : 0 + borderColor: K.Style.rim + shadowStrength: 0 + shadowSize: 0 + } + + BlobRect { + group: cardLiquid + width: cardPaper.width + height: cardPaper.height + topLeftRadius: liquidBody.leftOverhang ? liquidBody.radius : 0 + topRightRadius: liquidBody.rightOverhang ? liquidBody.radius : 0 + bottomLeftRadius: liquidBody.edgeRadius( + liquidBody.cardX, liquidBody.cardCurW, "left") + bottomRightRadius: liquidBody.edgeRadius( + liquidBody.cardX, liquidBody.cardCurW, "right") + stiffness: 180 + damping: 16 + deformScale: 0 + sinks: false + } + } + + C.CardHost { + id: cards + z: 2 + scene: scene + body: liquidBody + cardKey: scene.cardKey + cardArgs: scene.cardArgs + } + } + + Connections { + target: liquidBody + function onCardProgressChanged() { + if (liquidBody.cardProgress <= 0.001 && scene.cardKey === "" + && scene.pendingCardAction === null) + scene.cardSurfaceHeld = false; + } + } + } + S.SettingsWindow { scene: scene targetScreen: scene.screen diff --git a/ryoku/shell/quickshell/shell/modules/bar/barstyles/orpheus/cards/TrayCard.qml b/ryoku/shell/quickshell/shell/modules/bar/barstyles/orpheus/cards/TrayCard.qml index 3155126c39..32ad93da6c 100644 --- a/ryoku/shell/quickshell/shell/modules/bar/barstyles/orpheus/cards/TrayCard.qml +++ b/ryoku/shell/quickshell/shell/modules/bar/barstyles/orpheus/cards/TrayCard.qml @@ -38,6 +38,7 @@ K.Sheet { readonly property var overflowItems: (Tray.items || []).slice(trayLimit) pageWidth: 320 + maxHeight: scene ? scene.cardMaxHeight : 0 title: showingOverflow ? "Tray" : displayName(currentItem) backVisible: pathIds.length > 0 || (overflowMode && menuService.length > 0) onBack: backPage() diff --git a/ryoku/shell/quickshell/shell/modules/bar/barstyles/orpheus/kit/Sheet.qml b/ryoku/shell/quickshell/shell/modules/bar/barstyles/orpheus/kit/Sheet.qml index f5adc9a50f..c4c183c56f 100644 --- a/ryoku/shell/quickshell/shell/modules/bar/barstyles/orpheus/kit/Sheet.qml +++ b/ryoku/shell/quickshell/shell/modules/bar/barstyles/orpheus/kit/Sheet.qml @@ -1,9 +1,12 @@ import QtQuick +import QtQuick.Controls as Controls +import Ryoku.Ui as Ui Item { id: root property real pageWidth: 360 + property real maxHeight: 0 property string title: "" property bool backVisible: false property list trailing @@ -15,12 +18,20 @@ Item { // is shown, so the header never hides on their account. readonly property bool headerVisible: backVisible || title.length > 0 || trailing.length > 0 readonly property real padding: Style.s4 + readonly property real headerHeight: headerVisible + ? Math.max(32, headerRow.implicitHeight) + Style.s3 : 0 + readonly property real contentHeight: contentColumn.implicitHeight + readonly property real naturalHeight: padding * 2 + headerHeight + contentHeight + readonly property real sheetHeight: maxHeight > 0 + ? Math.min(naturalHeight, maxHeight) : naturalHeight + readonly property real bodyHeight: Math.max(0, + sheetHeight - padding * 2 - headerHeight) + readonly property bool scrollable: contentHeight > bodyHeight + 0.5 implicitWidth: pageWidth - implicitHeight: padding * 2 + contentColumn.implicitHeight - + (headerVisible ? Math.max(32, headerRow.implicitHeight) + Style.s3 : 0) + implicitHeight: naturalHeight width: implicitWidth - height: implicitHeight + height: sheetHeight function adoptTrailing() { for (let index = 0; index < trailing.length; ++index) { @@ -74,11 +85,30 @@ Item { } } - Column { - id: contentColumn + Flickable { + id: body + x: root.padding - y: root.padding + (root.headerVisible ? headerRow.height + Style.s3 : 0) + y: root.padding + root.headerHeight width: Math.max(0, root.width - root.padding * 2) - spacing: Style.s4 + height: root.bodyHeight + contentWidth: width + contentHeight: contentColumn.implicitHeight + clip: root.scrollable + interactive: root.scrollable + boundsBehavior: Flickable.StopAtBounds + flickDeceleration: 3000 + + Column { + id: contentColumn + width: body.width + spacing: Style.s4 + } + + Ui.WheelScroll {} + + Controls.ScrollBar.vertical: Ui.ScrollRail { + policy: root.scrollable ? Controls.ScrollBar.AsNeeded : Controls.ScrollBar.AlwaysOff + } } } diff --git a/ryoku/shell/quickshell/shell/modules/bar/barstyles/orpheus/modules/Ai.qml b/ryoku/shell/quickshell/shell/modules/bar/barstyles/orpheus/modules/Ai.qml index 479f5dd3a4..e062fd5cb5 100644 --- a/ryoku/shell/quickshell/shell/modules/bar/barstyles/orpheus/modules/Ai.qml +++ b/ryoku/shell/quickshell/shell/modules/bar/barstyles/orpheus/modules/Ai.qml @@ -2,6 +2,7 @@ import QtQuick import Ryoku.Ui.Singletons import shell.services import "../kit" as K +import "../settings" as S Item { id: root @@ -9,12 +10,17 @@ Item { required property var scene required property string lane - property bool present: AiUsage.available + // Tools the Modules page left on; a tool with no usage data stays hidden + // either way. + readonly property var shown: AiUsage.present.filter( + id => S.Prefs.aiTools.indexOf(id) >= 0) + + property bool present: root.shown.length > 0 readonly property string leadId: { let bestId = ""; let highest = -1; - for (const id of AiUsage.present) { + for (const id of root.shown) { const tool = AiUsage.tool(id); if (tool.has5h && tool.pct5h > highest) { highest = tool.pct5h; @@ -25,7 +31,7 @@ Item { } readonly property var lead: root.leadId.length > 0 ? AiUsage.tool(root.leadId) : null - readonly property bool hot: AiUsage.present.some(id => { + readonly property bool hot: root.shown.some(id => { const tool = AiUsage.tool(id); return tool.blocked || (tool.has5h && tool.pct5h >= 80) || (tool.has7d && tool.pct7d >= 80); @@ -52,7 +58,7 @@ Item { + (resetText.length > 0 ? " (resets " + resetText + ")" : ""); } - readonly property string hintText: AiUsage.present.map(id => { + readonly property string hintText: root.shown.map(id => { const tool = AiUsage.tool(id); const name = AiUsage.labels[id] || id; const state = tool.blocked ? "limit reached, " : ""; diff --git a/ryoku/shell/quickshell/shell/modules/bar/barstyles/orpheus/modules/Workspaces.qml b/ryoku/shell/quickshell/shell/modules/bar/barstyles/orpheus/modules/Workspaces.qml index 81d6d9a529..937fdad50a 100644 --- a/ryoku/shell/quickshell/shell/modules/bar/barstyles/orpheus/modules/Workspaces.qml +++ b/ryoku/shell/quickshell/shell/modules/bar/barstyles/orpheus/modules/Workspaces.qml @@ -36,6 +36,7 @@ Item { const parts = [ field(S.Prefs.workspaceStyle), S.Prefs.workspaceShowEmpty ? "1" : "0", + S.Prefs.workspaceSpare ? "1" : "0", field(Wm.workspaceModel), field(scene.screenName), outputsReported ? "1" : "0" @@ -63,8 +64,9 @@ Item { } } } - parts.push(field(key), field(workspace.name || key), - field(workspace.idx !== undefined ? workspace.idx : index + 1), + const rank = workspace.index !== undefined ? workspace.index + : workspace.idx !== undefined ? workspace.idx : index + 1; + parts.push(field(key), field(workspace.name || key), field(rank), workspace.active === true ? "1" : "0", workspace.occupied === true ? "1" : "0", workspace.urgent === true ? "1" : "0", @@ -120,10 +122,12 @@ Item { if (!S.Prefs.workspaceShowEmpty && !occupied && workspace.active !== true) continue; + const rank = workspace.index !== undefined ? Number(workspace.index) + : workspace.idx !== undefined ? Number(workspace.idx) : index + 1; shown.push({ key: key, label: String(workspace.name || key), - index: workspace.idx !== undefined ? Number(workspace.idx) : index + 1, + index: Number.isFinite(rank) && rank > 0 ? rank : index + 1, active: workspace.active === true, occupied: occupied, urgent: workspace.urgent === true, @@ -131,14 +135,22 @@ Item { windowCount: count }); } + shown.sort((left, right) => { + const leftIndex = Number(left.index); + const rightIndex = Number(right.index); + if (!Number.isFinite(leftIndex) || !Number.isFinite(rightIndex)) + return 0; + return leftIndex - rightIndex; + }); return S.Prefs.workspaceShowEmpty && Wm.workspaceModel === "fixed" ? root.padded(shown) : shown; } // A fixed workspace model numbers its slots, yet only the ones in use are - // reported. Fill the holes, plus one fresh slot after the last occupied - // one, so empty workspaces show the way the setting promises. + // reported. Fill the holes, plus (unless workspaceSpare is off) one fresh slot + // after the last occupied one, so empty workspaces show the way the setting + // promises. function padded(shown) { const byNumber = ({}); let last = 0; @@ -151,7 +163,7 @@ Item { } if (last === 0) return shown; - const end = byNumber[last].occupied ? last + 1 : last; + const end = S.Prefs.workspaceSpare && byNumber[last].occupied ? last + 1 : last; const filled = []; for (let number = 1; number <= end; ++number) { const row = byNumber[number]; diff --git a/ryoku/shell/quickshell/shell/modules/bar/barstyles/orpheus/settings/Prefs.qml b/ryoku/shell/quickshell/shell/modules/bar/barstyles/orpheus/settings/Prefs.qml index 5745818987..fb7c7c73c0 100644 --- a/ryoku/shell/quickshell/shell/modules/bar/barstyles/orpheus/settings/Prefs.qml +++ b/ryoku/shell/quickshell/shell/modules/bar/barstyles/orpheus/settings/Prefs.qml @@ -132,6 +132,7 @@ Singleton { feel: "fluid", workspaceStyle: "dots", workspaceShowEmpty: true, + workspaceSpare: true, showDate: true, seconds: false, voiceWeather: true, @@ -143,7 +144,8 @@ Singleton { sysmonItems: ["cpu", "mem", "temp"], actions: ["launcher", "capture", "clipboard"], trayLimit: 6, - statusItems: ["wifi", "bluetooth", "volume", "mic"] + statusItems: ["wifi", "bluetooth", "volume", "mic"], + aiTools: ["claude", "codex", "opencode"] }; } @@ -183,6 +185,7 @@ Singleton { case "workspaceStyle": return choice(value, ["dots", "numbers", "kanji", "greek", "dashes", "icons"], "dots"); case "workspaceShowEmpty": return flag(value, true); + case "workspaceSpare": return flag(value, true); case "showDate": return flag(value, true); case "seconds": return flag(value, false); case "voiceWeather": return flag(value, true); @@ -200,6 +203,8 @@ Singleton { case "statusItems": return subset(value, ["wifi", "bluetooth", "volume", "mic"], ["wifi", "bluetooth", "volume", "mic"]); + case "aiTools": return subset(value, + ["claude", "codex", "opencode"], ["claude", "codex", "opencode"]); default: return value; } } @@ -219,6 +224,7 @@ Singleton { readonly property string feel: clean("feel", value("feel")) readonly property string workspaceStyle: clean("workspaceStyle", value("workspaceStyle")) readonly property bool workspaceShowEmpty: clean("workspaceShowEmpty", value("workspaceShowEmpty")) + readonly property bool workspaceSpare: clean("workspaceSpare", value("workspaceSpare")) readonly property bool showDate: clean("showDate", value("showDate")) readonly property bool seconds: clean("seconds", value("seconds")) readonly property bool voiceWeather: clean("voiceWeather", value("voiceWeather")) @@ -231,6 +237,7 @@ Singleton { readonly property var actions: clean("actions", value("actions")) readonly property int trayLimit: clean("trayLimit", value("trayLimit")) readonly property var statusItems: clean("statusItems", value("statusItems")) + readonly property var aiTools: clean("aiTools", value("aiTools")) readonly property bool clock12h: own(_previews, "clock12h") ? _previews.clock12h === true : !TimeFormat.is24h diff --git a/ryoku/shell/quickshell/shell/modules/bar/barstyles/orpheus/settings/pages/ModulesPage.qml b/ryoku/shell/quickshell/shell/modules/bar/barstyles/orpheus/settings/pages/ModulesPage.qml index a22ee17db0..6df49782f2 100644 --- a/ryoku/shell/quickshell/shell/modules/bar/barstyles/orpheus/settings/pages/ModulesPage.qml +++ b/ryoku/shell/quickshell/shell/modules/bar/barstyles/orpheus/settings/pages/ModulesPage.qml @@ -35,6 +35,11 @@ Item { { value: "volume", label: "Volume", icon: "volume_up" }, { value: "mic", label: "Microphone", icon: "mic" } ] + readonly property var aiChoices: [ + { value: "claude", label: "Claude", icon: "auto_awesome" }, + { value: "codex", label: "Codex", icon: "auto_awesome" }, + { value: "opencode", label: "OpenCode", icon: "auto_awesome" } + ] readonly property var readingChoices: [ { value: "cpu", label: "Processor", icon: "memory" }, { value: "mem", label: "Memory", icon: "data_usage" }, @@ -135,6 +140,20 @@ Item { onToggled: value => S.Prefs.set("workspaceShowEmpty", value) } } + + SettingRow { + width: parent.width + visible: S.Prefs.workspaceShowEmpty + label: "Spare workspace at the end" + desc: "Show one extra empty workspace after the last one in use. Off stops the strip at the last workspace that exists." + controlWidth: 54 + + K.Switch { + anchors.right: parent.right + checked: S.Prefs.workspaceSpare + onToggled: value => S.Prefs.set("workspaceSpare", value) + } + } } SettingCard { @@ -343,6 +362,34 @@ Item { } } + SettingCard { + width: parent.width + title: "AI USAGE" + collapsible: false + + SettingRow { + width: parent.width + label: "Tools" + desc: "Choose which coding agents the module counts. A tool with no usage data stays hidden; none selected hides the module." + block: true + + Flow { + width: parent.width + spacing: K.Style.s2 + Repeater { + model: root.aiChoices + delegate: K.Chip { + required property var modelData + text: modelData.label + icon: modelData.icon + selected: root.contains(S.Prefs.aiTools, modelData.value) + onClicked: root.toggleList("aiTools", modelData.value) + } + } + } + } + } + SettingCard { width: parent.width title: "SHORTCUTS" diff --git a/ryoku/shell/quickshell/shell/modules/bar/barstyles/qsbar/Theme.qml b/ryoku/shell/quickshell/shell/modules/bar/barstyles/qsbar/Theme.qml index 14c982955e..581004ef3e 100644 --- a/ryoku/shell/quickshell/shell/modules/bar/barstyles/qsbar/Theme.qml +++ b/ryoku/shell/quickshell/shell/modules/bar/barstyles/qsbar/Theme.qml @@ -3,6 +3,7 @@ import Quickshell import Quickshell.Io import Quickshell.Services.UPower import shell.services +import Ryoku.Ui import Ryoku.Ui.Singletons import "core" import "Palette.js" as Palette @@ -270,6 +271,7 @@ Item { property bool barLayoutReady: false property bool _barLayoutInitialized: false readonly property var barLayout: _normalizeLayout(_storedLayout) + property bool _catalogPopulated: false // The set of ids that may hold a place in the bar: every built-in (always, // even hidden) plus every plugin currently enabled and hosted on the bar. @@ -287,6 +289,15 @@ Item { // after the id set changes, and a handler on the ids ran before that // re-derivation and handed the bars the old arrays (the plugin never drew). onBarLayoutChanged: if (barLayoutReady) _applyLayoutToBars("") + Connections { + target: barCat + function onEntriesChanged() { + const populated = barCat.entries.length > 0 + if (populated && !theme._catalogPopulated && theme.barLayoutReady) + theme._applyLayoutToBars("") + theme._catalogPopulated = populated + } + } function _eligibleIds() { var m = ({}) @@ -1685,34 +1696,39 @@ Item { storageTotalBytes = Math.max(0, total) } - FileView { + DeferredFileView { id: systemCpuFile path: "/proc/stat" - onLoaded: theme.parseSystemCpu(systemCpuFile.text()) + readLabel: "/proc/stat" + onTextReady: content => theme.parseSystemCpu(content) } - FileView { + DeferredFileView { id: systemCpuInfoFile path: "/proc/cpuinfo" - onLoaded: theme.parseCpuInfo(systemCpuInfoFile.text()) + readLabel: "/proc/cpuinfo" + onTextReady: content => theme.parseCpuInfo(content) } - FileView { + DeferredFileView { id: systemLoadFile path: "/proc/loadavg" - onLoaded: theme.parseSystemLoad(systemLoadFile.text()) + readLabel: "/proc/loadavg" + onTextReady: content => theme.parseSystemLoad(content) } - FileView { + DeferredFileView { id: kernelReleaseFile path: "/proc/sys/kernel/osrelease" - onLoaded: theme.kernelRelease = String(kernelReleaseFile.text() || "").trim() + readLabel: "/proc/sys/kernel/osrelease" + onTextReady: content => theme.kernelRelease = String(content || "").trim() } - FileView { + DeferredFileView { id: systemMemFile path: "/proc/meminfo" - onLoaded: theme.parseSystemMem(systemMemFile.text()) + readLabel: "/proc/meminfo" + onTextReady: content => theme.parseSystemMem(content) } Process { @@ -2091,14 +2107,15 @@ Item { } } - FileView { + DeferredFileView { id: notificationsStateFile path: theme.notificationsStatePath watchChanges: theme._notifRyokuShellSystem printErrors: false + readLabel: "notification state" onFileChanged: notificationsStateFile.reload() - onLoaded: { - if (theme._notifRyokuShellSystem) theme.parseNotificationsState(notificationsStateFile.text()) + onTextReady: content => { + if (theme._notifRyokuShellSystem) theme.parseNotificationsState(content) } onLoadFailed: { if (theme._notifRyokuShellSystem) theme.notifSilenced = false @@ -3093,10 +3110,9 @@ Item { if (w.battery !== undefined) modBattery = w.battery if (w.layout !== undefined) modLayout = w.layout } - // Bar layout (design section 1): apply the stored document, and on the - // first load without one migrate the retired cache (or write the shipped - // default) exactly once. A normalised incoming equal to the live layout - // is our own persist echo, so skip re-applying it. + // Compare the persisted document itself, not two catalogue-filtered + // views. During startup an empty catalogue must not make a real layout + // look like an empty persist echo. if (q.layout && q.layout.left !== undefined) { var incoming = { version: 1, @@ -3104,7 +3120,8 @@ Item { center: q.layout.center || [], right: q.layout.right || [] } - if (JSON.stringify(_normalizeLayout(incoming)) !== JSON.stringify(barLayout)) { + var stored = _storedLayout ? _cloneLayout(_storedLayout) : null + if (!stored || JSON.stringify(incoming) !== JSON.stringify(stored)) { _commitLayout(incoming, false) _applyLayoutToBars("") } else { @@ -3357,18 +3374,19 @@ Item { // Ryoku writes theme.name immediately after the complete theme directory // swap, before its slower application retint commands and final hooks. - FileView { + DeferredFileView { id: currentThemeNameWatcher path: theme.themeNamePath watchChanges: theme.omarchyCurrentRootResolved printErrors: false - onLoaded: { - theme.setCurrentThemeName(currentThemeNameWatcher.text()) + readLabel: "current theme name" + onTextReady: content => { + theme.setCurrentThemeName(content) theme.reloadCurrentThemeFiles() } onLoadFailed: theme.setCurrentThemeName("") onFileChanged: { - reload() + currentThemeNameWatcher.reload() theme.reloadCurrentThemeFiles() } } @@ -3416,39 +3434,48 @@ Item { onFileChanged: { paletteWatcher.reload(); theme.reloadCurrentThemeFiles() } } - FileView { + DeferredFileView { id: previewWatcher path: theme.previewColorsPath blockLoading: true watchChanges: true printErrors: false - onFileChanged: reload() - onLoaded: { - theme._previewColors = Palette.parseAll(text()) || {}; + readLabel: "wallpaper preview palette" + onFileChanged: previewWatcher.reload() + onTextReady: content => { + theme._previewColors = Palette.parseAll(content) || {}; theme._applyPalette(); } } // A fixed named theme publishes its palette here; retint when it changes. - FileView { + DeferredFileView { id: namedPaletteWatcher path: theme.shellConfigPath blockLoading: true watchChanges: true printErrors: false - onFileChanged: reload() - onLoaded: { theme._namedPalette = Palette.parseNamed(namedPaletteWatcher.text()); theme._applyPalette(); } + readLabel: "shell theme palette" + onFileChanged: namedPaletteWatcher.reload() + onTextReady: content => { + theme._namedPalette = Palette.parseNamed(content) + theme._applyPalette() + } } // The follow-the-wallpaper master gates whether the live palette applies. - FileView { + DeferredFileView { id: followWallpaperWatcher path: theme.themeJsonPath blockLoading: true watchChanges: true printErrors: false - onFileChanged: reload() - onLoaded: { theme._followWallpaper = Palette.parseFollow(followWallpaperWatcher.text()); theme._applyPalette(); } + readLabel: "theme settings" + onFileChanged: followWallpaperWatcher.reload() + onTextReady: content => { + theme._followWallpaper = Palette.parseFollow(content) + theme._applyPalette() + } } function ipcApplyTheme(payload) { diff --git a/ryoku/shell/quickshell/shell/modules/bar/barstyles/qsbar/core/BarCatalog.qml b/ryoku/shell/quickshell/shell/modules/bar/barstyles/qsbar/core/BarCatalog.qml index 872b1cb45c..689b336129 100644 --- a/ryoku/shell/quickshell/shell/modules/bar/barstyles/qsbar/core/BarCatalog.qml +++ b/ryoku/shell/quickshell/shell/modules/bar/barstyles/qsbar/core/BarCatalog.qml @@ -1,7 +1,7 @@ pragma ComponentBehavior: Bound import QtQuick -import Quickshell.Io +import Ryoku.Ui // The single source of truth for the qsbar's built-in widgets: it loads the // shipped widgets.json (the same file the daemon reads) and hands out the id @@ -26,6 +26,21 @@ Item { property var _idOfGid: ({}) property var _gidOfId: ({}) + function _load(content) { + var parsed + try { + parsed = JSON.parse(content || "[]") + } catch (error) { + console.warn("qsbar catalogue: widgets.json could not be parsed:", error) + return + } + if (!Array.isArray(parsed)) { + console.warn("qsbar catalogue: widgets.json is not an array") + return + } + catalog._reindex(parsed) + } + function _reindex(list) { var byId = ({}) var idOfGid = ({}) @@ -46,15 +61,11 @@ Item { function gidOf(id) { return catalog._gidOfId[id] !== undefined ? catalog._gidOfId[id] : "" } function idOf(gid) { return catalog._idOfGid[gid] !== undefined ? catalog._idOfGid[gid] : "" } - FileView { - id: file + DeferredFileView { path: Qt.resolvedUrl("widgets.json") blockLoading: true printErrors: false - onLoaded: { - var parsed = [] - try { parsed = JSON.parse(file.text() || "[]") } catch (e) { parsed = [] } - catalog._reindex(Array.isArray(parsed) ? parsed : []) - } + readLabel: "qsbar widgets.json" + onTextReady: content => catalog._load(content) } } diff --git a/ryoku/shell/quickshell/shell/modules/dock/DockSurface.qml b/ryoku/shell/quickshell/shell/modules/dock/DockSurface.qml index f9c1018f7f..55369a589e 100644 --- a/ryoku/shell/quickshell/shell/modules/dock/DockSurface.qml +++ b/ryoku/shell/quickshell/shell/modules/dock/DockSurface.qml @@ -32,7 +32,8 @@ PanelWindow { // ── sizing ──────────────────────────────────────────────────────────────── readonly property bool autohide: Dock.cfg("autohide", false) - readonly property real edgeGap: 8 + Math.max(0, Math.min(64, Number(Dock.cfg("edgeGap", 0)))) + readonly property real configuredEdgeGap: Math.max(-24, Math.min(64, Number(Dock.cfg("edgeGap", 0)))) + readonly property real edgeGap: Math.max(0, 8 + dock.configuredEdgeGap) readonly property real peek: 3 readonly property real depth: band.baseSize // Inner clearance for a magnified icon to rise into, plus room for the hover @@ -56,11 +57,11 @@ PanelWindow { visible: Dock.cfg("enabled", false) // `screen` is PanelWindow's own property, set per monitor from shell.qml. WlrLayershell.namespace: "ryoku-dock" - WlrLayershell.layer: dock.stageEditing ? WlrLayer.Overlay : WlrLayer.Bottom + WlrLayershell.layer: dock.stageEditing ? WlrLayer.Overlay : (dock.autohide ? WlrLayer.Top : WlrLayer.Bottom) exclusionMode: ExclusionMode.Normal // A pinned (non-autohide) dock reserves its depth; an autohiding one floats // over the desktop and reserves nothing. - exclusiveZone: (!dock.autohide && dock.visible) ? (dock.depth + dock.edgeGap) : 0 + exclusiveZone: (!dock.autohide && dock.visible) ? Math.max(0, dock.depth + dock.edgeGap) : 0 anchors { left: dock.horizontal || dock.edge === "left" diff --git a/ryoku/shell/quickshell/shell/modules/dock/ii/Dock.qml b/ryoku/shell/quickshell/shell/modules/dock/ii/Dock.qml index 963a762c44..536e9d29bc 100755 --- a/ryoku/shell/quickshell/shell/modules/dock/ii/Dock.qml +++ b/ryoku/shell/quickshell/shell/modules/dock/ii/Dock.qml @@ -38,7 +38,7 @@ Scope { const concaveCrossPad = (opts.isFullWidthConcave ?? false) ? Math.max(0, opts.concaveCornerRadius || 0) : 0 const isAttached = opts.isAttachedToEdge ?? false const gapsOut = opts.gapsOut - const edgeGap = Math.max(0, Math.min(64, Number(opts.edgeGap || 0))) + const configuredEdgeGap = Math.max(-24, Math.min(64, Number(opts.edgeGap || 0))) const shadowPad = Math.round(Appearance.sizes.elevationMargin * 1.2) const concaveReserve = isDynamic ? Math.max(0, opts.concaveCornerRadius || 0) : 0 @@ -47,6 +47,9 @@ Scope { const floatingPad = isAttached ? 0 : shadowPad const mainPad = isDynamic ? (concaveReserve * 2) : (isHug ? (shadowPad * 2) : (floatingPad * 2)) const crossPad = isAttached ? ((isHug || isFullWidth) ? shadowPad : 0) : (floatingPad * 2) + // Keep enough shadow envelope in the layer surface while allowing the + // visible tray to move all the way to the screen edge. + const edgeGap = Math.max(-floatingPad, configuredEdgeGap) // Full width is the screen's own edge: the window and the visible tray // both take the whole main axis, and the tray's body is the silhouette @@ -93,8 +96,8 @@ Scope { dockWidth: fullDockW, dockHeight: fullDockH, dockThickness: opts.isVertical ? fullDockW : fullDockH, - unmagnifiedThickness: (opts.isVertical ? baseContentW + (isFullWidth ? 0 : crossPad) : baseContentH + (isFullWidth ? 0 : crossPad)) + edgeGap, - surfaceMargin: floatingPad + edgeGap, + unmagnifiedThickness: Math.max(0, (opts.isVertical ? baseContentW + (isFullWidth ? 0 : crossPad) : baseContentH + (isFullWidth ? 0 : crossPad)) + edgeGap), + surfaceMargin: Math.max(0, floatingPad + edgeGap), // Main-axis room the tray keeps from the window edge for its shadow. mainEdgePad: isDynamic ? 0 : mainPad / 2, backgroundWidth: bgW, @@ -362,7 +365,7 @@ Scope { readonly property real actualWindowWidth: width readonly property real actualWindowHeight: height - exclusiveZone: (dock.pinned && reveal) ? unmagnifiedThickness : 0 + exclusiveZone: (dock.pinned && reveal) ? Math.max(0, unmagnifiedThickness) : 0 WlrLayershell.namespace: "quickshell:dock" // Pinned docks remain part of the desktop. An auto-hidden dock must // own its edge above app windows or neither its reveal strip nor the diff --git a/ryoku/shell/quickshell/shell/modules/launcher/shared/Singletons/Radio.qml b/ryoku/shell/quickshell/shell/modules/launcher/shared/Singletons/Radio.qml index eee565e140..5ea7486481 100644 --- a/ryoku/shell/quickshell/shell/modules/launcher/shared/Singletons/Radio.qml +++ b/ryoku/shell/quickshell/shell/modules/launcher/shared/Singletons/Radio.qml @@ -15,6 +15,8 @@ import "../lib/radio.js" as RadioLib Singleton { id: root + property bool available: false + property bool availabilityKnown: false property bool on: false property string station: "" property string label: "" @@ -45,10 +47,10 @@ Singleton { // an explicit tune-in means the radio has the floor: pause whatever real // player is sounding (never a live wallpaper) instead of instantly losing // to it, the collision watcher only referees music that starts LATER. - // Whoever was sounding at tune-in is grandfathered: if its pause doesn't + // Whoever was sounding at tune-in is tolerated: if its pause doesn't // take (canPause=false, or a stream that shrugs off MPRIS Pause), it still // never parks the radio the user explicitly chose over it. - property var _grandfathered: ({}) + property var _tolerated: ({}) function _quellRivals() { var g = {}; var list = Players.realPlayers(); @@ -60,7 +62,7 @@ Singleton { if (p.canPause) p.pause(); } - _grandfathered = g; + _tolerated = g; } function start(id) { _quellRivals(); _grace.restart(); _run(["start"].concat(id ? [id] : [])); } function stop() { _run(["stop"]); } @@ -94,6 +96,8 @@ Singleton { onStreamFinished: { try { var s = JSON.parse(text); + root.available = s.available === true; + root.availabilityKnown = true; root.on = s.on === true; root.station = s.station || ""; root.label = s.label || ""; @@ -121,7 +125,7 @@ Singleton { // later resume of the same player is fresh music and parks the // radio like anything else. Pruned here, not in the binding, // bindings must not mutate state. - var g = root._grandfathered; + var g = root._tolerated; var changed = false; var list = Players.realPlayers(); var playing = {}; @@ -131,7 +135,7 @@ Singleton { for (var k in g) if (!playing[k]) { delete g[k]; changed = true; } if (changed) - root._grandfathered = g; + root._tolerated = g; } } Component.onCompleted: refresh() @@ -153,9 +157,9 @@ Singleton { // radio's own player must never read as the rival that kills it. if (!RadioLib.countsAsMusic(p.trackTitle)) continue; - // the music the user tuned in over stays grandfathered until it + // the music the user tuned in over stays tolerated until it // actually stops; only fresh (or resumed) music parks the radio. - if (_grandfathered[String(p.dbusName || "")] === true) + if (_tolerated[String(p.dbusName || "")] === true) continue; return true; } diff --git a/ryoku/shell/quickshell/shell/modules/launcher/shared/providers/radio/RadioTuner.qml b/ryoku/shell/quickshell/shell/modules/launcher/shared/providers/radio/RadioTuner.qml index 59b0deaf14..5094d53c09 100644 --- a/ryoku/shell/quickshell/shell/modules/launcher/shared/providers/radio/RadioTuner.qml +++ b/ryoku/shell/quickshell/shell/modules/launcher/shared/providers/radio/RadioTuner.qml @@ -23,6 +23,8 @@ Provider { Connections { target: Radio function onTuningChanged() { Dispatcher.notifyAsync(); } + function onAvailableChanged() { Dispatcher.notifyAsync(); } + function onAvailabilityKnownChanged() { Dispatcher.notifyAsync(); } } function rowFor(r) { @@ -46,8 +48,29 @@ Provider { }; } + function missingPlayerRow() { + return [{ + id: "radio:install-mpv", + title: I18n.tr("Radio is not installed"), + subtitle: I18n.tr("Install mpv to use the radio"), + icon: "", + type: "Radio", + score: -30, + actions: [{ + id: "install", + name: I18n.tr("Install mpv"), + icon: "", + execute: function () { + Spawn.runApp(["kitty", "-e", "gpk", "install", "mpv", "mpv-mpris"], { label: "kitty" }); + } + }] + }]; + } + function query(text) { var t = (text || "").trim(); + if (Radio.availabilityKnown && !Radio.available) + return missingPlayerRow(); // "@stop" / "@off" tunes out, or lets a parked station go, from // anywhere, no list browsing needed; "@resume" picks a parked one up. if (t === "stop" || t === "off") { diff --git a/ryoku/shell/quickshell/shell/modules/sidebar/ControlsConnections.qml b/ryoku/shell/quickshell/shell/modules/sidebar/ControlsConnections.qml index 0ce47efe49..6cc4b359fa 100644 --- a/ryoku/shell/quickshell/shell/modules/sidebar/ControlsConnections.qml +++ b/ryoku/shell/quickshell/shell/modules/sidebar/ControlsConnections.qml @@ -1,7 +1,6 @@ pragma ComponentBehavior: Bound import QtQuick -import Quickshell.Bluetooth import Ryoku.Ui.Singletons import shell.services @@ -17,9 +16,8 @@ Item { readonly property bool showVpn: SidebarState.elementVisible("vpn") signal openPage(string page) - readonly property var adapter: root.showBluetooth ? Bluetooth.defaultAdapter : null - readonly property var connectedDevices: root.showBluetooth && Bluetooth.devices && Bluetooth.devices.values - ? Bluetooth.devices.values.filter(device => device && device.connected) : [] + readonly property var connectedDevices: root.showBluetooth + ? BluetoothPower.devices.filter(device => device && device.connected) : [] readonly property var activeAccessPoint: { if (!root.showWifi) return null; @@ -46,8 +44,8 @@ Item { : I18n.tr("%1 · %2%").arg(Network.activeSsid).arg(strength); } readonly property string bluetoothDetail: BluetoothPower.blocked ? I18n.tr("Blocked") - : !root.adapter ? I18n.tr("No adapter") - : !root.adapter.enabled ? I18n.tr("Radio off") + : !BluetoothPower.available ? I18n.tr("No adapter") + : !BluetoothPower.enabled ? I18n.tr("Radio off") : root.connectedDevices.length === 1 ? BtLink.label(root.connectedDevices[0]) : root.connectedDevices.length > 1 ? I18n.tr("%1 connected").arg(root.connectedDevices.length) : I18n.tr("No devices connected") @@ -103,11 +101,11 @@ Item { active: root.active label: I18n.tr("Bluetooth") detail: root.bluetoothDetail - glyph: root.adapter && root.adapter.enabled ? "bluetooth" : "bluetooth_disabled" + glyph: BluetoothPower.enabled ? "bluetooth" : "bluetooth_disabled" kind: "bluetooth" connected: root.connectedDevices.length > 0 - radioOn: !!(root.adapter && root.adapter.enabled) - available: root.adapter !== null + radioOn: BluetoothPower.enabled + available: BluetoothPower.available connectedCount: root.connectedDevices.length onSelected: root.openPage("bluetooth") onToggleRequested: Toggles.toggleBt() diff --git a/ryoku/shell/quickshell/shell/modules/sidebar/ControlsHero.qml b/ryoku/shell/quickshell/shell/modules/sidebar/ControlsHero.qml index 4806c65042..2de274f059 100644 --- a/ryoku/shell/quickshell/shell/modules/sidebar/ControlsHero.qml +++ b/ryoku/shell/quickshell/shell/modules/sidebar/ControlsHero.qml @@ -4,6 +4,7 @@ import QtQuick import QtQuick.Controls as QQC import Quickshell import Ryoku.Blobs +import Ryoku.Ui import Ryoku.Ui.Singletons import shell.services @@ -161,8 +162,16 @@ Item { radius: width / 2 color: Tokens.role("primaryContainer", Tokens.tint10) + AvatarImage { + id: avatarImage + anchors.fill: parent + userName: root.monitor.userName + home: Quickshell.env("HOME") || "" + } + Text { anchors.fill: parent + visible: !avatarImage.hasImage text: root.monitor.userName.length > 0 ? root.monitor.userName.slice(0, 1).toUpperCase() : I18n.tr("—") diff --git a/ryoku/shell/quickshell/shell/modules/sidebar/cards/SystemBluetoothPage.qml b/ryoku/shell/quickshell/shell/modules/sidebar/cards/SystemBluetoothPage.qml index 0e73d1e390..c449752b5a 100644 --- a/ryoku/shell/quickshell/shell/modules/sidebar/cards/SystemBluetoothPage.qml +++ b/ryoku/shell/quickshell/shell/modules/sidebar/cards/SystemBluetoothPage.qml @@ -1,8 +1,6 @@ pragma ComponentBehavior: Bound import QtQuick -import Quickshell -import Quickshell.Bluetooth import Quickshell.Io import shell.services import Ryoku.Ui.Singletons @@ -15,18 +13,18 @@ Item { required property bool active signal backRequested() - readonly property var adapter: Bluetooth.defaultAdapter + readonly property var adapter: BluetoothPower.adapter readonly property bool adapterAvailable: BluetoothPower.available - readonly property bool adapterEnabled: root.adapter !== null && root.adapter.enabled - readonly property bool scanning: root.adapterEnabled && root.adapter.discovering + readonly property bool adapterEnabled: BluetoothPower.enabled + readonly property bool scanning: root.adapter !== null && root.adapterEnabled && root.adapter.discovering property var discoveryAdapter: null property string busyAddress: "" property string errorText: "" readonly property bool busy: linkProcess.running readonly property bool motionAllowed: root.active && !Tokens.reduceMotion && !Motion.reduce - readonly property var devices: !root.adapterEnabled || !Bluetooth.devices - ? [] : (Bluetooth.devices.values || []).filter(device => !!device) + readonly property var devices: root.adapterEnabled + ? BluetoothPower.devices.filter(device => !!device) : [] readonly property var connectedDevices: root.devices.filter(device => device && device.connected) readonly property var pairedDevices: root.devices.filter(device => device && !device.connected && (device.paired || device.bonded)) readonly property var discoveredDevices: root.devices.filter(device => device && !device.connected && !device.paired && !device.bonded) diff --git a/ryoku/shell/quickshell/shell/services/BluetoothPower.qml b/ryoku/shell/quickshell/shell/services/BluetoothPower.qml index 74ca995f84..2d3f96ba11 100644 --- a/ryoku/shell/quickshell/shell/services/BluetoothPower.qml +++ b/ryoku/shell/quickshell/shell/services/BluetoothPower.qml @@ -8,10 +8,23 @@ import Quickshell.Io Singleton { id: root - readonly property bool enabled: Bluetooth.defaultAdapter?.enabled ?? false - readonly property bool available: Bluetooth.defaultAdapter !== null || root.blocked + readonly property var adapter: { + if (!root.cliAvailable) + return null; + const adapters = Bluetooth.adapters.values; + if (adapters.length === 0) + return null; + const preferred = Bluetooth.defaultAdapter; + return preferred && adapters.indexOf(preferred) >= 0 ? preferred : adapters[0]; + } + readonly property var devices: root.adapter !== null && Bluetooth.devices + ? Bluetooth.devices.values : [] + readonly property bool enabled: root.adapter !== null ? root.adapter.enabled : root.cliEnabled + readonly property bool available: root.adapter !== null || root.cliAvailable || root.blocked readonly property bool blocked: root.softBlocked || root.hardBlocked - || Bluetooth.defaultAdapter?.state === BluetoothAdapterState.Blocked + || root.adapter?.state === BluetoothAdapterState.Blocked + property bool cliAvailable: false + property bool cliEnabled: false property bool softBlocked: false property bool hardBlocked: false @@ -23,11 +36,53 @@ Singleton { setEnabled(!enabled); } - Connections { - target: Bluetooth - function onDefaultAdapterChanged(): void { - rfkillState.running = true; + Component.onCompleted: adapterState.running = true + + onAdapterChanged: { + rfkillState.running = true; + adapterRefresh.restart(); + } + + Timer { + id: adapterRefresh + interval: 100 + onTriggered: { + adapterState.running = false; + adapterState.running = true; + } + } + + Process { + id: adapterState + command: ["bluetoothctl", "--timeout", "2", "show"] + stdout: StdioCollector { + onStreamFinished: { + root.cliAvailable = text.indexOf("Controller") >= 0 + && text.indexOf("No default controller") < 0; + root.cliEnabled = /Powered:[^\\n]*yes/i.test(text); + } + } + } + + Process { + id: adapterMonitor + command: [ + "dbus-monitor", "--system", + "type='signal',sender='org.bluez',interface='org.freedesktop.DBus.ObjectManager'", + "type='signal',sender='org.bluez',interface='org.freedesktop.DBus.Properties',member='PropertiesChanged',arg0='org.bluez.Adapter1'", + "type='signal',sender='org.freedesktop.DBus',interface='org.freedesktop.DBus',member='NameOwnerChanged',arg0='org.bluez'" + ] + running: true + stdout: SplitParser { + onRead: adapterRefresh.restart() } + onExited: adapterMonitorRestart.restart() + } + + Timer { + id: adapterMonitorRestart + interval: 1000 + onTriggered: if (!adapterMonitor.running) adapterMonitor.running = true } Process { diff --git a/ryoku/shell/quickshell/shell/shell.qml b/ryoku/shell/quickshell/shell/shell.qml index 60a46d9438..e67bb4214d 100644 --- a/ryoku/shell/quickshell/shell/shell.qml +++ b/ryoku/shell/quickshell/shell/shell.qml @@ -243,11 +243,14 @@ ShellRoot { } } - // Only Above windows needs a separate surface; the plain look stays on the desktop. + // Build the separate window only for the explicit Above windows + // mode. Inferring it from the inline hosts races their bindings when + // the ordinary toggle enables the visualizer. LazyLoader { id: vizLoader activeAsync: VizCfg.Config.enabled && Perf.visualizerResident - && !desktop.hostsVisualizer && !desktop.plainVisualizer + && perScreen.st && perScreen.st.visualizerOverlay + && !desktop.hostsVisualizer Visualizer { id: perScreenViz screen: perScreen.modelData diff --git a/ryoku/shell/quickshell/stage/modules/ii/editMode/EditDockAppearancePage.qml b/ryoku/shell/quickshell/stage/modules/ii/editMode/EditDockAppearancePage.qml index 044912b764..76e24a0336 100644 --- a/ryoku/shell/quickshell/stage/modules/ii/editMode/EditDockAppearancePage.qml +++ b/ryoku/shell/quickshell/stage/modules/ii/editMode/EditDockAppearancePage.qml @@ -115,9 +115,9 @@ StyledFlickable { subtitle: Translation.tr("Extra space from the screen edge") trailingKind: "stepper" valueText: Math.round(Number(root.cfg("edgeGap", 0))) + " px" - stepDownEnabled: Number(root.cfg("edgeGap", 0)) > 0 + stepDownEnabled: Number(root.cfg("edgeGap", 0)) > -24 stepUpEnabled: Number(root.cfg("edgeGap", 0)) < 64 - onStepDown: root.setCfg("edgeGap", Math.max(0, Number(root.cfg("edgeGap", 0)) - 1)) + onStepDown: root.setCfg("edgeGap", Math.max(-24, Number(root.cfg("edgeGap", 0)) - 1)) onStepUp: root.setCfg("edgeGap", Math.min(64, Number(root.cfg("edgeGap", 0)) + 1)) } diff --git a/ryoku/shell/ryogami/daemon/paper_apply.go b/ryoku/shell/ryogami/daemon/paper_apply.go index b613746fe5..3bb4b4f173 100644 --- a/ryoku/shell/ryogami/daemon/paper_apply.go +++ b/ryoku/shell/ryogami/daemon/paper_apply.go @@ -156,7 +156,7 @@ func (d *daemon) weStill(weID, itemDir string) string { } } if project, err := resolveProject(itemDir); err == nil { - if p := findPreview(itemDir, project.document); p != "" { + if p := findPreview(itemDir, project); p != "" { return p } } diff --git a/ryoku/shell/ryogami/daemon/preview.go b/ryoku/shell/ryogami/daemon/preview.go index b1cd186923..e8a0f74b8e 100644 --- a/ryoku/shell/ryogami/daemon/preview.go +++ b/ryoku/shell/ryogami/daemon/preview.go @@ -156,7 +156,14 @@ func writeWallPreviewPalette(colors map[string]string) { return } } - _ = os.WriteFile(filepath.Join(runtimeDir, "ryoku-wall-preview.json"), data, 0o600) + path := filepath.Join(runtimeDir, "ryoku-wall-preview.json") + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return + } + if err := os.Rename(tmp, path); err != nil { + _ = os.Remove(tmp) + } } // repaintCommitted repaints the committed wallpaper on the given outputs, diff --git a/ryoku/shell/ryogami/daemon/preview_test.go b/ryoku/shell/ryogami/daemon/preview_test.go index 9f7222a3c4..7f2cbb03b4 100644 --- a/ryoku/shell/ryogami/daemon/preview_test.go +++ b/ryoku/shell/ryogami/daemon/preview_test.go @@ -415,3 +415,33 @@ func TestClearedPreviewDropsLatePalette(t *testing.T) { t.Fatalf("late palette repainted after cancel: %s, %v", b, err) } } + +func TestPreviewPalettePublishesByAtomicReplacement(t *testing.T) { + runtimeDir := t.TempDir() + t.Setenv("XDG_RUNTIME_DIR", runtimeDir) + path := filepath.Join(runtimeDir, "ryoku-wall-preview.json") + if err := os.WriteFile(path, []byte(`{"primary":"#111111"}`), 0o600); err != nil { + t.Fatal(err) + } + before, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + + writeWallPreviewPalette(map[string]string{"primary": "#abcdef"}) + + after, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if os.SameFile(before, after) { + t.Fatal("preview palette was rewritten in place") + } + b, err := os.ReadFile(path) + if err != nil || !strings.Contains(string(b), "#abcdef") { + t.Fatalf("published palette = %s, %v", b, err) + } + if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) { + t.Fatalf("temporary palette remains: %v", err) + } +} diff --git a/ryoku/shell/ryogami/daemon/vendor/ryoku-wm/state.go b/ryoku/shell/ryogami/daemon/vendor/ryoku-wm/state.go index 87f0e49a5d..dbd350123f 100644 --- a/ryoku/shell/ryogami/daemon/vendor/ryoku-wm/state.go +++ b/ryoku/shell/ryogami/daemon/vendor/ryoku-wm/state.go @@ -67,7 +67,10 @@ type Workspace struct { ID string `json:"id"` Name string `json:"name"` Output string `json:"output,omitempty"` - Active bool `json:"active,omitempty"` + // Index is the one-based presentation rank inside Output. Zero means the + // provider cannot report an order, so consumers preserve frame order. + Index int `json:"index,omitempty"` + Active bool `json:"active,omitempty"` // Windows is a count, which is all the wallpaper occupancy gate needs. Windows int `json:"windows"` Fullscreen bool `json:"fullscreen,omitempty"` diff --git a/ryoku/shell/ryogami/daemon/workshop_library.go b/ryoku/shell/ryogami/daemon/workshop_library.go index 70ad971f14..c7b68002a1 100644 --- a/ryoku/shell/ryogami/daemon/workshop_library.go +++ b/ryoku/shell/ryogami/daemon/workshop_library.go @@ -6,6 +6,8 @@ import ( "encoding/json" "errors" "fmt" + "io/fs" + "math" "os" "os/exec" "path/filepath" @@ -408,31 +410,135 @@ func projectTags(doc map[string]interface{}) string { return strings.Join(tags, ", ") } -func findPreview(itemDir string, doc map[string]interface{}) string { - if file, ok := doc["preview"].(string); ok { - if joined, ok := safeItemJoin(itemDir, file); ok && fileExists(joined) { - return joined - } +func previewSource(path string) bool { + ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(path), ".")) + return imageExts[ext] || videoExts[ext] +} + +func declaredPreview(dir string, doc map[string]interface{}) string { + file, _ := doc["preview"].(string) + joined, ok := safeItemJoin(dir, file) + if !ok || !fileExists(joined) || !previewSource(joined) { + return "" } - entries, err := os.ReadDir(itemDir) + return joined +} + +func namedPreview(dir string) string { + entries, err := os.ReadDir(dir) if err != nil { return "" } - var names []string - for _, e := range entries { - if strings.HasPrefix(strings.ToLower(e.Name()), "preview.") { - names = append(names, e.Name()) + for _, entry := range entries { + name := entry.Name() + if strings.HasPrefix(strings.ToLower(name), "preview.") { + if joined, ok := safeItemJoin(dir, name); ok && fileExists(joined) && previewSource(joined) { + return joined + } } } - sort.Strings(names) - for _, name := range names { - if joined, ok := safeItemJoin(itemDir, name); ok && fileExists(joined) { - return joined + return "" +} + +func fallbackPreview(dir string) string { + var found string + _ = filepath.WalkDir(dir, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + if path == dir { + return err + } + return nil + } + if entry.IsDir() { + return nil + } + switch strings.ToLower(filepath.Ext(entry.Name())) { + case ".jpg", ".jpeg", ".png": + found = path + return fs.SkipAll + default: + return nil + } + }) + return found +} + +func findPreview(itemDir string, project *weProject) string { + dirs := []string{itemDir} + docs := []map[string]interface{}{nil} + if doc, err := readProject(itemDir); err == nil { + docs[0] = doc + } + if project != nil && project.source != itemDir { + dirs = append(dirs, project.source) + docs = append(docs, project.document) + } else if project != nil { + docs[0] = project.document + } + for i, dir := range dirs { + if preview := declaredPreview(dir, docs[i]); preview != "" { + return preview + } + } + for _, dir := range dirs { + if preview := namedPreview(dir); preview != "" { + return preview + } + } + for _, dir := range dirs { + if preview := fallbackPreview(dir); preview != "" { + return preview } } return "" } +func projectSchemeColor(doc map[string]interface{}) (int, int, bool) { + general, _ := doc["general"].(map[string]interface{}) + properties, _ := general["properties"].(map[string]interface{}) + raw := properties["schemecolor"] + if object, ok := raw.(map[string]interface{}); ok { + raw = object["value"] + } + value, _ := raw.(string) + parts := strings.Fields(value) + if len(parts) < 3 { + return 0, 0, false + } + r, errR := strconv.ParseFloat(parts[0], 64) + g, errG := strconv.ParseFloat(parts[1], 64) + b, errB := strconv.ParseFloat(parts[2], 64) + if errR != nil || errG != nil || errB != nil { + return 0, 0, false + } + r = math.Max(0, math.Min(1, r)) + g = math.Max(0, math.Min(1, g)) + b = math.Max(0, math.Min(1, b)) + high := math.Max(r, math.Max(g, b)) + low := math.Min(r, math.Min(g, b)) + delta := high - low + lightness := (high + low) / 2 + var saturation float64 + if delta > 0 { + saturation = delta / (1 - math.Abs(2*lightness-1)) + } + var hue float64 + switch { + case delta == 0: + case high == r: + hue = 60 * math.Mod((g-b)/delta, 6) + case high == g: + hue = 60 * ((b-r)/delta + 2) + default: + hue = 60 * ((r-g)/delta + 4) + } + if hue < 0 { + hue += 360 + } + sat := int(math.Round(saturation * 100)) + return int(hueBucket(uint16(math.Round(hue))%360, uint16(sat))), sat, true +} + func (w *workshopLib) downloadedIDs() []string { dir := w.workshopDir() entries, err := os.ReadDir(dir) @@ -495,7 +601,7 @@ func (w *workshopLib) scanEntries(cacheDir string, prior map[string]Entry, onIte if err != nil { continue } - preview := findPreview(itemDir, project.document) + preview := findPreview(itemDir, project) e := Entry{ Key: key, Name: firstNonEmpty(readProjectTitle(itemDir), id), @@ -507,6 +613,10 @@ func (w *workshopLib) scanEntries(cacheDir string, prior map[string]Entry, onIte Path: itemDir, Mtime: mtime, } + if hue, sat, ok := projectSchemeColor(project.document); ok { + e.Hue = hue + e.Sat = sat + } if p, ok := prior[key]; ok { e.Favourite = p.Favourite e.ApplyCount = p.ApplyCount @@ -543,11 +653,20 @@ func generateWeThumb(preview, thumb, thumbSm string) error { if err := os.MkdirAll(filepath.Dir(thumb), 0o755); err != nil { return err } + source := preview + if needsFirstFrame(preview) { + frame, err := extractFirstFrame(preview) + if err != nil { + return fmt.Errorf("first frame: %w", err) + } + defer os.Remove(frame) + source = frame + } tmp := tmpPath(thumb) ctx, cancel := context.WithTimeout(context.Background(), cmdTimeout) defer cancel() cmd := exec.CommandContext(ctx, "magick", - fmt.Sprintf("%s[0]", preview), + fmt.Sprintf("%s[0]", source), "-resize", fmt.Sprintf("%dx%d^", thumbW, thumbH), "-gravity", "center", "-extent", fmt.Sprintf("%dx%d", thumbW, thumbH), diff --git a/ryoku/shell/ryogami/daemon/workshop_library_test.go b/ryoku/shell/ryogami/daemon/workshop_library_test.go index 017b0cdfdb..6332b2d1ec 100644 --- a/ryoku/shell/ryogami/daemon/workshop_library_test.go +++ b/ryoku/shell/ryogami/daemon/workshop_library_test.go @@ -2,7 +2,11 @@ package main import ( "encoding/json" + "image" + "image/color" + "image/gif" "os" + "os/exec" "path/filepath" "testing" "time" @@ -264,6 +268,99 @@ func TestSafeItemJoin(t *testing.T) { } } +func TestFindPreviewUsesDecodableFallback(t *testing.T) { + item := t.TempDir() + writeWEProject(t, item, map[string]interface{}{ + "type": "scene", + "title": "Fallback scene", + "preview": "preview.tex", + }) + touch(t, filepath.Join(item, "preview.tex")) + if err := os.MkdirAll(filepath.Join(item, "materials"), 0o755); err != nil { + t.Fatal(err) + } + fallback := filepath.Join(item, "materials", "cover.jpg") + touch(t, fallback) + + project, err := resolveProject(item) + if err != nil { + t.Fatal(err) + } + if got := findPreview(item, project); got != fallback { + t.Fatalf("preview = %q, want fallback %q", got, fallback) + } +} + +func TestFindPreviewKeepsAnimatedAndVideoSources(t *testing.T) { + for _, name := range []string{"preview.gif", "preview.webm"} { + t.Run(name, func(t *testing.T) { + item := t.TempDir() + writeWEProject(t, item, map[string]interface{}{"type": "scene", "preview": name}) + source := filepath.Join(item, name) + touch(t, source) + project, err := resolveProject(item) + if err != nil { + t.Fatal(err) + } + if got := findPreview(item, project); got != source { + t.Fatalf("preview = %q, want %q", got, source) + } + }) + } +} + +func TestProjectSchemeColorProvidesFallbackTint(t *testing.T) { + doc := map[string]interface{}{ + "general": map[string]interface{}{ + "properties": map[string]interface{}{ + "schemecolor": map[string]interface{}{"value": "0.1 0.8 0.2"}, + }, + }, + } + hue, sat, ok := projectSchemeColor(doc) + if !ok || hue < 3 || hue > 5 || sat < 70 { + t.Fatalf("scheme tint = (%d, %d, %v), want saturated green", hue, sat, ok) + } +} + +func TestGenerateWeThumbReadsGIFFrame(t *testing.T) { + if !haveMagick() { + t.Skip("magick not on PATH") + } + if _, err := exec.LookPath("ffmpeg"); err != nil { + t.Skip("ffmpeg not on PATH") + } + root := t.TempDir() + source := filepath.Join(root, "preview.gif") + file, err := os.Create(source) + if err != nil { + t.Fatal(err) + } + palette := color.Palette{color.RGBA{R: 255, A: 255}, color.RGBA{G: 255, A: 255}} + first := image.NewPaletted(image.Rect(0, 0, 32, 18), palette) + second := image.NewPaletted(image.Rect(0, 0, 32, 18), palette) + for i := range second.Pix { + second.Pix[i] = 1 + } + if err := gif.EncodeAll(file, &gif.GIF{Image: []*image.Paletted{first, second}, Delay: []int{2, 2}}); err != nil { + file.Close() + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } + thumb := filepath.Join(root, "cache", "thumb.webp") + small := filepath.Join(root, "cache-sm", "thumb.webp") + if err := generateWeThumb(source, thumb, small); err != nil { + t.Fatal(err) + } + for _, path := range []string{thumb, small} { + if info, err := os.Stat(path); err != nil || info.Size() == 0 { + t.Fatalf("thumbnail %q missing or empty: %v", path, err) + } + } +} + func TestValidWeID(t *testing.T) { for _, id := range []string{"123", "3808523371"} { if !validWeID(id) { diff --git a/ryoku/shell/ryogami/picker/qml/Picker.qml b/ryoku/shell/ryogami/picker/qml/Picker.qml index af2ad436f0..6132264b2d 100644 --- a/ryoku/shell/ryogami/picker/qml/Picker.qml +++ b/ryoku/shell/ryogami/picker/qml/Picker.qml @@ -164,6 +164,7 @@ PanelWindow { onBackgroundClicked: () => pickerState.goBack() } + WorkshopCardChrome { anchors.fill: parent; state: pickerState } CardChrome { anchors.fill: parent; state: pickerState } EmptyState { anchors.fill: parent; state: pickerState } diff --git a/ryoku/shell/ryogami/picker/qml/chrome/CardChrome.qml b/ryoku/shell/ryogami/picker/qml/chrome/CardChrome.qml index 5770d1ebc0..2d4ac69e42 100644 --- a/ryoku/shell/ryogami/picker/qml/chrome/CardChrome.qml +++ b/ryoku/shell/ryogami/picker/qml/chrome/CardChrome.qml @@ -108,6 +108,7 @@ Item { } Rectangle { + visible: chrome.state.collection !== "workshop" anchors.left: parent.left anchors.right: parent.right anchors.bottom: parent.bottom diff --git a/ryoku/shell/ryogami/picker/qml/chrome/WorkshopCardChrome.qml b/ryoku/shell/ryogami/picker/qml/chrome/WorkshopCardChrome.qml new file mode 100644 index 0000000000..ae5c047a4b --- /dev/null +++ b/ryoku/shell/ryogami/picker/qml/chrome/WorkshopCardChrome.qml @@ -0,0 +1,103 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Ryoku.Ui.Singletons + +Item { + id: root + + required property PickerState state + readonly property CardField field: state.field + readonly property bool active: state.collection === "workshop" && !!field && !!state.view + property int modelRevision: 0 + + function entryAt(row, revision) { + return root.state.view.get(row) + } + + function rectAt(row, currentRect, stageRect, scrollPosition, fieldWidth, fieldHeight) { + return root.field.rectOf(row) + } + + clip: true + visible: active + + Connections { + target: root.state.view + function onModelReset() { root.modelRevision++ } + function onDataChanged() { root.modelRevision++ } + function onLayoutChanged() { root.modelRevision++ } + function onCountChanged() { root.modelRevision++ } + function onCollectionChanged() { root.modelRevision++ } + } + + Repeater { + model: root.active ? root.state.view.count : 0 + + delegate: Item { + id: cardLabel + required property int index + readonly property var entry: root.entryAt(cardLabel.index, root.modelRevision) + readonly property rect cardRect: root.rectAt(cardLabel.index, + root.field.currentRect, root.field.stageRect, root.field.scrollPosition, + root.field.width, root.field.height) + readonly property bool missingPreview: !cardLabel.entry.thumb + + x: cardRect.x + y: cardRect.y + width: cardRect.width + height: cardRect.height + visible: root.active && width > 4 && height > 4 + && x + width > 0 && y + height > 0 && x < root.width && y < root.height + clip: true + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: Math.min(parent.height, titleText.implicitHeight + 12 * Theme.scale) + visible: parent.width > 36 * Theme.scale && parent.height > 22 * Theme.scale + color: Theme.withAlpha(Theme.background, 0.68) + + Text { + id: titleText + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: 10 * Theme.scale + anchors.rightMargin: 10 * Theme.scale + text: cardLabel.entry.name || cardLabel.entry.key || "" + elide: Text.ElideRight + font.family: Theme.sans + font.weight: Font.DemiBold + font.pixelSize: Theme.fontBody + color: Theme.surfaceText + renderType: Text.NativeRendering + } + } + + Rectangle { + anchors.centerIn: parent + visible: cardLabel.missingPreview + && parent.width > 80 * Theme.scale && parent.height > 52 * Theme.scale + radius: Theme.radius + color: Theme.withAlpha(Theme.background, 0.58) + border.width: 1 + border.color: Theme.withAlpha(Theme.outline, 0.38) + implicitWidth: noPreviewText.implicitWidth + 14 * Theme.scale + implicitHeight: noPreviewText.implicitHeight + 8 * Theme.scale + + Text { + id: noPreviewText + anchors.centerIn: parent + text: I18n.tr("No preview") + font.family: Theme.sans + font.weight: Font.Medium + font.pixelSize: Theme.fontFine + color: Theme.withAlpha(Theme.surfaceText, 0.86) + renderType: Text.NativeRendering + } + } + } + } +} diff --git a/ryoku/shell/scripts/ryoku-cmd-google-lens b/ryoku/shell/scripts/ryoku-cmd-google-lens index 586f661c32..b168549cc8 100755 --- a/ryoku/shell/scripts/ryoku-cmd-google-lens +++ b/ryoku/shell/scripts/ryoku-cmd-google-lens @@ -1,46 +1,143 @@ #!/usr/bin/env bash -# grab a region, dump it to uguu.se, hand the URL to Google Lens. grim+slurp -# pick, curl+jq do the upload + read back the URL, xdg-open opens the search. -# --file IMAGE skips the grab. set -euo pipefail app="Google Lens" -need() { command -v "$1" >/dev/null 2>&1 || { notify-send -u normal "$app" "$1 is not installed"; exit 1; }; } - input_file="" -if [[ ${1:-} == "--file" ]]; then - [[ -n ${2:-} ]] || { notify-send -u critical "$app" "Missing image path"; exit 2; } - input_file="$2"; shift 2 -fi -(( $# == 0 )) || { notify-send -u critical "$app" "Usage: ryoku-cmd-google-lens [--file IMAGE]"; exit 2; } +remove_input=false + +notify_failure() { + notify-send -u critical "$app" "$1" 2>/dev/null || true +} -for c in curl jq notify-send xdg-open; do need "$c"; done +while (( $# > 0 )); do + case "$1" in + --file) + [[ -n ${2:-} ]] || { notify_failure "Missing image path"; exit 2; } + input_file="$2" + shift 2 + ;; + --remove-input) + remove_input=true + shift + ;; + *) + notify_failure "Usage: ryoku-cmd-google-lens [--file IMAGE] [--remove-input]" + exit 2 + ;; + esac +done + +runtime="${XDG_RUNTIME_DIR:-}" +if [[ -z $runtime || ! -d $runtime || ! -w $runtime || ! -O $runtime ]]; then + notify_failure "Private temporary storage is unavailable" + exit 1 +fi -tmp="$input_file" -if [[ -z $tmp ]]; then - for c in grim slurp; do need "$c"; done - tmp="$(mktemp "${XDG_RUNTIME_DIR:-/tmp}/ryoku-google-lens.XXXXXX.png")" - trap 'rm -f "$tmp"' EXIT +umask 077 +if [[ -z $input_file ]]; then + for command in grim slurp; do + command -v "$command" >/dev/null 2>&1 || { + notify_failure "$command is not installed" + exit 1 + } + done + input_file=$(mktemp --tmpdir="$runtime" ryoku-google-lens-XXXXXXXX.png) + remove_input=true + geometry=$(slurp 2>/dev/null) || { + rm -f -- "$input_file" + exit 130 + } + [[ -n $geometry ]] || { + rm -f -- "$input_file" + exit 0 + } + ryoku-cmd-grim -g "$geometry" "$input_file" fi -if [[ -n $input_file ]]; then - [[ -r $tmp ]] || { notify-send -u critical "$app" "Cannot read image at $tmp"; exit 1; } +[[ -r $input_file ]] || { + notify_failure "Could not read the captured image" + exit 1 +} +crop="" +html="" +cleanup() { + [[ -z $crop ]] || rm -f -- "$crop" + [[ -z $html ]] || rm -f -- "$html" + if $remove_input; then + rm -f -- "$input_file" + fi +} +trap cleanup EXIT HUP INT TERM + +crop=$(mktemp --tmpdir="$runtime" ryoshot-lens-XXXXXXXX.jpg) +html=$(mktemp --tmpdir="$runtime" ryoshot-lens-XXXXXXXX.html) + +if command -v magick >/dev/null 2>&1; then + magick "$input_file" -resize '1000x1000>' -strip -quality 85 "$crop" 2>/dev/null || + cp -- "$input_file" "$crop" else - # slurp nonzero = Esc/cancel. bail quietly. - geometry="$(slurp 2>/dev/null)" || exit 130 - [[ -n $geometry ]] || exit 0 - ryoku-cmd-grim -g "$geometry" "$tmp" + cp -- "$input_file" "$crop" +fi + +if ! b64=$(base64 -w0 "$crop" 2>/dev/null); then + b64=$(base64 "$crop" 2>/dev/null | tr -d '\n') || { + notify_failure "Could not prepare the captured image" + exit 1 + } +fi + +cat >"$html" <

Searching with Google Lens…

+EOF + +url="file://$html" + +launch_command() { + local command_string="$1" + local -a argv=() + read -r -a argv <<<"$command_string" + [[ ${#argv[@]} -gt 0 ]] || return 1 + command -v "${argv[0]}" >/dev/null 2>&1 || return 1 + "${argv[@]}" "$url" >/dev/null 2>&1 & +} + +config_home="${XDG_CONFIG_HOME:-$HOME/.config}" +configured="" +if command -v jq >/dev/null 2>&1; then + configured=$(jq -r '.desktop.apps.browser // empty' \ + "$config_home/ryoku/desktop.json" 2>/dev/null || true) +fi + +opened=false +if [[ -n $configured ]] && launch_command "$configured"; then + opened=true fi -notify-send -u low "$app" "Uploading region and opening Google Lens" +if ! $opened && command -v xdg-settings >/dev/null 2>&1 && + command -v gtk-launch >/dev/null 2>&1; then + desktop=$(xdg-settings get default-web-browser 2>/dev/null || true) + if [[ $desktop == *.desktop ]] && + gtk-launch "$desktop" "$url" >/dev/null 2>&1; then + opened=true + fi +fi -if ! response="$(curl -fsS --connect-timeout 10 --max-time 45 -F "files[]=@$tmp" https://uguu.se/upload)"; then - notify-send -u critical "$app" "Image upload failed" - exit 1 +if ! $opened && [[ -n ${BROWSER:-} ]] && launch_command "${BROWSER%%:*}"; then + opened=true fi -remote_url="$(printf '%s' "$response" | jq -r '.files[0].url // .files[0].src // empty')" -[[ -n $remote_url ]] || { notify-send -u critical "$app" "Upload response had no image URL"; exit 1; } +if ! $opened; then + for candidate in firefox chromium zen zen-browser google-chrome; do + if launch_command "$candidate"; then + opened=true + break + fi + done +fi + +if ! $opened; then + notify_failure "No web browser is available. Pick one in Settings > Default Apps." + exit 1 +fi -encoded_url="$(jq -nr --arg url "$remote_url" '$url|@uri')" -xdg-open "https://www.google.com/searchbyimage?image_url=$encoded_url" >/dev/null 2>&1 +sleep 15 diff --git a/ryoku/shell/scripts/ryoku-cmd-radio b/ryoku/shell/scripts/ryoku-cmd-radio index 198aa8d627..f400513e8f 100755 --- a/ryoku/shell/scripts/ryoku-cmd-radio +++ b/ryoku/shell/scripts/ryoku-cmd-radio @@ -98,7 +98,8 @@ cmd_stations() { } cmd_status() { - local on=false station="" label="" live=false fellback=false art="" pgid aside=null + local on=false station="" label="" live=false fellback=false art="" pgid aside=null available=false + command -v "$mpv_bin" >/dev/null 2>&1 && available=true if [ -f "$state_file" ]; then pgid="$(state_get pgid)" if group_alive "$pgid" || { [ "${pgid:-0}" = "0" ] && state_fresh; }; then @@ -112,9 +113,9 @@ cmd_status() { fi fi [ -s "$aside_file" ] && aside="$(cat "$aside_file")" - jq -cn --argjson on "$on" --arg station "$station" --arg label "$label" \ + jq -cn --argjson available "$available" --argjson on "$on" --arg station "$station" --arg label "$label" \ --argjson live "$live" --argjson fellBack "$fellback" --arg art "$art" --argjson aside "$aside" \ - '{on:$on,station:$station,label:$label,live:$live,fellBack:$fellBack,art:$art,aside:$aside}' + '{available:$available,on:$on,station:$station,label:$label,live:$live,fellBack:$fellBack,art:$art,aside:$aside}' } # shellcheck disable=SC2120 # --aside arrives through the with_lock indirection diff --git a/ryoku/shell/scripts/ryoku-reload-cover b/ryoku/shell/scripts/ryoku-reload-cover index 2edf7874fb..e560af6f4f 100755 --- a/ryoku/shell/scripts/ryoku-reload-cover +++ b/ryoku/shell/scripts/ryoku-reload-cover @@ -60,19 +60,27 @@ stop_owned() { owns_pid "$pid" "$start" || break sleep 0.05 done + if owns_pid "$pid" "$start"; then + kill -KILL "$pid" 2>/dev/null || true + fi fi state_matches "$token" "$pid" "$start" && rm -f "$state" } reap_after_exit() { local token="$1" pid="$2" start="$3" + local ticks="${RYOKU_RELOAD_COVER_WATCHDOG_TICKS:-600}" + [[ $ticks =~ ^[1-9][0-9]*$ ]] || ticks=600 ( - for _ in $(seq 1 600); do + for _ in $(seq 1 "$ticks"); do owns_pid "$pid" "$start" || { state_matches "$token" "$pid" "$start" && rm -f "$state" exit 0 } sleep 0.1 done + # The QML deadline cannot run when a graphics failure wedges its event + # loop. This process watchdog still returns the session to a bare desktop. + stop_owned "$token" "$pid" "$start" ) >/dev/null 2>&1 & } reclaim_state() { diff --git a/ryoku/ui/AvatarImage.qml b/ryoku/ui/AvatarImage.qml new file mode 100644 index 0000000000..5d6a7c2709 --- /dev/null +++ b/ryoku/ui/AvatarImage.qml @@ -0,0 +1,80 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Effects +import Quickshell.Io + +// A person's profile picture, clipped to a circle. It looks for the picture in +// the places the desktop stack keeps it, first hit wins: the AccountsService +// icon (what login managers and GNOME or KDE settings write), then ~/.face and +// ~/.face.icon for a system without the accounts daemon. `hasImage` is false +// when none loads, so the caller keeps drawing its letter in that case. +Item { + id: root + + property string userName: "" + property string home: "" + // Bump after the picture changed on disk to load it again. + property int revision: 0 + + readonly property bool hasImage: image.status === Image.Ready + readonly property var candidates: { + const list = []; + if (root.userName !== "") + list.push("/var/lib/AccountsService/icons/" + root.userName); + if (root.home !== "") { + list.push(root.home + "/.face"); + list.push(root.home + "/.face.icon"); + } + return list; + } + + property int index: 0 + property int stamp: 0 + + function reload() { + root.index = 0; + root.stamp++; + } + + onCandidatesChanged: root.reload() + onRevisionChanged: root.reload() + onVisibleChanged: if (visible) root.reload() + FileView { + path: root.userName !== "" ? "/var/lib/AccountsService/icons/" + root.userName : "" + watchChanges: true + printErrors: false + onFileChanged: root.reload() + } + + Image { + id: image + anchors.fill: parent + visible: false + layer.enabled: true + asynchronous: true + cache: false + fillMode: Image.PreserveAspectCrop + sourceSize: Qt.size(Math.max(1, root.width) * 2, Math.max(1, root.height) * 2) + // The query is ignored for a local file; it only makes each reload a new URL. + source: root.index < root.candidates.length + ? "file://" + root.candidates[root.index] + "?r=" + root.stamp : "" + onStatusChanged: if (status === Image.Error) root.index++ + } + + Rectangle { + id: mask + anchors.fill: parent + radius: width / 2 + visible: false + layer.enabled: true + } + + MultiEffect { + anchors.fill: parent + source: image + maskEnabled: true + maskSource: mask + visible: root.hasImage + } +} diff --git a/ryoku/ui/DeferredFileView.qml b/ryoku/ui/DeferredFileView.qml new file mode 100644 index 0000000000..f7ba3937a2 --- /dev/null +++ b/ryoku/ui/DeferredFileView.qml @@ -0,0 +1,33 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell.Io + +FileView { + id: root + + property int readRetryLimit: 3 + property string readLabel: path + property int _readAttempt: 0 + + signal textReady(string content) + + function _readText(): void { + try { + root.textReady(root.text()); + } catch (error) { + if (root._readAttempt < root.readRetryLimit) { + root._readAttempt++; + Qt.callLater(() => root._readText()); + return; + } + console.warn("FileView text read failed for " + root.readLabel + ":", error); + } + } + + onLoaded: { + root._readAttempt = 0; + Qt.callLater(() => root._readText()); + } + +} diff --git a/ryoku/ui/Field.qml b/ryoku/ui/Field.qml index 6e7e478f12..c445b5ba8f 100644 --- a/ryoku/ui/Field.qml +++ b/ryoku/ui/Field.qml @@ -11,6 +11,7 @@ Rectangle { property string placeholder: "" property bool tabular: false // mono for what a config file would hold property bool toolbar: false // 36 in a toolbar, 30 in a sheet + property bool readOnly: false // shows a value the screen does not own property bool secret: false // masked, for a password property bool clearOnEscape: true readonly property bool focused: input.activeFocus @@ -43,6 +44,7 @@ Rectangle { selectByMouse: true echoMode: field.secret ? TextInput.Password : TextInput.Normal passwordCharacter: "\u2022" + readOnly: field.readOnly clip: true onTextEdited: field.edited(text) onEditingFinished: field.committed(text) diff --git a/ryoku/ui/Singletons/Wm.qml b/ryoku/ui/Singletons/Wm.qml index abe1b0bf4c..b0e4ed434a 100644 --- a/ryoku/ui/Singletons/Wm.qml +++ b/ryoku/ui/Singletons/Wm.qml @@ -136,6 +136,7 @@ Singleton { out.push({ id: r.id !== undefined && r.id !== "" ? String(r.id) : r.name, name: r.name, + index: r.index !== undefined ? r.index : r.idx, active: r.active === true, urgent: s ? s.urgent === true : false, canActivate: s ? s.canActivate === true : root.caps.workspaces === true, @@ -260,18 +261,29 @@ Singleton { // The foreign-toplevel activated flag marks the focused window on every // compositor; focusOrder is only meaningful where caps.focusHistory is set, - // so it is the fallback. + // so it is the fallback, and only for a window on a workspace its output is + // showing: after a switch to an empty workspace nothing is activated, and + // the last-focused window of the workspace just left must not read as focus. readonly property var _focusedCandidate: { const list = root.windows; for (let i = 0; i < list.length; i++) if (list[i].toplevel && list[i].toplevel.activated) return list[i]; for (let i = 0; i < list.length; i++) - if (list[i].focusOrder === 0) + if (list[i].focusOrder === 0 && root._windowIsShown(list[i])) return list[i]; return null; } + // False only when the compositor reports the window's workspace and its + // output's shown workspace and they differ. + function _windowIsShown(w) { + const o = root.outputByName(w.output); + if (!o || !o.activeWorkspace || !w.workspace) + return true; + return root._wsId(w.workspace) === root._wsId(o.activeWorkspace); + } + // windows rebuilds every object on any title change, so the focused one is // only replaced when one of its own fields moved; otherwise an unrelated // window retitling would fire every onFocusedWindowChanged listener. diff --git a/ryoku/ui/qmldir b/ryoku/ui/qmldir index 1fd2dfb77c..532bdc02af 100644 --- a/ryoku/ui/qmldir +++ b/ryoku/ui/qmldir @@ -7,6 +7,7 @@ SettingCard 1.0 SettingCard.qml Grain 1.0 Grain.qml Btn 1.0 Btn.qml ActionBar 1.0 ActionBar.qml +AvatarImage 1.0 AvatarImage.qml Preview 1.0 Preview.qml Barcode 1.0 Barcode.qml Field 1.0 Field.qml @@ -38,6 +39,7 @@ DitherImage 1.0 DitherImage.qml HeroCrop 1.0 HeroCrop.qml PickFile 1.0 PickFile.qml FileDialogHost 1.0 FileDialogHost.qml +DeferredFileView 1.0 DeferredFileView.qml AppPicker 1.0 AppPicker.qml AuraField 1.0 AuraField.qml SpectrumField 1.0 SpectrumField.qml diff --git a/ryoku/wm/hyprland/config_plugins.go b/ryoku/wm/hyprland/config_plugins.go index 30d57757f3..58746008a7 100644 --- a/ryoku/wm/hyprland/config_plugins.go +++ b/ryoku/wm/hyprland/config_plugins.go @@ -119,9 +119,15 @@ end ` -// genPlugins renders the enabled plugins for settings.lua: per plugin an -// hl.plugin.load plus its config behind the loaded guard, inside a pcall so a -// missing or ABI-mismatched .so degrades to "off" instead of aborting the file. +const luaPluginStartupSync = `hl.on("hyprland.start", function() + hl.exec_cmd("ryoku-wm-hyprland plugins sync >/dev/null 2>&1") +end) + +` + +// genPlugins renders the enabled plugins for settings.lua. Hyprland resolves +// hl.plugin.load declarations after the first config pass; the startup sync +// then loads anything still missing and applies the saved plugin values. func genPlugins(o Overrides) string { var b strings.Builder blocks := pluginBlocks(o) @@ -133,15 +139,31 @@ func genPlugins(o Overrides) string { fmt.Fprintf(&b, "-- %s: no copy built for this Hyprland; rebuild it from Settings > Plugins\n\n", pb.id) continue } - b.WriteString("pcall(function()\n") - fmt.Fprintf(&b, " hl.plugin.load(%s)\n", luaStr(pb.so)) - b.WriteString(pb.configLua(" ")) - b.WriteString("end)\n\n") + b.WriteString(pb.loadLua()) + } + if len(blocks) > 0 { + b.WriteString(luaPluginStartupSync) } b.WriteString(genScrolling(o)) return b.String() } +// loadLua keeps one plugin's failure local while making it visible in the +// compositor log instead of discarding pcall's error value. +func (pb pluginBlock) loadLua() string { + var b strings.Builder + b.WriteString("do\n") + b.WriteString(" local ok, err = pcall(function()\n") + fmt.Fprintf(&b, " hl.plugin.load(%s)\n", luaStr(pb.so)) + b.WriteString(pb.configLua(" ")) + b.WriteString(" end)\n") + b.WriteString(" if not ok then\n") + fmt.Fprintf(&b, " print(%s .. tostring(err))\n", luaStr("Ryoku plugin "+pb.id+": ")) + b.WriteString(" end\n") + b.WriteString("end\n\n") + return b.String() +} + // genPluginConfig renders only the config of the enabled plugins, for the live // eval: the preview and the pass after a load, when a plugin just come up still // holds its defaults. @@ -174,13 +196,13 @@ func (pb pluginBlock) configLua(indent string) string { return b.String() } -// typedBlock is a bundled plugin's block: its options under one section, keys -// underscored (the Lua config normalises them to the plugin's dashed names). -func typedBlock(id, section string, opts []string, extra string) pluginBlock { +// typedBlock is a bundled plugin's block. ConfigSection is the exact name +// Hyprland exposes to Lua after normalising the plugin's option namespace. +func typedBlock(id string, opts []string, extra string) pluginBlock { d, _ := bundledByID(id) return pluginBlock{ id: id, name: d.Loaded, so: pluginSoPath(id), - config: fmt.Sprintf("{ %s = { %s } }", section, strings.Join(opts, ", ")), + config: fmt.Sprintf("{ %s = { %s } }", d.ConfigSection, strings.Join(opts, ", ")), extra: extra, } } @@ -196,7 +218,7 @@ func pluginBlocks(o Overrides) []pluginBlock { fmt.Sprintf("mode = %s", luaStr(dc.Mode)), fmt.Sprintf("shake = { enabled = %t, base = %s }", dc.Shake, luaNum(dc.Magnify)), } - out = append(out, typedBlock("dynamic-cursors", "dynamic_cursors", opts, "")) + out = append(out, typedBlock("dynamic-cursors", opts, "")) } if hb := p.Hyprbars; hb.Enabled { @@ -211,7 +233,7 @@ func pluginBlocks(o Overrides) []pluginBlock { extra = " hl.plugin.hyprbars.add_button({ bg_color = \"rgb(ff5f57)\", fg_color = \"rgb(ffffff)\", size = 12, icon = \"\u00d7\", action = \"hyprctl dispatch killactive\" })\n" + " hl.plugin.hyprbars.add_button({ bg_color = \"rgb(28c840)\", fg_color = \"rgb(ffffff)\", size = 12, icon = \"+\", action = \"hyprctl dispatch fullscreen 1\" })\n" } - out = append(out, typedBlock("hyprbars", "hyprbars", opts, extra)) + out = append(out, typedBlock("hyprbars", opts, extra)) } if ib := p.Imgborders; ib.Enabled { @@ -224,7 +246,7 @@ func pluginBlocks(o Overrides) []pluginBlock { fmt.Sprintf("smooth = %t", ib.Smooth), fmt.Sprintf("blur = %t", ib.Blur), } - out = append(out, typedBlock("imgborders", "imgborders", opts, "")) + out = append(out, typedBlock("imgborders", opts, "")) } if hg := p.Hyprglass; hg.Enabled { @@ -237,7 +259,7 @@ func pluginBlocks(o Overrides) []pluginBlock { fmt.Sprintf("brightness = %s", luaNum(hg.Brightness)), fmt.Sprintf("default_theme = %s", luaStr(hg.Theme)), } - out = append(out, typedBlock("hyprglass", "hyprglass", opts, "")) + out = append(out, typedBlock("hyprglass", opts, "")) } if hf := p.Hyprfocus; hf.Enabled { @@ -255,7 +277,7 @@ func pluginBlocks(o Overrides) []pluginBlock { fmt.Sprintf("shrink_percentage = %s", luaNum(hf.Bounce)), fmt.Sprintf("slide_height = %s", luaNum(hf.Slide)), } - out = append(out, typedBlock("hyprfocus", "hyprfocus", opts, "")) + out = append(out, typedBlock("hyprfocus", opts, "")) } if ks := p.Keysounds; ks.Enabled { @@ -265,7 +287,7 @@ func pluginBlocks(o Overrides) []pluginBlock { fmt.Sprintf("volume = %s", luaNum(ks.Volume)), fmt.Sprintf("release = %t", ks.Release), } - out = append(out, typedBlock("keysounds", "keysounds", opts, "")) + out = append(out, typedBlock("keysounds", opts, "")) } if bp := p.BordersPlusPlus; bp.Enabled { @@ -277,7 +299,7 @@ func pluginBlocks(o Overrides) []pluginBlock { fmt.Sprintf("col = { border_1 = %s }", luaStr(luaRGB(bp.Color))), fmt.Sprintf("border_size_1 = %d", bp.Size), } - out = append(out, typedBlock("borders-plus-plus", "borders_plus_plus", opts, "")) + out = append(out, typedBlock("borders-plus-plus", opts, "")) } if gr := p.Hyprgrass; gr.Enabled { @@ -297,7 +319,7 @@ func pluginBlocks(o Overrides) []pluginBlock { " action = hl.dsp.window.float({ action = \"toggle\" }),\n" + " })\n" } - out = append(out, typedBlock("hyprgrass", "hyprgrass", opts, extra)) + out = append(out, typedBlock("hyprgrass", opts, extra)) } for _, id := range sortedExtraIDs(p.Extra) { diff --git a/ryoku/wm/hyprland/config_plugins_test.go b/ryoku/wm/hyprland/config_plugins_test.go index 486d25a547..2487b5dead 100644 --- a/ryoku/wm/hyprland/config_plugins_test.go +++ b/ryoku/wm/hyprland/config_plugins_test.go @@ -206,6 +206,80 @@ func TestPluginBlockConfigLua(t *testing.T) { } } +// A session starts with no plugins loaded, so the guarded config beside a load +// declaration cannot apply its values on the first config pass. The startup +// callback must run after that declaration and reconcile the saved values. +func TestGenPluginsSyncsSavedValuesAtStartup(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + plugin := filepath.Join(home, ".local", "lib", "hyprland", "plugins", "hyprglass.so") + if err := os.MkdirAll(filepath.Dir(plugin), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(plugin, nil, 0o755); err != nil { + t.Fatal(err) + } + + o := defaultOverrides() + o.Plugins.Hyprglass.Enabled = true + got := genPlugins(o) + loadAt := strings.Index(got, `hl.plugin.load("`) + configAt := strings.Index(got, `if ryoku_plugin_loaded("hyprglass")`) + syncAt := strings.Index(got, `hl.on("hyprland.start"`) + if loadAt < 0 || configAt < 0 || syncAt < 0 { + t.Fatalf("generated plugin startup block is incomplete:\n%s", got) + } + if !(loadAt < configAt && configAt < syncAt) { + t.Fatalf("plugin startup order is load=%d config=%d sync=%d:\n%s", loadAt, configAt, syncAt, got) + } + if strings.Count(got, "ryoku-wm-hyprland plugins sync") != 1 { + t.Fatalf("startup sync count is not one:\n%s", got) + } +} + +// Hyprland turns ':' into '.' and '-' into '_' when it registers plugin +// options with the Lua config provider. The section names must follow that Lua +// namespace, not the plugin filename. +func TestBundledPluginLuaNamespaces(t *testing.T) { + for id, want := range map[string]string{ + "dynamic-cursors": "dynamic_cursors", + "borders-plus-plus": "borders_plus_plus", + "hyprglass": "hyprglass", + "hyprgrass": "hyprgrass", + "imgborders": "imgborders", + } { + d, ok := bundledByID(id) + if !ok { + t.Fatalf("%s missing from bundled plugins", id) + } + if d.ConfigSection != want { + t.Errorf("%s config section = %q, want %q", id, d.ConfigSection, want) + } + } +} + +func TestPluginLoadLuaReportsErrors(t *testing.T) { + pb := pluginBlock{ + id: "dynamic-cursors", name: "dynamic-cursors", so: "/p/dynamic-cursors.so", + config: "{ dynamic_cursors = { mode = \"rotate\", shake = { enabled = false } } }", + } + got := pb.loadLua() + want := "do\n" + + " local ok, err = pcall(function()\n" + + " hl.plugin.load(\"/p/dynamic-cursors.so\")\n" + + " if ryoku_plugin_loaded(\"dynamic-cursors\") then\n" + + " hl.config({ plugin = { dynamic_cursors = { mode = \"rotate\", shake = { enabled = false } } } })\n" + + " end\n" + + " end)\n" + + " if not ok then\n" + + " print(\"Ryoku plugin dynamic-cursors: \" .. tostring(err))\n" + + " end\n" + + "end\n\n" + if got != want { + t.Fatalf("load block:\n got %q\nwant %q", got, want) + } +} + // Every bundled plugin's toggle has to reach the roster through the store, or a // plugin the user enabled would never be loaded by the status probe. func TestPluginEnabledCoversBundled(t *testing.T) { @@ -225,6 +299,33 @@ func TestPluginEnabledCoversBundled(t *testing.T) { } } +func TestEnabledPluginReportsUnreachableCompositor(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + dir := filepath.Join(home, ".local", "lib", "hyprland", "plugins") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + const id = "offline-test" + so := filepath.Join(dir, id+".so") + if err := os.WriteFile(so, []byte("plugin:offline-test:enabled"), 0o644); err != nil { + t.Fatal(err) + } + target := parseABI("aaaa_aq_0.15_hu_0.14_hg_0.5_hc_0.1_hlg_0.6") + if err := os.WriteFile(filepath.Join(dir, id+".abi"), []byte(target.String()+"\n"), 0o644); err != nil { + t.Fatal(err) + } + o := defaultOverrides() + o.Plugins.Extra = map[string]ExtraPlugin{id: {Enabled: true}} + info := describePlugin( + pluginDef{ID: id, Name: "Offline test", Loaded: id}, + o, target, map[string]loadedPlugin{}, false, + ) + if info.Status != "unreachable" || !strings.Contains(info.Detail, "not reachable") { + t.Fatalf("offline status = %q, detail = %q", info.Status, info.Detail) + } +} + func TestRepoSlug(t *testing.T) { cases := map[string]string{ "https://github.com/hyprwm/hyprland-plugins": "github.com-hyprwm-hyprland-plugins", diff --git a/ryoku/wm/hyprland/config_plugins_tier.go b/ryoku/wm/hyprland/config_plugins_tier.go index 8bda9a91d1..f476f5fe8d 100644 --- a/ryoku/wm/hyprland/config_plugins_tier.go +++ b/ryoku/wm/hyprland/config_plugins_tier.go @@ -22,16 +22,17 @@ import ( // pluginDef is one plugin Ryoku bundles. type pluginDef struct { - ID string - Name string - Desc string - Repo string // upstream git URL; "" for a plugin authored in this repo - Plugin string // the [table] in the repo's hyprpm.toml - Local string // in-tree source dir for a Ryoku-authored plugin - Package string // the native package that ships the .so - Docs string - Sounds string - Loaded string // the name the plugin reports in `hyprctl plugins list` + ID string + Name string + Desc string + Repo string // upstream git URL; "" for a plugin authored in this repo + Plugin string // the [table] in the repo's hyprpm.toml + Local string // in-tree source dir for a Ryoku-authored plugin + Package string // the native package that ships the .so + Docs string + Sounds string + Loaded string // the name the plugin reports in `hyprctl plugins list` + ConfigSection string // the option namespace Hyprland exposes in Lua } var bundledPlugins = []pluginDef{ @@ -41,7 +42,7 @@ var bundledPlugins = []pluginDef{ Repo: "https://github.com/hyprwm/hyprland-plugins", Plugin: "hyprbars", Package: "ryoku-hypr-plugins", Docs: "https://github.com/hyprwm/hyprland-plugins/tree/main/hyprbars", - Loaded: "hyprbars", + Loaded: "hyprbars", ConfigSection: "hyprbars", }, { ID: "hyprglass", Name: "Glass", @@ -49,7 +50,7 @@ var bundledPlugins = []pluginDef{ Repo: "https://github.com/hyprnux/hyprglass", Plugin: "hyprglass", Package: "hyprglass", Docs: "https://github.com/hyprnux/hyprglass#configuration", - Loaded: "hyprglass", + Loaded: "hyprglass", ConfigSection: "hyprglass", }, { ID: "imgborders", Name: "Image borders", @@ -57,7 +58,7 @@ var bundledPlugins = []pluginDef{ Repo: "https://codeberg.org/zacoons/imgborders", Plugin: "imgborders", Package: "imgborders", Docs: "https://codeberg.org/zacoons/imgborders#configuration", - Loaded: "imgborders", + Loaded: "imgborders", ConfigSection: "imgborders", }, { ID: "dynamic-cursors", Name: "Cursor motion", @@ -65,7 +66,7 @@ var bundledPlugins = []pluginDef{ Repo: "https://github.com/VirtCode/hypr-dynamic-cursors", Plugin: "dynamic-cursors", Package: "hypr-dynamic-cursors", Docs: "https://github.com/VirtCode/hypr-dynamic-cursors#configuration", - Loaded: "dynamic-cursors", + Loaded: "dynamic-cursors", ConfigSection: "dynamic_cursors", }, { ID: "hyprfocus", Name: "Focus flash", @@ -73,7 +74,7 @@ var bundledPlugins = []pluginDef{ Repo: "https://github.com/hyprwm/hyprland-plugins", Plugin: "hyprfocus", Package: "ryoku-hypr-plugins", Docs: "https://github.com/hyprwm/hyprland-plugins/tree/main/hyprfocus", - Loaded: "hyprfocus", + Loaded: "hyprfocus", ConfigSection: "hyprfocus", }, { ID: "borders-plus-plus", Name: "Extra borders", @@ -81,7 +82,7 @@ var bundledPlugins = []pluginDef{ Repo: "https://github.com/hyprwm/hyprland-plugins", Plugin: "borders-plus-plus", Package: "ryoku-hypr-plugins", Docs: "https://github.com/hyprwm/hyprland-plugins/tree/main/borders-plus-plus", - Loaded: "borders-plus-plus", + Loaded: "borders-plus-plus", ConfigSection: "borders_plus_plus", }, { ID: "hyprgrass", Name: "Touch gestures", @@ -89,7 +90,7 @@ var bundledPlugins = []pluginDef{ Repo: "https://github.com/horriblename/hyprgrass", Plugin: "hyprgrass", Package: "hyprgrass", Docs: "https://github.com/horriblename/hyprgrass/blob/master/docs/configuration.md", - Loaded: "hyprgrass", + Loaded: "hyprgrass", ConfigSection: "hyprgrass", }, { ID: "keysounds", Name: "Key sounds", @@ -98,7 +99,7 @@ var bundledPlugins = []pluginDef{ Package: "ryoku-keysounds", Docs: "https://github.com/ryoku-dev/ryoku/blob/unstable-dev/ryoku/hyprland/plugins/keysounds/README.md", Sounds: "https://github.com/hainguyents13/mechvibes/tree/main/src/audio", - Loaded: "keysounds", + Loaded: "keysounds", ConfigSection: "keysounds", }, } @@ -647,7 +648,7 @@ type pluginInfo struct { ABI string `json:"abi"` Enabled bool `json:"enabled"` Loaded bool `json:"loaded"` - Status string `json:"status"` // running | ready | stale | missing | failed + Status string `json:"status"` // running | ready | unreachable | stale | missing | failed Detail string `json:"detail"` Current bool `json:"current"` Rebuildable bool `json:"rebuildable"` @@ -730,20 +731,23 @@ func pluginRosterNow() pluginRoster { if !target.ok() { target = hdr } - loaded := loadedPlugins() + loaded := map[string]loadedPlugin{} + if comp.Live { + loaded = loadedPlugins() + } roster := pluginRoster{Compositor: comp, Toolchain: toolchain()} seen := map[string]bool{} for _, d := range bundledPlugins { seen[d.ID] = true - roster.Plugins = append(roster.Plugins, describePlugin(d, o, target, loaded)) + roster.Plugins = append(roster.Plugins, describePlugin(d, o, target, loaded, comp.Live)) } for _, r := range localReceipts() { if seen[r.ID] { continue } seen[r.ID] = true - roster.Plugins = append(roster.Plugins, describePlugin(receiptDef(r), o, target, loaded)) + roster.Plugins = append(roster.Plugins, describePlugin(receiptDef(r), o, target, loaded, comp.Live)) } for _, p := range hyprpmCopies() { id := strings.TrimSuffix(filepath.Base(p), ".so") @@ -752,7 +756,7 @@ func pluginRosterNow() pluginRoster { } seen[id] = true d := pluginDef{ID: id, Name: id, Plugin: id, Loaded: id, Desc: "Installed by hyprpm from " + filepath.Base(filepath.Dir(p)) + "."} - roster.Plugins = append(roster.Plugins, describePlugin(d, o, target, loaded)) + roster.Plugins = append(roster.Plugins, describePlugin(d, o, target, loaded, comp.Live)) } return roster } @@ -770,7 +774,7 @@ func receiptDef(r pluginReceipt) pluginDef { return pluginDef{ID: r.ID, Name: name, Desc: r.Desc, Repo: r.Repo, Plugin: r.Plugin, Local: r.Local, Docs: r.Repo, Loaded: loadedName} } -func describePlugin(d pluginDef, o Overrides, target hyprABI, loaded map[string]loadedPlugin) pluginInfo { +func describePlugin(d pluginDef, o Overrides, target hyprABI, loaded map[string]loadedPlugin, compositorLive bool) pluginInfo { _, bundled := bundledByID(d.ID) info := pluginInfo{ ID: d.ID, Name: d.Name, Desc: d.Desc, Docs: d.Docs, Sounds: d.Sounds, Repo: d.Repo, Package: d.Package, @@ -836,6 +840,8 @@ func describePlugin(d pluginDef, o Overrides, target hyprABI, loaded map[string] switch { case isLoaded: info.Status, info.Detail = "running", "Loaded in the running compositor." + case info.Enabled && !compositorLive: + info.Status, info.Detail = "unreachable", "The Hyprland compositor instance is not reachable. Start or restart the session, then refresh." case stale: info.Status, info.Detail = "stale", abiDiff(c.ABI, target)+". Rebuild it for this Hyprland." case info.Enabled && target.ok(): diff --git a/ryoku/wm/hyprland/config_settings.go b/ryoku/wm/hyprland/config_settings.go index 996b778e2e..83adc7eb70 100644 --- a/ryoku/wm/hyprland/config_settings.go +++ b/ryoku/wm/hyprland/config_settings.go @@ -128,9 +128,10 @@ func genApps(o Overrides) string { // diffed-away "us" would silently win over what the UI shows. func genConfig(o Overrides, follow bool) string { d := defaultOverrides() - var general, deco, input, cursor, misc, gestures, dwindle, master []string + var general, render, deco, input, cursor, misc, gestures, dwindle, master []string a, da := o.Appearance, d.Appearance + r, dr := o.Render, d.Render if a.GapsIn != da.GapsIn { general = append(general, fmt.Sprintf("gaps_in = %d", a.GapsIn)) } @@ -174,6 +175,9 @@ func genConfig(o Overrides, follow bool) string { if a.GapsWorkspaces != da.GapsWorkspaces { general = append(general, fmt.Sprintf("gaps_workspaces = %d", a.GapsWorkspaces)) } + if r.AutoHDR != dr.AutoHDR { + render = append(render, fmt.Sprintf("cm_auto_hdr = %d", boolToInt(r.AutoHDR))) + } if a.Rounding != da.Rounding { deco = append(deco, fmt.Sprintf("rounding = %d", a.Rounding)) @@ -419,6 +423,9 @@ func genConfig(o Overrides, follow bool) string { if len(general) > 0 { sections = append(sections, " general = { "+strings.Join(general, ", ")+" }") } + if len(render) > 0 { + sections = append(sections, " render = { "+strings.Join(render, ", ")+" }") + } if len(dwindle) > 0 { sections = append(sections, " dwindle = { "+strings.Join(dwindle, ", ")+" }") } @@ -647,7 +654,7 @@ func genStartHook(o Overrides) string { // the preview eval forces the exact state, including a value moved back to its // default. func fullConfigLua(o Overrides, follow bool) string { - a, in, c := o.Appearance, o.Input, o.Cursor + a, in, c, r := o.Appearance, o.Input, o.Cursor, o.Render dw, ms := o.Dwindle, o.Master general := []string{ fmt.Sprintf("gaps_in = %d", a.GapsIn), @@ -708,6 +715,7 @@ func fullConfigLua(o Overrides, follow bool) string { } return "hl.config({\n" + " general = { " + strings.Join(general, ", ") + " },\n" + + " render = { cm_auto_hdr = " + fmt.Sprintf("%d", boolToInt(r.AutoHDR)) + " },\n" + " decoration = { " + strings.Join(deco, ", ") + " },\n" + " input = { " + strings.Join(input, ", ") + " },\n" + fmt.Sprintf(" cursor = { inactive_timeout = %d, hide_on_key_press = %t },\n", c.InactiveTimeout, c.HideOnKeyPress) + diff --git a/ryoku/wm/hyprland/config_store.go b/ryoku/wm/hyprland/config_store.go index ca463ca4b2..68a718b5f5 100644 --- a/ryoku/wm/hyprland/config_store.go +++ b/ryoku/wm/hyprland/config_store.go @@ -71,6 +71,11 @@ type Appearance struct { GapsWorkspaces int `json:"gapsWorkspaces"` } +// Render: compositor-owned colour-management behaviour. +type Render struct { + AutoHDR bool `json:"autoHdr"` +} + // Dwindle: dwindle layout keywords, emitted as a dwindle block diffed against // the defaults. type Dwindle struct { @@ -333,6 +338,7 @@ type Plugins struct { type Overrides struct { Appearance Appearance `json:"appearance"` + Render Render `json:"render"` Dwindle Dwindle `json:"dwindle"` Master Master `json:"master"` Input Input `json:"input"` @@ -390,6 +396,7 @@ func defaultOverrides() Overrides { ExtendBorderGrab: 15, HoverIconOnBorder: true, NoFocusFallback: false, ResizeCorner: 0, GapsWorkspaces: 0, }, + Render: Render{AutoHDR: false}, Dwindle: Dwindle{PreserveSplit: false, SmartSplit: false, SmartResizing: true, DefaultSplitRatio: 1, ForceSplit: "follow", UseActiveForSplits: true}, Master: Master{Mfact: 0.55, NewStatus: "slave", NewOnTop: false, Orientation: "left", SmartResizing: true}, Input: Input{ @@ -434,6 +441,7 @@ func defaultOverrides() Overrides { // the one split table, driving both the reader and splitStore. var wmHyprlandFields = map[string]bool{ "dwindle": true, + "render": true, "master": true, "anim": true, "layerRules": true, diff --git a/ryoku/wm/hyprland/config_test.go b/ryoku/wm/hyprland/config_test.go index 2cc0b5a89d..c81d462d93 100644 --- a/ryoku/wm/hyprland/config_test.go +++ b/ryoku/wm/hyprland/config_test.go @@ -119,6 +119,71 @@ func TestDefaultFollowMouseMatchesShippedInput(t *testing.T) { } } +// Automatic HDR stays opt-in because a fullscreen browser can make some +// DisplayPort panels lose signal during the mode switch. The base module owns +// the safe default, while the generated user overlay must still be able to +// override it and survive materialization. +func TestAutoHDROffByDefaultAndUserOverlayCanEnable(t *testing.T) { + if defaultOverrides().Render.AutoHDR { + t.Fatal("automatic HDR must default off") + } + base, err := os.ReadFile(filepath.Join("..", "..", "hyprland", "modules", "render.lua")) + if err != nil { + t.Fatalf("read shipped render config: %v", err) + } + if !regexp.MustCompile(`(?m)^[[:space:]]*cm_auto_hdr[[:space:]]*=[[:space:]]*0,[[:space:]]*$`).Match(base) { + t.Fatal("shipped render.lua must disable automatic HDR") + } + if !bytes.Contains(schemaRows, []byte(`"wm.hyprland.render.autoHdr"`)) { + t.Fatal("schema missing automatic HDR control") + } + if cfg := genConfig(defaultOverrides(), false); strings.Contains(cfg, "cm_auto_hdr") { + t.Fatalf("default settings.lua overrides render.lua:\n%s", cfg) + } + on := defaultOverrides() + on.Render.AutoHDR = true + if cfg := genConfig(on, false); !strings.Contains(cfg, "render = { cm_auto_hdr = 1 }") { + t.Fatalf("enabled automatic HDR missing from settings.lua:\n%s", cfg) + } + if cfg := fullConfigLua(defaultOverrides(), false); !strings.Contains(cfg, "render = { cm_auto_hdr = 0 }") { + t.Fatalf("preview cannot switch automatic HDR off:\n%s", cfg) + } + if cfg := fullConfigLua(on, false); !strings.Contains(cfg, "render = { cm_auto_hdr = 1 }") { + t.Fatalf("preview cannot switch automatic HDR on:\n%s", cfg) + } + + config := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", config) + t.Setenv("RYOKU_GREETER_NUMLOCK_FILE", filepath.Join(t.TempDir(), "greeter-numlock")) + store := filepath.Join(config, "ryoku", "desktop.json") + if err := os.MkdirAll(filepath.Dir(store), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(store, []byte(`{"wm":{"hyprland":{"render":{"autoHdr":true}}}}`), 0o644); err != nil { + t.Fatal(err) + } + var output bytes.Buffer + old := stdout + stdout = bufio.NewWriter(&output) + t.Cleanup(func() { stdout = old }) + if err := runApply([]string{store}); err != nil { + t.Fatal(err) + } + stdout.Flush() + for _, path := range []string{ + filepath.Join(config, "hypr", "settings.lua"), + filepath.Join(config, "ryoku", "user_edits", "hypr", "settings.lua"), + } { + body, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read generated %s: %v", path, err) + } + if !bytes.Contains(body, []byte("render = { cm_auto_hdr = 1 }")) { + t.Fatalf("%s cannot force automatic HDR on:\n%s", path, body) + } + } +} + // Hyprland tames a maximise-on-open by refusing the client's request outright, a // catch-all suppress_event rule. This is the whole of the Hyprland side, so it // is pinned by the exact line and by its presence in the full config when the diff --git a/ryoku/wm/hyprland/schema.json b/ryoku/wm/hyprland/schema.json index 75607d3f51..8b645618b7 100644 --- a/ryoku/wm/hyprland/schema.json +++ b/ryoku/wm/hyprland/schema.json @@ -1,4 +1,14 @@ [ + { + "tab": "Display", + "group": "HDR", + "key": "wm.hyprland.render.autoHdr", + "label": "Switch to HDR for fullscreen HDR content", + "desc": "Let fullscreen HDR apps switch the display mode automatically; leave this off if the panel blanks during the switch", + "ctl": "sw", + "src": "desktop.json", + "page": "windowmanager" + }, { "tab": "Layout", "group": "SETTINGS WINDOW", diff --git a/ryoku/wm/hyprland/session.go b/ryoku/wm/hyprland/session.go index 0ae2382104..30c4d5c3dd 100644 --- a/ryoku/wm/hyprland/session.go +++ b/ryoku/wm/hyprland/session.go @@ -105,6 +105,7 @@ func eventSocketPath() string { var ctl = runCtl func runCtl(args ...string) ([]byte, error) { + ensureLiveSignature() cmd := exec.Command("hyprctl", args...) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout diff --git a/ryoku/wm/hyprland/session_environment_test.go b/ryoku/wm/hyprland/session_environment_test.go index 7f09bb1979..be8312fd32 100644 --- a/ryoku/wm/hyprland/session_environment_test.go +++ b/ryoku/wm/hyprland/session_environment_test.go @@ -3,6 +3,7 @@ package main import ( "bufio" "bytes" + "net" "os" "path/filepath" "testing" @@ -38,3 +39,34 @@ func TestRunEnvironmentExportsOnlyProviderHandle(t *testing.T) { t.Fatalf("environment output = %q, want %q", got, want) } } + +func TestRunCtlRebindsStaleInstanceSignature(t *testing.T) { + runtimeDir := t.TempDir() + t.Setenv("XDG_RUNTIME_DIR", runtimeDir) + t.Setenv("HYPRLAND_INSTANCE_SIGNATURE", "dead-instance") + + socketDir := filepath.Join(runtimeDir, "hypr", "live-instance") + if err := os.MkdirAll(socketDir, 0o700); err != nil { + t.Fatal(err) + } + listener, err := net.Listen("unix", filepath.Join(socketDir, ".socket.sock")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = listener.Close() }) + + bin := t.TempDir() + hyprctl := filepath.Join(bin, "hyprctl") + if err := os.WriteFile(hyprctl, []byte("#!/bin/sh\nprintf '%s\\n' \"$HYPRLAND_INSTANCE_SIGNATURE\"\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin) + + out, err := runCtl("version", "-j") + if err != nil { + t.Fatal(err) + } + if got, want := string(out), "live-instance\n"; got != want { + t.Fatalf("hyprctl signature = %q, want %q", got, want) + } +} diff --git a/ryoku/wm/hyprland/watch.go b/ryoku/wm/hyprland/watch.go index 2ea70dfd76..9b931814ec 100644 --- a/ryoku/wm/hyprland/watch.go +++ b/ryoku/wm/hyprland/watch.go @@ -397,10 +397,17 @@ func readWorkspaces(mons []hyprMonitor) []wm.Workspace { list := make([]wm.Workspace, 0, len(raw)) for _, w := range raw { key := workspaceKey(w.ID, w.Name) + index := 0 + if w.ID > 0 { + index = w.ID + } else if parsed, err := strconv.Atoi(w.Name); err == nil && parsed > 0 { + index = parsed + } list = append(list, wm.Workspace{ ID: key, Name: w.Name, Output: w.Monitor, + Index: index, Active: active[key], // A negative id is how Hyprland marks a scratchpad workspace. Special: w.ID < 0 || strings.HasPrefix(w.Name, "special:"), diff --git a/ryoku/wm/hyprland/watch_state_test.go b/ryoku/wm/hyprland/watch_state_test.go index 11760689db..5ef0c8e09b 100644 --- a/ryoku/wm/hyprland/watch_state_test.go +++ b/ryoku/wm/hyprland/watch_state_test.go @@ -32,6 +32,35 @@ func TestMonitorOutputsDisabledState(t *testing.T) { } } +func TestWorkspaceFramesCarryNumericRank(t *testing.T) { + restore := stubCtl(t, func(args ...string) ([]byte, error) { + if args[0] != "workspaces" { + t.Fatalf("unexpected query %v", args) + } + return []byte(`[ + {"id":3,"name":"3","monitor":"DP-1"}, + {"id":1,"name":"1","monitor":"DP-1"}, + {"id":2,"name":"2","monitor":"DP-1"} + ]`), nil + }) + defer restore() + workspaces := readWorkspaces([]hyprMonitor{{ + Name: "DP-1", ActiveWorkspace: struct { + ID int `json:"id"` + Name string `json:"name"` + }{ID: 1, Name: "1"}, + }}) + got := map[string]int{} + for _, workspace := range workspaces { + got[workspace.ID] = workspace.Index + } + for id, index := range map[string]int{"1": 1, "2": 2, "3": 3} { + if got[id] != index { + t.Errorf("workspace %s index = %d, want %d", id, got[id], index) + } + } +} + func TestFocusEventsDoNotQueryAndPreserveHistory(t *testing.T) { restore := stubCtl(t, func(...string) ([]byte, error) { t.Fatal("focus queried compositor"); return nil, nil }) defer restore() diff --git a/ryoku/wm/niri/watch.go b/ryoku/wm/niri/watch.go index 93f5c0a084..e3a25efe45 100644 --- a/ryoku/wm/niri/watch.go +++ b/ryoku/wm/niri/watch.go @@ -222,6 +222,9 @@ func (s *session) apply(name string, body json.RawMessage, emit func(wm.Frame), } s.activate(e.ID, e.Focused) s.emitWorkspaces(emit, wants) + if wants(wm.FrameOutputs) { + emit(wm.Frame{Kind: wm.FrameOutputs, Outputs: s.outputs}) + } if e.Focused { s.emitFocus(emit, wants) } @@ -427,6 +430,14 @@ func (s *session) activate(id uint64, focused bool) { ws.IsFocused = false } } + if output != "" { + for i := range s.outputs { + if s.outputs[i].Name == output { + s.outputs[i].ActiveWorkspace = formatUint(id) + break + } + } + } } func (s *session) upsert(w niriWindow) { @@ -519,6 +530,7 @@ func (s *session) workspaceFrame() []wm.Workspace { ID: formatUint(ws.ID), Name: workspaceName(ws), Output: ws.Output, + Index: ws.Idx, Active: ws.IsActive, Windows: counts[ws.ID], }) diff --git a/ryoku/wm/niri/watch_test.go b/ryoku/wm/niri/watch_test.go index b49173a5e9..b4439824a1 100644 --- a/ryoku/wm/niri/watch_test.go +++ b/ryoku/wm/niri/watch_test.go @@ -28,6 +28,24 @@ func foldEvents(t *testing.T, s *session, wants func(wm.FrameKind) bool, events func allKinds(wm.FrameKind) bool { return true } +func TestWorkspaceFrameCarriesPerOutputIndex(t *testing.T) { + s := &session{workspaces: []niriWorkspace{ + {ID: 20, Idx: 3, Output: "eDP-1"}, + {ID: 2, Idx: 1, Output: "eDP-1"}, + {ID: 10, Idx: 2, Output: "eDP-1"}, + }} + got := map[string]int{} + for _, workspace := range s.workspaceFrame() { + got[workspace.ID] = workspace.Index + } + want := map[string]int{"2": 1, "10": 2, "20": 3} + for id, index := range want { + if got[id] != index { + t.Errorf("workspace %s index = %d, want %d", id, got[id], index) + } + } +} + // The overview state is what gates the backdrop's blur pass, so the fold must // carry is_open straight onto a FrameOverview and keep it on the session for a // later connect replay. @@ -83,6 +101,30 @@ func TestWindowEventCarriesProcessID(t *testing.T) { } } +// The shell joins a window's workspace to the active workspace carried by its +// output. WorkspaceActivated must refresh both frames or an empty workspace +// switch leaves the output pointing at the workspace just left. +func TestWorkspaceActivationRefreshesOutputWorkspace(t *testing.T) { + s := &session{ + outputs: []wm.Output{{Name: "DP-1", ActiveWorkspace: "1"}}, + workspaces: []niriWorkspace{ + {ID: 1, Output: "DP-1", IsActive: true, IsFocused: true}, + {ID: 2, Output: "DP-1"}, + }, + } + + frames := foldEvents(t, s, allKinds, map[string]any{ + "WorkspaceActivated": map[string]any{"id": 2, "focused": true}, + }) + if len(frames) != 3 { + t.Fatalf("activation frames = %+v, want workspace, output and focus", frames) + } + if frames[1].Kind != wm.FrameOutputs || len(frames[1].Outputs) != 1 || + frames[1].Outputs[0].ActiveWorkspace != "2" { + t.Fatalf("output frame = %+v, want active workspace 2", frames[1]) + } +} + // Floating geometry is the persistence source after a native move/resize. niri // reports it output-local, while wm.Window is globally logical, so the output // origin is added and a layout delta must publish a fresh window frame. diff --git a/ryoku/wm/state.go b/ryoku/wm/state.go index 87f0e49a5d..dbd350123f 100644 --- a/ryoku/wm/state.go +++ b/ryoku/wm/state.go @@ -67,7 +67,10 @@ type Workspace struct { ID string `json:"id"` Name string `json:"name"` Output string `json:"output,omitempty"` - Active bool `json:"active,omitempty"` + // Index is the one-based presentation rank inside Output. Zero means the + // provider cannot report an order, so consumers preserve frame order. + Index int `json:"index,omitempty"` + Active bool `json:"active,omitempty"` // Windows is a count, which is all the wallpaper occupancy gate needs. Windows int `json:"windows"` Fullscreen bool `json:"fullscreen,omitempty"` diff --git a/ryoku/wm/wayfire/frames.go b/ryoku/wm/wayfire/frames.go index d3e05a8357..c4a712371c 100644 --- a/ryoku/wm/wayfire/frames.go +++ b/ryoku/wm/wayfire/frames.go @@ -427,6 +427,7 @@ func (s *stage) workspaceFrame() []wm.Workspace { ID: handle, Name: workspaceName(x, y, gridW), Output: ws.OutputName, + Index: y*gridW + x + 1, Active: attached && onCurrent.X == x && onCurrent.Y == y, Windows: counts[handle], Fullscreen: fullscreen[handle], diff --git a/ryoku/wm/wayfire/frames_test.go b/ryoku/wm/wayfire/frames_test.go index 8ba8e54bec..41d8c251e4 100644 --- a/ryoku/wm/wayfire/frames_test.go +++ b/ryoku/wm/wayfire/frames_test.go @@ -98,6 +98,10 @@ func TestWorkspaceAndWindowFrames(t *testing.T) { ids := map[string]bool{} for _, w := range ws { ids[w.ID] = true + _, x, y, _ := parseWorkspaceHandle(w.ID) + if want := y*3 + x + 1; w.Index != want { + t.Errorf("workspace %s index = %d, want %d", w.ID, w.Index, want) + } if w.Active { active++ if w.ID != "1:1:0" { diff --git a/system/hardware/CHANGELOG.md b/system/hardware/CHANGELOG.md index 05c07c8e0b..24ab4d4557 100644 --- a/system/hardware/CHANGELOG.md +++ b/system/hardware/CHANGELOG.md @@ -17,6 +17,13 @@ headless one exits at once. ### Fixed +- `bluetooth/ryoku-bluetooth-power`: **Bluetooth stays off after login when it + was turned off.** The login reset restarts bluetoothd, and BlueZ's + `AutoEnable` is still powering the new controller on when `restore` runs, so + `power off` was refused with `org.bluez.Error.Busy`, the radio came up on, and + `ryoku-bluetooth-reset.service` failed. Turning off now waits for the + controller and retries until BlueZ reports it off, as turning on already + waited. Covered by `tests/bluetooth-power-persist.sh`. - `network/50-ryoku-dns.rules`: **the passwordless DNS grant no longer matches any path ending in `ryoku-dns`.** The rule tested the program with a basename regex, so any executable a local wheel user placed at a path ending in diff --git a/system/hardware/bluetooth/ryoku-bluetooth-power b/system/hardware/bluetooth/ryoku-bluetooth-power index 5b1c89bf52..e206c000d7 100755 --- a/system/hardware/bluetooth/ryoku-bluetooth-power +++ b/system/hardware/bluetooth/ryoku-bluetooth-power @@ -30,12 +30,29 @@ controller_available() { bluetoothctl list 2>/dev/null | command grep -q '^Controller[[:space:]]' } +powered() { + bluetoothctl show 2>/dev/null | command grep -q '^[[:space:]]*Powered:[[:space:]]*yes' +} + +powered_off() { + bluetoothctl show 2>/dev/null | command grep -q '^[[:space:]]*Powered:[[:space:]]*no' +} + apply_state() { local state=$1 attempt case $state in off) - command -v bluetoothctl >/dev/null 2>&1 \ - && bluetoothctl power off >/dev/null 2>&1 + command -v bluetoothctl >/dev/null 2>&1 || return 1 + # Right after bluetoothd (re)starts, AutoEnable is still powering the + # controller on: power off fails with org.bluez.Error.Busy and the radio + # comes up on against the saved choice. Retry until BlueZ reports it off. + for ((attempt = 0; attempt < 50; attempt++)); do + if controller_available && bluetoothctl power off >/dev/null 2>&1 && powered_off; then + return 0 + fi + ((attempt == 49)) || sleep 0.1 + done + return 1 ;; on) if command -v rfkill >/dev/null 2>&1; then @@ -65,8 +82,7 @@ case ${1:-} in set_state "$1" ;; toggle) - if command -v bluetoothctl >/dev/null 2>&1 \ - && bluetoothctl show 2>/dev/null | command grep -q '^[[:space:]]*Powered:[[:space:]]*yes'; then + if command -v bluetoothctl >/dev/null 2>&1 && powered; then set_state off else set_state on @@ -79,8 +95,7 @@ case ${1:-} in saved_state ;; is-on) - command -v bluetoothctl >/dev/null 2>&1 \ - && bluetoothctl show 2>/dev/null | command grep -q '^[[:space:]]*Powered:[[:space:]]*yes' + command -v bluetoothctl >/dev/null 2>&1 && powered ;; *) echo 'Usage: ryoku-bluetooth-power ' >&2 diff --git a/system/packages/CHANGELOG.md b/system/packages/CHANGELOG.md index 72d2e41cb0..9dd1d00c27 100644 --- a/system/packages/CHANGELOG.md +++ b/system/packages/CHANGELOG.md @@ -3,6 +3,9 @@ ## Unreleased ### Changed +- `cachyos.replaces`: **CachyOS leaves out stock `linux-headers` with stock + `linux`.** Its package set already carries matching headers for both CachyOS + kernels, while doctor derives matches for any additional installed kernel. - `cachyos.packages`: **the CachyOS edition now carries CachyOS's current defaults and identity.** `shelly`, `lsb-release`, and `cachyos-hello` join the kernel pair, repositories, hooks, `chwd`, tuning, schedulers, and desktop diff --git a/system/packages/base.packages b/system/packages/base.packages index 4af7dbdb8a..ea122f1a8f 100644 --- a/system/packages/base.packages +++ b/system/packages/base.packages @@ -11,12 +11,10 @@ base-devel rust linux linux-firmware -# linux-headers + dkms: the shipped kernel's headers and the DKMS build system, -# so out-of-tree driver modules build (e.g. xpadneo-dkms, the Xbox One/Series -# Bluetooth pads; see aur.packages). ryoku-desktop hard-depends on both, and a -# DKMS module fails to build without matching headers. The CachyOS kernel is an -# opt-in post-install (the CachyOS Layer extras bundle), never baked into the -# base, so the base ships and boots the stock linux kernel. +# The base boots stock linux, so its matching headers and DKMS build shipped +# modules such as xpadneo-dkms during installation. The CachyOS edition replaces +# both kernel and headers with its own pairs; doctor derives -headers +# from every installed kernel's pkgbase on existing Arch and CachyOS boxes. linux-headers dkms mkinitcpio diff --git a/system/packages/cachyos.replaces b/system/packages/cachyos.replaces index 2989e940ef..4d07214440 100644 --- a/system/packages/cachyos.replaces +++ b/system/packages/cachyos.replaces @@ -1,14 +1,16 @@ # base.packages and aur.packages names the CachyOS layer (cachyos.packages) # replaces. A CachyOS install never carries them: pacstrap and the AUR step leave # them out, the offline ISO does not bake them, and the installer records them in -# the provisioning ledger so `ryoku update` does not deliver them later. One -# package per line. +# the provisioning ledger. The release manifest also carries this list, so +# `ryoku update` never delivers these names to any CachyOS box, including one +# converted in place by ryoku-shell-install, which has no ledger entries for +# them. One package per line. -# The stock kernel: CachyOS boots linux-cachyos with linux-cachyos-lts as the -# fallback. linux-headers is not listed: ryoku-desktop depends on it by name -# (for xpadneo-dkms) and no CachyOS headers package provides that name, so it -# lands with the desktop either way. +# The stock kernel and its headers: CachyOS boots linux-cachyos with +# linux-cachyos-lts as the fallback, and cachyos.packages carries the matching +# header package for each. linux +linux-headers # The AUR helper: CachyOS ships yay in [cachyos], so the box updates it from the # repo instead of carrying an AUR build of yay-bin. diff --git a/system/packages/upstream-pins.tsv b/system/packages/upstream-pins.tsv index 8a19b81631..c52cc773e2 100644 --- a/system/packages/upstream-pins.tsv +++ b/system/packages/upstream-pins.tsv @@ -8,6 +8,7 @@ catppuccin-cursors-mocha github-release catppuccin/cursors 2.0.0 no plain plain dualsensectl github-tags nowrep/dualsensectl 0.7 no plain plain - fedora/packages/rpm/dualsensectl.spec;fedora/packages/rpm/sources/dualsensectl;void/packages/srcpkgs/dualsensectl/template - - https://github.com/nowrep/dualsensectl/archive/v{version}.tar.gz game-devices-udev git-tags https://codeberg.org/fabiscafe/game-devices-udev.git 1.0 no plain plain - fedora/packages/rpm/game-devices-udev.spec;fedora/packages/rpm/sources/game-devices-udev;void/packages/srcpkgs/game-devices-udev/template - - https://codeberg.org/fabiscafe/game-devices-udev/archive/{version}.tar.gz gpk github-release neur0map/glazepkg 0.6.10 no plain plain - fedora/packages/rpm/gpk.spec;fedora/packages/rpm/sources/gpk;void/packages/srcpkgs/gpk/template - - https://github.com/neur0map/glazepkg/releases/download/v{version}/gpk-linux-amd64;https://raw.githubusercontent.com/neur0map/glazepkg/v{version}/LICENSE +hyprland-preview-share-picker github-release WhySoBad/hyprland-preview-share-picker 0.3.0 no plain - - fedora/packages/rpm/hyprland-preview-share-picker.spec release/packages/hyprland-preview-share-picker/PKGBUILD#pkgver release/packages/hyprland-preview-share-picker/PKGBUILD#_commit;fedora/packages/rpm/fetch/hyprland-preview-share-picker.sh#commit - gpu-screen-recorder git-tags https://repo.dec05eba.com/gpu-screen-recorder 6.1.3 no plain plain - fedora/packages/rpm/gpu-screen-recorder.spec;fedora/packages/rpm/sources/gpu-screen-recorder;void/packages/srcpkgs/gpu-screen-recorder/template - - https://dec05eba.com/snapshot/gpu-screen-recorder.git.{version}.tar.gz limine-snapper-sync git-tags https://gitlab.com/Zesko/limine-snapper-sync.git 1.32.1 no - plain - void/packages/srcpkgs/limine-snapper-sync/template - - https://gitlab.com/Zesko/limine-snapper-sync/-/archive/{version}/limine-snapper-sync-{version}.tar.gz;https://repo1.maven.org/maven2/{artifact} localsend-bin github-release localsend/localsend 1.18.2 no plain plain - fedora/packages/rpm/localsend-bin.spec;fedora/packages/rpm/sources/localsend-bin;void/packages/srcpkgs/localsend-bin/template - - https://github.com/localsend/localsend/releases/download/v{version}/LocalSend-{version}-linux-x86-64.deb;https://raw.githubusercontent.com/localsend/localsend/v{version}/LICENSE diff --git a/tests/bluetooth-power-persist.sh b/tests/bluetooth-power-persist.sh index 13458311e2..164f22aee6 100755 --- a/tests/bluetooth-power-persist.sh +++ b/tests/bluetooth-power-persist.sh @@ -11,7 +11,9 @@ LOG="$WORK/commands" LIVE="$WORK/live" BLOCKED="$WORK/blocked" POLLS="$WORK/polls" +BUSY="$WORK/busy" STATE="$WORK/state/bluetooth-power" +SHOW_FAILS="$WORK/show-fails" cat >"$WORK/bin/rfkill" <<'EOF' #!/usr/bin/env bash @@ -44,10 +46,25 @@ case ${1:-} in polls=0 if IFS= read -r polls <"$RYOKU_TEST_POLLS"; then :; fi ((polls >= RYOKU_TEST_APPEAR_AFTER)) || exit 1 + else + # BlueZ answers org.bluez.Error.Busy while AutoEnable is still powering a + # freshly registered controller on; the radio stays on. + busy=0 + if IFS= read -r busy <"$RYOKU_TEST_BUSY"; then :; fi + if ((busy > 0)); then + printf '%s\n' "$((busy - 1))" >"$RYOKU_TEST_BUSY" + exit 1 + fi fi printf '%s\n' "$2" >"$RYOKU_TEST_LIVE" ;; show) + show_fails=0 + if IFS= read -r show_fails <"$RYOKU_TEST_SHOW_FAILS"; then :; fi + if ((show_fails > 0)); then + printf '%s\n' "$((show_fails - 1))" >"$RYOKU_TEST_SHOW_FAILS" + exit 1 + fi state=off if IFS= read -r state <"$RYOKU_TEST_LIVE"; then :; fi [[ $state == on ]] && printf 'Powered: yes\n' || printf 'Powered: no\n' @@ -79,7 +96,9 @@ export RYOKU_TEST_LOG="$LOG" export RYOKU_TEST_LIVE="$LIVE" export RYOKU_TEST_BLOCKED="$BLOCKED" export RYOKU_TEST_POLLS="$POLLS" +export RYOKU_TEST_BUSY="$BUSY" export RYOKU_TEST_APPEAR_AFTER=1 +export RYOKU_TEST_SHOW_FAILS="$SHOW_FAILS" export RYOKU_TEST_RFKILL_UNBLOCK_STATUS=0 export RYOKU_BLUETOOTH_STATE_FILE="$STATE" export RYOKU_BLUETOOTH_POWER_BIN="$POWER" @@ -95,6 +114,8 @@ assert_eq() { reset_commands() { : >"$LOG" printf '0\n' >"$POLLS" + printf '0\n' >"$BUSY" + printf '0\n' >"$SHOW_FAILS" } # No saved choice is a fresh install, which deliberately defaults to on. @@ -123,13 +144,20 @@ reset_commands "$POWER" off assert_eq off "$(<"$STATE")" 'saved off choice' assert_eq off "$(<"$LIVE")" 'live off choice' -assert_eq 'bluetoothctl power off' "$(<"$LOG")" 'off command sequence' +assert_eq $'bluetoothctl list\nbluetoothctl power off\nbluetoothctl show' "$(<"$LOG")" 'off command sequence' + +# A failed status read is not confirmation that the controller is off. +reset_commands +printf '1\n' >"$SHOW_FAILS" +"$POWER" off +assert_eq $'bluetoothctl list\nbluetoothctl power off\nbluetoothctl show\nbluetoothctl list\nbluetoothctl power off\nbluetoothctl show' \ + "$(<"$LOG")" 'off retries after an unreadable state' # Simulate systemd restarting bluetoothd with AutoEnable=true. Restore must win. reset_commands "$RESET" assert_eq off "$(<"$LIVE")" 'off after systemd login reset' -assert_eq $'systemctl restart bluetooth.service\nbluetoothctl power off' \ +assert_eq $'systemctl restart bluetooth.service\nbluetoothctl list\nbluetoothctl power off\nbluetoothctl show' \ "$(<"$LOG")" 'systemd reset command sequence' # Void has no systemd-rfkill boot restore, so its login reset applies the same @@ -137,9 +165,21 @@ assert_eq $'systemctl restart bluetooth.service\nbluetoothctl power off' \ reset_commands RYOKU_SYSTEMD_RUNTIME="$WORK/no-systemd" "$RESET" assert_eq off "$(<"$LIVE")" 'off after runit login reset' -assert_eq $'sv restart bluetoothd\nbluetoothctl power off' \ +assert_eq $'sv restart bluetoothd\nbluetoothctl list\nbluetoothctl power off\nbluetoothctl show' \ "$(<"$LOG")" 'runit reset command sequence' +# The login reset races bluetoothd: the controller registers a beat after the +# restart and AutoEnable is still powering it on, so power off first finds no +# controller, then gets Busy. The saved off choice must still win. +export RYOKU_TEST_APPEAR_AFTER=2 +reset_commands +printf '1\n' >"$BUSY" +"$RESET" || { printf 'FAIL: login reset failed on a late, busy controller\n' >&2; exit 1; } +assert_eq off "$(<"$LIVE")" 'off after a late, busy controller' +assert_eq $'systemctl restart bluetooth.service\nbluetoothctl list\nbluetoothctl list\nbluetoothctl power off\nbluetoothctl list\nbluetoothctl power off\nbluetoothctl show' \ + "$(<"$LOG")" 'late, busy controller command sequence' +export RYOKU_TEST_APPEAR_AFTER=1 + # Power-on waits for a controller that re-enumerates after rfkill is cleared. export RYOKU_TEST_APPEAR_AFTER=3 printf 'yes\n' >"$BLOCKED" diff --git a/tests/fedora-packages.sh b/tests/fedora-packages.sh index 5136e540c8..04b517e453 100755 --- a/tests/fedora-packages.sh +++ b/tests/fedora-packages.sh @@ -142,6 +142,11 @@ add_lane udev desktop for pkg in dualsensectl dbus hidapi libusb; do add_lane "$pkg" optional; done # ryoku-gpu-lib32 installs the 32-bit GPU baseline on demand for games. for pkg in lib32-mesa lib32-vulkan-icd-loader lib32-vulkan-nouveau; do add_lane "$pkg" optional; done +# The radio player remains in Arch's base set so ISO pacstrap installs it, but +# Fedora owns it through RPM weak dependencies and the default-on radio +# component. It must not re-enter the Fedora desktop lane as a hard package. +for pkg in mpv mpv-mpris; do expected_lanes[$pkg]='|optional|'; done + canonical_lanes() { local encoded=$1 lane out= @@ -215,6 +220,18 @@ spec_field() { ' "$path" } +hard_dependency_satisfied() { + local arch_dep=$1 expected_dep=$2 file_requirement + [[ -n ${hard[$expected_dep]:-}${provided[$expected_dep]:-} ]] && return 0 + case $arch_dep in + ffmpeg) + file_requirement=/usr/bin/ffmpeg + [[ -n ${hard[$file_requirement]:-} ]] + ;; + *) return 1 ;; + esac +} + declare -A spec_names=() spec_paths=() spec_refs=() spec_count=0 for spec in "$SPECS"/*.spec; do @@ -258,7 +275,7 @@ for spec_pkg in "${!spec_names[@]}"; do mapping=${row_fedora[$arch_dep]} [[ $mapping != - ]] || continue for expected_dep in $mapping; do - [[ -n ${hard[$expected_dep]:-}${provided[$expected_dep]:-} ]] \ + hard_dependency_satisfied "$arch_dep" "$expected_dep" \ || fail "$spec_pkg spec misses translated dependency $expected_dep (from $arch_dep)" done done < <(pkgbuild_field "$pkgbuild" depends) diff --git a/tests/install-offline.sh b/tests/install-offline.sh index 33a1469b5b..23ab88b637 100755 --- a/tests/install-offline.sh +++ b/tests/install-offline.sh @@ -53,12 +53,14 @@ EOF chmod +x "$trip/$cmd" done -# run_offline [variant]: the backend in dry-run. Leaves output -# in $out and the exit code in $rc. PATH is prefixed with the tripwires. +# run_offline [variant] [login-shell]: the backend in dry-run. +# Leaves output in $out and the exit code in $rc. PATH is prefixed with the +# tripwires. run_offline() { rc=0 out="$(PATH="$trip:$PATH" RYOKU_DRYRUN=1 RYOKU_REPO="$root" \ - RYOKU_VARIANT="${3:-plain}" RYOKU_ONLINE="$2" RYOKU_OFFLINE_REPO="$1" \ + RYOKU_VARIANT="${3:-plain}" RYOKU_LOGIN_SHELL="${4:-}" \ + RYOKU_ONLINE="$2" RYOKU_OFFLINE_REPO="$1" \ RYOKU_DISK=/dev/vda RYOKU_PASSWORD_HASH='$6$fake$hash' \ RYOKU_DISK_STRATEGY=whole RYOKU_ENCRYPT=0 RYOKU_SWAP_GIB=8 \ bash "$root/installation/backend/ryoku-install" 2>&1)" || rc=$? @@ -222,8 +224,8 @@ done < <(grep -rnE '^[^#]*run arch-chroot /mnt ryoku-[a-z-]+' "$root/installatio ) # ---- 8. a CachyOS install drops the names CachyOS replaces -------------------- -# the stock kernel joins the drop list, which keeps it out of pacstrap and puts it -# in the provisioning ledger, so `ryoku update` never delivers it afterwards. +# The stock kernel and headers join the drop list, which keeps them out of +# pacstrap and the update manifest after the CachyOS kernel pairs replace them. # shellcheck disable=SC2329 ( log() { :; } @@ -234,9 +236,9 @@ done < <(grep -rnE '^[^#]*run arch-chroot /mnt ryoku-[a-z-]+' "$root/installatio export RYOKU_REPO=$root RYOKU_DROP_PACKAGES=chromium ryoku_cachyos_drops - [[ $RYOKU_DROP_PACKAGES == chromium,linux,yay-bin ]] || fail "CachyOS drop list is '$RYOKU_DROP_PACKAGES'" + [[ $RYOKU_DROP_PACKAGES == chromium,linux,linux-headers,yay-bin ]] || fail "CachyOS drop list is '$RYOKU_DROP_PACKAGES'" ryoku_cachyos_drops - [[ $RYOKU_DROP_PACKAGES == chromium,linux,yay-bin ]] || fail "CachyOS drops are not idempotent: '$RYOKU_DROP_PACKAGES'" + [[ $RYOKU_DROP_PACKAGES == chromium,linux,linux-headers,yay-bin ]] || fail "CachyOS drops are not idempotent: '$RYOKU_DROP_PACKAGES'" ) # ---- 9. CachyOS identity is applied without a second first-login welcome ----- @@ -258,4 +260,44 @@ run_offline "$baked" 0 plain ! grep -qF 'cachyos-hello.desktop' <<<"$out" \ || fail "plain install suppressed a CachyOS-only autostart: $out" +# ---- 10. zsh installs seed the packaged startup file on every ISO flavour ----- +for variant in plain cachyos; do + run_offline "$baked" 0 "$variant" zsh + [[ $rc -eq 0 ]] || fail "$variant zsh dry run exited $rc: $out" + grep -qF 'seed /mnt/home/ryoku/.zshrc from /mnt/usr/share/ryoku/zsh/zshrc when missing' <<<"$out" \ + || fail "$variant zsh install did not plan the startup config seed: $out" + + target="$tmp/zsh-$variant" + mkdir -p "$target/usr/share/ryoku/zsh" "$target/home/ryoku" + cp "$root/ryoku/hub/backend/zshrc" "$target/usr/share/ryoku/zsh/zshrc" + ROOT="$root" TARGET="$target" bash -c ' + # shellcheck source=/dev/null + source "$ROOT/installation/backend/lib/common.sh" + # shellcheck source=/dev/null + source "$ROOT/installation/backend/lib/deploy.sh" + RYOKU_TARGET_ROOT=$TARGET + RYOKU_LOGIN_SHELL=zsh + RYOKU_DRYRUN= + RYOKU_ACCOUNT_NAMES=(ryoku) + ryoku_seed_login_shell_config + ' || fail "$variant zsh seed failed against the fake target" + cmp "$root/ryoku/hub/backend/zshrc" "$target/home/ryoku/.zshrc" \ + || fail "$variant zsh seed did not copy the packaged template" + + printf 'custom zsh config\n' >"$target/home/ryoku/.zshrc" + ROOT="$root" TARGET="$target" bash -c ' + # shellcheck source=/dev/null + source "$ROOT/installation/backend/lib/common.sh" + # shellcheck source=/dev/null + source "$ROOT/installation/backend/lib/deploy.sh" + RYOKU_TARGET_ROOT=$TARGET + RYOKU_LOGIN_SHELL=zsh + RYOKU_DRYRUN= + RYOKU_ACCOUNT_NAMES=(ryoku) + ryoku_seed_login_shell_config + ' || fail "$variant existing zsh config check failed" + [[ $(<"$target/home/ryoku/.zshrc") == 'custom zsh config' ]] \ + || fail "$variant zsh seed overwrote an existing config" +done + echo "install-offline: OK" diff --git a/tests/radio-engine.sh b/tests/radio-engine.sh index ee19c3794a..3a203ab80b 100755 --- a/tests/radio-engine.sh +++ b/tests/radio-engine.sh @@ -64,10 +64,10 @@ else fi # ---- off status -------------------------------------------------------------- -if "$radio" status | jq -e '.on==false and .aside==null' >/dev/null; then - ok "silent status" +if "$radio" status | jq -e '.available==true and .on==false and .aside==null' >/dev/null; then + ok "silent status reports the player" else - bad "silent status" "$("$radio" status)" + bad "silent status reports the player" "$("$radio" status)" fi # ---- start: resolves and hands mpv the stream -------------------------------- @@ -172,6 +172,15 @@ if "$radio" status | jq -e '.on==false' >/dev/null \ else bad "rapid toggle pair lands off" "$("$radio" status); $(pgrep -af '_supervise lofi' || true)" fi +# ---- missing player is explicit ---------------------------------------------- +mv "$work/bin/mpv" "$work/bin/mpv.off" +if "$radio" status | jq -e '.available==false and .on==false' >/dev/null; then + ok "missing mpv is explicit in status" +else + bad "missing mpv is explicit in status" "$("$radio" status)" +fi +mv "$work/bin/mpv.off" "$work/bin/mpv" + # ---- one title prefix across the stack ---------------------------------------- # the engine's forced title, the launcher's matcher and the media service's copy diff --git a/tests/reload-cover.sh b/tests/reload-cover.sh index 3e968ab7b6..2aed96bbd7 100755 --- a/tests/reload-cover.sh +++ b/tests/reload-cover.sh @@ -92,6 +92,37 @@ if env -u WAYLAND_DISPLAY -u RYOKU_RELOAD_COVER_TEST "$cover" begin >/dev/null 2 fi test ! -e "$XDG_RUNTIME_DIR/ryoku-reload-cover.json" +# A graphics hang can stop the cover's QML timer with the surface still mapped. +# The launcher's process watchdog must tear that instance down independently. +mkdir -p "$tmp/bin" +cat >"$tmp/bin/qs" <<'EOF' +#!/usr/bin/env bash +if [[ ${3:-} == ipc ]]; then + case "${6:-}" in + mapped|covered) echo true ;; + status) echo hold ;; + *) echo false ;; + esac + exit 0 +fi +exec sleep 300 +EOF +chmod +x "$tmp/bin/qs" +token=$(PATH="$tmp/bin:$PATH" WAYLAND_DISPLAY=wayland-test \ + RYOKU_SHELL_DIR="$root/ryoku/shell" \ + RYOKU_RELOAD_COVER_WATCHDOG_TICKS=10 "$cover" begin boot) +test -n "$token" +stuck_pid=$(jq -r .pid "$XDG_RUNTIME_DIR/ryoku-reload-cover.json") +for _ in $(seq 1 40); do + [[ ! -e $XDG_RUNTIME_DIR/ryoku-reload-cover.json ]] && break + sleep 0.1 +done +test ! -e "$XDG_RUNTIME_DIR/ryoku-reload-cover.json" +if kill -0 "$stuck_pid" 2>/dev/null; then + echo "process watchdog left a stuck cover alive" >&2 + exit 1 +fi + echo "reload cover launcher: PASS" if [[ ${RYOKU_RELOAD_COVER_LIVE:-0} == 1 ]]; then diff --git a/tests/sddm-greeter-card.sh b/tests/sddm-greeter-card.sh index 454459703b..959372bc77 100755 --- a/tests/sddm-greeter-card.sh +++ b/tests/sddm-greeter-card.sh @@ -14,6 +14,7 @@ fail() { printf 'sddm-greeter-card: %s\n' "$*" >&2; exit 1; } cat >"$bin/modetest" <<'STUB' #!/usr/bin/env bash printf '%s\n' "$@" >"$RYOKU_TEST_MODETEST_LOG" +[ "${RYOKU_TEST_MODETEST_FAIL:-0}" = 1 ] && exit 255 printf 'Connectors:\nid\tencoder\tstatus\t\tname\t\tsize (mm)\tmodes\tencoders\n' printf '31\t30\tconnected\t%s\t300x190\t\t1\t30\n' "$RYOKU_TEST_CONNECTOR" printf ' modes:\n\tindex name refresh (Hz) hdisp hss hse htot vdisp vss vse vtot\n' @@ -48,11 +49,18 @@ reset_fixture() { } add_card() { - local number=$1 render=$2 boot=${3:-0} vendor=${4:-0x0000} + local number=$1 render=$2 boot=${3:-0} vendor=${4:-0x0000} driver local card=$tmp/drm/card$number mkdir -p "$card/device/drm" printf '%s\n' "$boot" >"$card/device/boot_vga" printf '%s\n' "$vendor" >"$card/device/vendor" + case "$vendor" in + 0x10de) driver=nvidia ;; + 0x1002) driver=amdgpu ;; + 0x8086) driver=i915 ;; + *) driver=virtio_gpu ;; + esac + printf 'DRIVER=%s\n' "$driver" >"$card/device/uevent" [ "$render" = none ] || : >"$card/device/drm/$render" } @@ -71,7 +79,7 @@ assert_log() { run_case() { local name=$1 card=$2 connector=$3 pixman=${4:-0} - local weston_expected + local weston_expected driver RYOKU_GREETER_DRM_ROOT="$tmp/drm" \ RYOKU_GREETER_CONF="$tmp/missing-greeter.conf" \ RYOKU_GREETER_PRIMARY_FILE="$tmp/missing-primary" \ @@ -87,7 +95,8 @@ run_case() { PATH="$bin:$PATH" \ "$greeter" >/dev/null 2>"$tmp/run/stderr" || fail "$name did not start weston" - assert_log "$tmp/run/modetest" $'-D\n/dev/dri/'"$card"$'\n-c' "$name modetest argv" + driver=$(sed -n 's/^DRIVER=//p' "$tmp/drm/$card/device/uevent") + assert_log "$tmp/run/modetest" $'-M\n'"$driver"$'\n-c' "$name modetest argv" weston_expected=$'--shell=kiosk\n--drm-device='"$card"$'\n' [ "$pixman" = 0 ] || weston_expected+=$'--renderer=pixman\n' weston_expected+='--config=' @@ -154,4 +163,32 @@ add_card 1 renderD129 0 0x1002 add_connector 1 DP-1 connected run_case 'desktop external output' card1 DP-1 +# A card modetest cannot open (its DRM driver name differs from the kernel +# driver, as with nvidia-drm) must start weston at once instead of polling +# until the deadline in front of a black screen. +reset_fixture +add_card 0 renderD128 1 0x8086 +add_connector 0 eDP-1 connected +started=$(date +%s%N) +RYOKU_GREETER_DRM_ROOT="$tmp/drm" \ + RYOKU_GREETER_CONF="$tmp/missing-greeter.conf" \ + RYOKU_GREETER_PRIMARY_FILE="$tmp/missing-primary" \ + RYOKU_GREETER_NUMLOCK_FILE="$tmp/missing-numlock" \ + RYOKU_GREETER_SETTLE=1 \ + RYOKU_GREETER_DEADLINE=6 \ + RYOKU_TEST_MODETEST_FAIL=1 \ + RYOKU_TEST_MODETEST_LOG="$tmp/run/modetest" \ + RYOKU_TEST_WESTON_LOG="$tmp/run/weston" \ + RYOKU_TEST_LOGGER_LOG="$tmp/run/logger" \ + RYOKU_GREETER_RETRY_LIMIT=1 \ + RYOKU_TEST_CONNECTOR=eDP-1 \ + TMPDIR="$tmp/run" \ + PATH="$bin:$PATH" \ + "$greeter" >/dev/null 2>"$tmp/run/stderr" || fail "unprobeable card did not start weston" +elapsed_ms=$(( ($(date +%s%N) - started) / 1000000 )) +[ "$elapsed_ms" -lt 2000 ] || + fail "unprobeable card waited ${elapsed_ms}ms for the settle deadline before starting weston" +grep -Fqx -- '--drm-device=card0' "$tmp/run/weston" || fail "unprobeable card lost its card pin" +printf 'sddm-greeter-card: unprobeable card -> plain weston in %sms\n' "$elapsed_ms" + printf 'sddm-greeter-card: PASS\n' diff --git a/tests/sddm-greeter-supervise.sh b/tests/sddm-greeter-supervise.sh index 8cf5e787d1..dd4d732313 100755 --- a/tests/sddm-greeter-supervise.sh +++ b/tests/sddm-greeter-supervise.sh @@ -52,6 +52,7 @@ add_card() { mkdir -p "$card/device/drm" "$tmp/drm/card$1-eDP-1" printf '1\n' >"$card/device/boot_vga" printf '0x1002\n' >"$card/device/vendor" + printf 'DRIVER=amdgpu\n' >"$card/device/uevent" : >"$card/device/drm/renderD128" printf 'connected\n' >"$tmp/drm/card$1-eDP-1/status" } diff --git a/tests/sddm-wayland.sh b/tests/sddm-wayland.sh index f95d1cc136..c624b12ab7 100755 --- a/tests/sddm-wayland.sh +++ b/tests/sddm-wayland.sh @@ -45,6 +45,7 @@ printf '#!/bin/sh\nexit 0\n' >"$gtmp/bin/weston" # eDP. Mode lines follow modetest's real columns (index name refresh ...). cat >"$gtmp/bin/modetest" <<'STUB' #!/bin/sh +[ "${RYOKU_TEST_MODETEST_FAIL:-0}" = 1 ] && exit 255 printf 'Connectors:\nid\tencoder\tstatus\t\tname\t\tsize (mm)\tmodes\tencoders\n' printf '393\t392\tconnected\tHDMI-A-1 \t610x350\t\t2\t392\n' printf ' modes:\n\tindex name refresh (Hz) hdisp hss hse htot vdisp vss vse vtot\n' @@ -61,6 +62,7 @@ drm=$gtmp/drm mkdir -p "$drm/card0/device/drm/renderD128" "$drm/card0-HDMI-A-1" "$drm/card0-eDP-1" printf 'connected\n' >"$drm/card0-HDMI-A-1/status" printf 'connected\n' >"$drm/card0-eDP-1/status" +printf 'DRIVER=amdgpu\n' >"$drm/card0/device/uevent" gdry() { PATH="$gtmp/bin:$PATH" RYOKU_GREETER_DRYRUN=1 RYOKU_GREETER_CONF="$gtmp/none.conf" \ RYOKU_GREETER_DRM_ROOT="$drm" RYOKU_GREETER_NUMLOCK_FILE="$gtmp/no-numlock" \ @@ -107,4 +109,17 @@ pinned "$gini" eDP-1 || fail "a stale hand-off must fall back to the internal pa gini=$(gdry RYOKU_GREETER_PRIMARY_FILE="$gtmp/primary" RYOKU_GREETER_PRIMARY=eDP-1) pinned "$gini" eDP-1 || fail "RYOKU_GREETER_PRIMARY must outrank the session hand-off" +# modetest opens a card by driver name, so with two cards on one driver it can +# answer for the other one: only connectors sysfs reports connected on the +# selected card may become [output] blocks. +printf 'disconnected\n' >"$drm/card0-HDMI-A-1/status" +gini=$(gdry) +grep -Fq 'name=HDMI-A-1' <<<"$gini" && fail "greeter configured a connector sysfs reports disconnected on its card" +grep -Fq 'name=eDP-1' <<<"$gini" || fail "greeter dropped the panel sysfs reports connected" +printf 'connected\n' >"$drm/card0-HDMI-A-1/status" + +# A card modetest cannot open leaves weston on its preferred modes. +gini=$(gdry RYOKU_TEST_MODETEST_FAIL=1) +grep -Fq '[output]' <<<"$gini" && fail "greeter wrote [output] blocks from a probe that failed" + printf 'sddm-wayland: PASS\n' diff --git a/tests/ui/ryostore-apps-probe.qml b/tests/ui/ryostore-apps-probe.qml new file mode 100644 index 0000000000..3eca303ec8 --- /dev/null +++ b/tests/ui/ryostore-apps-probe.qml @@ -0,0 +1,180 @@ +import QtQuick +import Quickshell +import "ryostore" as Ryo +import "ryostore/Singletons" as RyoState + +ShellRoot { + id: root + + property int phase: 0 + property int attempts: 0 + property string firstKey: "" + property string proofDir: Quickshell.env("RYOSTORE_PROOF_DIR") + property bool proofPending: false + readonly property int captureWidth: Number(Quickshell.env("RYOSTORE_PROBE_WIDTH") || "1180") + + FloatingWindow { + id: win + implicitWidth: root.captureWidth + implicitHeight: 760 + + Ryo.App { + id: app + anchors.fill: parent + } + } + + function require(condition, label) { + if (!condition) + throw new Error("RYOSTORE-APPS-PROBE-FAIL " + label); + } + + function findObject(item, name) { + if (!item) + return null; + if (item.objectName === name) + return item; + const children = item.children || []; + for (const child of children) { + const found = root.findObject(child, name); + if (found) + return found; + } + return null; + } + + function keys() { + return app.collection.map(item => item.id); + } + + function saveProof(name, done) { + if (proofDir === "") { + done(); + return; + } + proofPending = true; + app.grabToImage(function(result) { + root.require(result.saveToFile(proofDir + "/" + name), "saved " + name); + proofPending = false; + done(); + }); + } + + Timer { + interval: 50 + repeat: true + running: true + onTriggered: { + root.attempts++; + if (root.attempts > 300) + throw new Error("RYOSTORE-APPS-PROBE-FAIL timed out in phase " + root.phase); + + if (root.phase === 0) { + if (RyoState.Store.items.length < 5) + return; + app.openRoute("flatpak"); + root.phase = 1; + return; + } + + if (root.phase === 1) { + if (!app.appsBrowse) + return; + if (root.captureWidth < 980) { + root.require(app.narrowAppsTools, "narrow Apps tools breakpoint"); + const tools = root.findObject(app, "ryostore-apps-tools"); + const search = root.findObject(app, "ryostore-apps-search"); + root.require(tools && search && tools.height >= 88, + "narrow Apps tools reserve a second row"); + root.require(search.y >= 44, + "narrow Apps search sits below the sort chips"); + } + root.require(root.findObject(app, "ryostore-apps-hero") !== null, "featured hero is mounted"); + root.require(root.findObject(app, "ryostore-apps-sort") !== null, "sort strip is mounted"); + const grid = root.findObject(app, "ryostore-grid"); + root.require(grid !== null, "compact grid is mounted"); + root.require(grid.compact, "grid uses the compact app layout"); + root.require(grid.cellH < 140, "compact cells are shorter than the artwork cards"); + root.require(JSON.stringify(root.keys()) === JSON.stringify( + ["org.example.Gamma", "org.example.Alpha", "org.example.Beta", + "org.example.Epsilon", "org.example.Delta"]), + "Trending is the default order"); + root.firstKey = app.selectedKey; + const popular = root.findObject(app, "ryostore-provider-popular"); + root.require(popular !== null, "Popular plate exists"); + popular.chose(); + root.phase = 2; + return; + } + + if (root.phase === 2) { + if (app.appSort !== "popular") + return; + root.require(root.keys()[0] === "org.example.Beta", "Popular reorders by monthly installs"); + root.require(app.selectedKey !== root.firstKey, "selection follows the new order"); + app.selectKey("flatpak:org.example.Beta"); + app.openSelectedDetail(); + root.phase = 3; + return; + } + + if (root.phase === 3) { + if (!app.detailOpen) + return; + root.require(app.detailItem && app.detailItem.id === "org.example.Beta", "detail opens the selected app"); + app.escapeLayer(); + root.phase = 4; + return; + } + + if (root.phase === 4) { + if (app.detailOpen || app.detailItem !== null || !app.appsBrowse) + return; + root.require(app.selectedKey === "flatpak:org.example.Beta", "closing the detail restores the app selection"); + root.require(app.appSort === "popular", "closing the detail keeps the chosen sort"); + root.require(app.appTabs.length === 4, "market tabs list the categories present"); + root.require(root.findObject(app, "ryostore-apps-tabs") !== null, "category strip is mounted"); + root.require(root.findObject(app, "ryostore-apps-search-field") !== null, "market search field is mounted"); + app.setAppTag("game"); + root.phase = 5; + return; + } + + if (root.phase === 5) { + if (app.appsTag !== "game") + return; + root.require(JSON.stringify(root.keys()) === JSON.stringify( + ["org.example.Epsilon", "org.example.Gamma"]), + "a category tab narrows the market"); + root.require(app.appsFiltered, "the market reports a filter"); + root.require(!root.findObject(app, "ryostore-apps-hero").visible, + "the featured shelf hides under a filter"); + app.setAppsQuery("eps"); + root.phase = 6; + return; + } + + if (root.phase === 6) { + if (app.appsQuery !== "eps") + return; + root.require(JSON.stringify(root.keys()) === JSON.stringify(["org.example.Epsilon"]), + "the market search narrows the chosen tab"); + app.clearAppFilter(); + root.phase = 7; + return; + } + + if (app.appsFiltered) + return; + root.require(app.appsTag === "" && app.appsQuery === "", "clearing the filter resets both"); + root.require(root.keys().length === 5, "the whole market returns"); + if (root.proofPending) + return; + root.saveProof(root.captureWidth < 900 + ? "ryostore-apps-narrow.png" : "ryostore-apps-wide.png", function() { + console.log("RYOSTORE-APPS-PROBE-PASS"); + Qt.quit(); + }); + } + } +} diff --git a/tests/ui/ryostore-apps-probe.sh b/tests/ui/ryostore-apps-probe.sh new file mode 100755 index 0000000000..b954043e51 --- /dev/null +++ b/tests/ui/ryostore-apps-probe.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -euo pipefail + +here="$(cd "$(dirname "$0")" && pwd)" +repo="$here/../.." +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +mkdir -p "$work/Ryoku" "$work/bin" +ln -s "$repo/ryoku/ui" "$work/Ryoku/Ui" +cp -a "$repo/ryoku/apps/ryostore/quickshell" "$work/ryostore" +cp "$here/ryostore-apps-probe.qml" "$work/probe.qml" + +cat >"$work/bin/ryostore" <<'FAKE' +#!/usr/bin/env bash +set -euo pipefail +case "${1:-}" in +catalog) + cat <&2 + exit 2 + ;; +esac +FAKE +chmod +x "$work/bin/ryostore" + +PATH="$work/bin:$PATH" \ +QML2_IMPORT_PATH="$work:${QML2_IMPORT_PATH:-$HOME/.local/lib/qt6/qml}" \ + timeout 30 qs -p "$work/probe.qml" >"$work/log" 2>&1 || true +if ! grep -q RYOSTORE-APPS-PROBE-PASS "$work/log"; then + sed -n '1,200p' "$work/log" + exit 1 +fi +if grep -Eq '(^|[[:space:]])ERROR|TypeError|ReferenceError' "$work/log"; then + sed -n '1,200p' "$work/log" + exit 1 +fi +echo "ryostore-apps-probe: hero, sort strip, compact grid, detail restore" diff --git a/tests/ui/ryostore-flow-probe.qml b/tests/ui/ryostore-flow-probe.qml index 35295dcef2..bd76300bc1 100644 --- a/tests/ui/ryostore-flow-probe.qml +++ b/tests/ui/ryostore-flow-probe.qml @@ -9,6 +9,8 @@ ShellRoot { property int phase: 0 property real savedOffset: 0 property int attempts: 0 + property string proofDir: Quickshell.env("RYOSTORE_PROOF_DIR") + property bool proofPending: false FloatingWindow { implicitWidth: 1180 @@ -46,6 +48,19 @@ ShellRoot { return null; } + function saveProof(name, done) { + if (proofDir === "") { + done(); + return; + } + proofPending = true; + app.grabToImage(function(result) { + root.require(result.saveToFile(proofDir + "/" + name), "saved " + name); + proofPending = false; + done(); + }); + } + Timer { interval: 50 repeat: true @@ -79,6 +94,22 @@ ShellRoot { } if (root.phase === 2) { + if (RyoState.Store.busyKey !== "lockscreens:clock") + return; + app.detailItem = root.item("broken"); + const blocked = root.findObject(app, "ryostore-detail-install"); + if (!blocked || blocked.text !== "DOWNLOADING ANOTHER ITEM") + return; + root.require(blocked.armed === false, "another product is visibly disabled while a download runs"); + if (!root.proofPending) + root.saveProof("ryostore-download-blocked.png", function() { + app.detailItem = root.item("clock"); + root.phase = 3; + }); + return; + } + + if (root.phase === 3) { const installed = root.item("clock"); if (!installed || !installed.installed || RyoState.Store.busyKey !== "") return; @@ -86,11 +117,11 @@ ShellRoot { root.require(app.detailItem.installed === true, "detail refreshed from backend"); app.closeDetail(); root.require(app.selectedKey === "lockscreens:clock", "detail restored selection"); - root.phase = 3; + root.phase = 4; return; } - if (root.phase === 3) { + if (root.phase === 4) { const grid = root.findObject(app, "ryostore-grid"); if (!grid) return; @@ -105,11 +136,11 @@ ShellRoot { const flowHeader = root.findObject(app, "ryostore-header"); if (flowHeader) flowHeader.focusSearch(); - root.phase = 4; + root.phase = 5; return; } - if (root.phase === 4) { + if (root.phase === 5) { const searchField = root.findObject(app, "ryostore-header-search-field"); if (searchField && !searchField.activeFocus) searchField.forceActiveFocus(); @@ -118,11 +149,11 @@ ShellRoot { root.require(app.collection.length === 1, "showroom search projection"); root.require(app.selectedKey === "lockscreens:clock", "search retained matching selection"); app.openSelectedDetail(); - root.phase = 5; + root.phase = 6; return; } - if (root.phase === 5) { + if (root.phase === 6) { const detail = root.findObject(app, "ryostore-detail"); const detailClose = root.findObject(detail, "ryostore-detail-close"); if (!app.detailOpen || !detail || !detailClose || !detailClose.activeFocus) @@ -130,31 +161,49 @@ ShellRoot { root.require(detail.activeFocus, "detail focus scope owns keyboard focus"); root.require(detailClose.visible, "detail exposes a visible close action"); app.escapeLayer(); - root.phase = 6; + root.phase = 7; return; } - if (root.phase === 6) { + if (root.phase === 7) { const searchField = root.findObject(app, "ryostore-header-search-field"); if (!app.searchOpen || !searchField || !searchField.activeFocus) return; root.require(app.query === "installed clock", "detail restores query and search keyboard focus"); app.escapeLayer(); - root.phase = 7; + root.phase = 8; + return; + } + + if (root.phase === 8) { + const restoredGrid = root.findObject(app, "ryostore-grid"); + if (app.searchOpen || !restoredGrid || !restoredGrid.activeFocus + || Math.abs(restoredGrid.contentY - root.savedOffset) >= 0.5) + return; + root.require(app.categoryID === "lockscreens" && app.selectedKey === "lockscreens:clock", + "search restored exact context"); + root.require(Math.abs(app.gridOffset - root.savedOffset) < 0.5, + "coordinator mirrors restored grid offset"); + const names = app.collection.map(item => String(item.name || item.id)); + for (let i = 1; i < names.length; i++) + root.require(names[i - 1].localeCompare(names[i]) <= 0, "category is ordered A to Z"); + root.require(root.findObject(app, "ryostore-category-search-field") !== null, + "category search field is mounted"); + app.setCategoryQuery("broken"); + root.phase = 9; return; } - const restoredGrid = root.findObject(app, "ryostore-grid"); - if (app.searchOpen || !restoredGrid || !restoredGrid.activeFocus - || Math.abs(restoredGrid.contentY - root.savedOffset) >= 0.5) + if (app.collection.length !== 1 || app.collection[0].id !== "broken") return; - root.require(app.categoryID === "lockscreens" && app.selectedKey === "lockscreens:clock", - "search restored exact context"); - root.require(Math.abs(app.gridOffset - root.savedOffset) < 0.5, - "coordinator mirrors restored grid offset"); - console.log("RYOSTORE-FLOW-PROBE-PASS"); - Qt.quit(); + root.require(app.categoryID === "lockscreens" && !app.searchOpen, + "category search remains scoped to the open category"); + if (!root.proofPending) + root.saveProof("ryostore-category-search.png", function() { + console.log("RYOSTORE-FLOW-PROBE-PASS"); + Qt.quit(); + }); } } } diff --git a/tests/ui/ryostore-flow-probe.sh b/tests/ui/ryostore-flow-probe.sh index 97a1277dca..bb01d0394c 100755 --- a/tests/ui/ryostore-flow-probe.sh +++ b/tests/ui/ryostore-flow-probe.sh @@ -28,6 +28,7 @@ install) exit 9 fi [[ ${2:-} == lockscreens && ${3:-} == clock ]] || { echo "unexpected install: $*" >&2; exit 2; } + sleep 2 : >"$state" ;; *) diff --git a/tests/visualizer-placement.sh b/tests/visualizer-placement.sh index 3454bf18e7..784a0833e9 100755 --- a/tests/visualizer-placement.sh +++ b/tests/visualizer-placement.sh @@ -6,11 +6,16 @@ viz="$root/ryoku/shell/quickshell/shell/modules/visualizer/Visualizer.qml" grip="$root/ryoku/shell/quickshell/shell/modules/visualizer/VisualizerGrip.qml" config="$root/ryoku/shell/quickshell/shell/modules/visualizer/Singletons/Config.qml" desktop="$root/ryoku/shell/quickshell/shell/modules/desktop/Desktop.qml" +shell="$root/ryoku/shell/quickshell/shell/shell.qml" fail() { printf 'visualizer placement: %s\n' "$*" >&2; exit 1; } grep -Fq 'function dataAt(index)' "$config" || fail 'missing stable per-instance accessor' grep -Fq 'model: Config.count' "$viz" || fail 'render repeater rebuilds on every config object update' +grep -Fq '&& perScreen.st && perScreen.st.visualizerOverlay' "$shell" \ + || fail 'the separate surface is not gated by explicit overlay mode' +! grep -Fq '&& !desktop.hostsVisualizer && !desktop.plainVisualizer' "$shell" \ + || fail 'the separate surface infers overlay mode from inline host timing' # Placement lives in the Stage Editor now: the grip rides the desktop surface # and writes the box through the same store the old placer did. The standalone diff --git a/tests/void-packages.sh b/tests/void-packages.sh index 97be43de33..cc20ce7102 100755 --- a/tests/void-packages.sh +++ b/tests/void-packages.sh @@ -166,6 +166,11 @@ add_lane ryoku-keyring desktop add_lane ryoku-palette-bridge desktop add_lane udev desktop for pkg in dualsensectl dbus hidapi libusb; do add_lane "$pkg" optional; done +# The radio player remains in Arch's base set so ISO pacstrap installs it, but +# Void owns it through the default-on radio component rather than the desktop +# package. It must not re-enter the Void desktop lane as a hard package. +for pkg in mpv mpv-mpris; do expected_lanes[$pkg]='|optional|'; done + # The GPU helper still requests Nouveau on demand; the gaming component owns # its baseline Mesa and Vulkan packages. add_lane lib32-vulkan-nouveau optional diff --git a/void/iso/backend/lib/deploy.sh b/void/iso/backend/lib/deploy.sh index d290997ee4..399e09c31b 100755 --- a/void/iso/backend/lib/deploy.sh +++ b/void/iso/backend/lib/deploy.sh @@ -37,6 +37,7 @@ void_deploy_user() { local user=$RYOKU_USERNAME local home=/mnt/home/$user + ryoku_seed_login_shell_config ryoku_seed_keymap ryoku_deploy_chown "$user" ryoku_deploy_materialize "$user" diff --git a/void/packages/srcpkgs/ryoku-desktop/template b/void/packages/srcpkgs/ryoku-desktop/template index 0f9fe41b5c..c3d95cfbc1 100644 --- a/void/packages/srcpkgs/ryoku-desktop/template +++ b/void/packages/srcpkgs/ryoku-desktop/template @@ -33,8 +33,6 @@ depends=" syntax-highlighting brightnessctl playerctl - mpv - mpv-mpris yt-dlp wl-clipboard wl-clip-persist diff --git a/void/packages/translations.tsv b/void/packages/translations.tsv index 7b7ff42fd6..f843271684 100644 --- a/void/packages/translations.tsv +++ b/void/packages/translations.tsv @@ -143,8 +143,8 @@ matugen matugen desktop mesa mesa-dri mesa-vaapi desktop mise mise dev mkinitcpio dracut system -mpv mpv desktop -mpv-mpris mpv-mpris desktop +mpv mpv optional +mpv-mpris mpv-mpris optional nautilus nautilus desktop nautilus-python nautilus-python desktop neovim neovim desktop