Watch
1
0
Fork
You've already forked souveraine
0

shell: dock reorder, fullscreen detection fix, idle-power, sessiond, misc shell work

- Dock drag-to-reorder for pinned apps (insertion gap, quick-slide vs dwell)
- Fullscreen detection: scan all windows via HyprlandData.windowList
- IdleCoordinator, GlobalStates, Session.qml updates
- Deploy script, qmldir, settings, wallpaper, visualizer fixes
- sessiond server, memory module updates

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Fimeg 2026-07-22 22:18:20 -04:00
commit 19fe6b1d0e
59 changed files with 19450 additions and 139 deletions

View file

@ -0,0 +1,160 @@
# Maintainer: Zoddo <archlinux+aur@zoddo.fr>
# Contributor: Thaodan <AUR+me@thaodan.de>
# Contributor: Manuel Hüsers <aur@huesers.de>
# Contributor: huyizheng
# Contributor: johnnyapol <arch@johnnyapol.me>
# Based off the discord community repo PKGBUILD by Filipe Laíns (FFY00) <lains@archlinux.org>
_pkgname=discord
_electron=electron
pkgname=${_pkgname}_arch_electron
pkgver=1.0.146
pkgrel=1
epoch=1
pkgdesc="Discord using system provided ${_electron} for increased security and performance"
arch=('x86_64')
provides=("${_pkgname}")
conflicts=("${_pkgname}")
url='https://discord.com'
license=('LicenseRef-custom')
options=('!strip')
install="$pkgname.install"
depends=("${_electron}" 'libxss')
makedepends=('asar'
'jq'
'python-pyelftools' # Required for Krisp patcher
'python-capstone') # Required for Krisp patcher
optdepends=('libpulse: Pulseaudio support'
'xdg-utils: Open files')
source=("https://dl.discordapp.net/apps/linux/${pkgver}/${_pkgname}-${pkgver}.tar.gz"
'LICENSE.html::https://discord.com/terms'
'OSS-LICENSES.html::https://discord.com/licenses'
'discord-launcher.sh'
'krisp-patcher.py'
# Discord modules (from 'curl "https://updates.discord.com/distributions/app/manifests/latest?channel=stable&platform=linux&arch=x64"')
"core-${pkgver}.tar.br::https://stable.dl2.discordapp.net/distro/app/stable/linux/x64/${pkgver}/full.distro"
"discord_desktop_core-${pkgver}.tar.br::https://stable.dl2.discordapp.net/distro/app/stable/linux/x64/${pkgver}/discord_desktop_core/1/full.distro"
"discord_zstd-${pkgver}.tar.br::https://stable.dl2.discordapp.net/distro/app/stable/linux/x64/${pkgver}/discord_zstd/1/full.distro"
"discord_krisp-${pkgver}.tar.br::https://stable.dl2.discordapp.net/distro/app/stable/linux/x64/${pkgver}/discord_krisp/1/full.distro"
"discord_rpc-${pkgver}.tar.br::https://stable.dl2.discordapp.net/distro/app/stable/linux/x64/${pkgver}/discord_rpc/1/full.distro"
"discord_utils-${pkgver}.tar.br::https://stable.dl2.discordapp.net/distro/app/stable/linux/x64/${pkgver}/discord_utils/1/full.distro"
"discord_voice-${pkgver}.tar.br::https://stable.dl2.discordapp.net/distro/app/stable/linux/x64/${pkgver}/discord_voice/1/full.distro"
"discord_game_utils-${pkgver}.tar.br::https://stable.dl2.discordapp.net/distro/app/stable/linux/x64/${pkgver}/discord_game_utils/1/full.distro"
"discord_erlpack-${pkgver}.tar.br::https://stable.dl2.discordapp.net/distro/app/stable/linux/x64/${pkgver}/discord_erlpack/1/full.distro"
"discord_modules-${pkgver}.tar.br::https://stable.dl2.discordapp.net/distro/app/stable/linux/x64/${pkgver}/discord_modules/1/full.distro"
"discord_spellcheck-${pkgver}.tar.br::https://stable.dl2.discordapp.net/distro/app/stable/linux/x64/${pkgver}/discord_spellcheck/1/full.distro"
"discord_dispatch-${pkgver}.tar.br::https://stable.dl2.discordapp.net/distro/app/stable/linux/x64/${pkgver}/discord_dispatch/1/full.distro")
sha512sums=('b90cf25a6e615e836e0ef248b3090f23d90c2a6d73faeb21954ba669fb39275c26ac58a8f9e92445254c29fb90716cab54bee06f701cde88f74578c4228bc22b'
'2590151db4404a9ecbae2c45269c74f5ea26479967de5bc0b221a9143f4efeeeda9e335c23cf1f982680ab4fd895dcf3fd5731bd230908b3b2568eee0ec223ec'
'5cccf397a772fed0db9b19253e496e78af27810227325ce681de985e2e0fbbdb195873d9642574ad3ae7bfcf4699bfdaa16933ca50c13cb98a1ff53c86c6de05'
'd5373e2b2e9754bdcdbcda81fc1392ce6605939651839868823f3c3e0bf16f6fba2fb73bc1a0ff230eebd743d1463f2e8b2295503248f14845b3a56e57385bd1'
'85da93530b2b92faad58cc84d0f7ddaea6b22fbec413e806c1981eb6369a301ec995eee6bfbe6b828e2cd0baf6168e578bbde456f2450a13199994ff9a1e7cd0'
'dca9bbf55afcb1425af7bd0fc4d891fe0cc37859436a8b2a1ae9d29689d72e574dc2f974d468a86527c0420847fd27af5517da7b02075ee2eebe8bd2d43a7d09'
'88024b0faf4e4db8e283af9081b88072ec8ab908e4b72a7c0bab8ae7fcb3c5ae09b2b3e34af0b35429b51f46be359dc39983191e376a762c6c4831d4918a9792'
'b7a676bd7e7cb1a9d57aaad1c828499f7ecdc0360bb982a3138181bb29710354509c50998344004c73ca2b87f7866a603109c2cb79b848b3240fa08a35578b16'
'7283eb04ec779a5f679e7c90f793c5d9bb2a6a44507f966156d831f0013ebcad507fbcc07d037719740861f3e23f95a532b5c9d0da707932a0da9ffd7e17349d'
'6e75733d93b41b4a3d59749ce5331b9da4d6fb5fad1266bf639a948cc497c05b03ea3f15bfd6384e819730bd220dbf34dbb967c90f9683bb2529f73a4ed5897b'
'e88e7ce27b6d406d1fe631964b772067bc52808a74ecb57a96b5585199ec2aceab405321f9d9faf9199870fd83823afc6c5402eb85c9ed41826cedb60d8925d9'
'fc5dd9840f0b13bf8b66dc4901833c701293a914c54765c04c18414755f237284e93e1a7bf2660120507263500f9ab385c1c9966fe0f33e368b3fae9d695b432'
'f23b41b7e55c6611a77767c4ccd7010bfc5a901d241ac91826b191ca7d894e5960eda1afde05a0b52564b555006ce762620b08c9378eff733a0b89c5fd49a3e4'
'755e37330fc16d99f630ed0036eaabf83b4f137123c72eae35fe47996d243ceaed1ddc6f1891efaeffb3f28fa4c0eb9b2be76308df50d98a97be24c37f5e41f0'
'83454dd202685f368d0934c3949e2d3e2ecb30dd89c7a593f9210318828cd3b3b4813e216e45bcc1752b0b0d214f1979bf7ba14d00e0840e0c238cfeec88aa63'
'aee5b9cd718d803618e6f83f4f004853f283e89c5c8bc87b61b85189787aed0c710bba2b9ab81e148e18c685e5a1bcbe18554cc67f6123be360c3299349d0edc'
'959e30368d9c4d4e82fe4beb2457f3287365e1fcb3ee5e44eb8d8e648e30b2b2ec2aa346b7bfa46d24b8f496193b154c89c471c997fe9d7d5b64277dcc5092ca')
# Skip "LICENSE.html" and "OSS-LICENSES.html" files hashes as they are unstable
# Since "updpkgsums"/"pkgctl version upgrade" overwrite the checksum array with
# literal hashes, set them to SKIP with indexed assignments (pacman-contrib#119)
# https://gitlab.archlinux.org/pacman/pacman-contrib/-/issues/119
sha512sums[1]='SKIP'
sha512sums[2]='SKIP'
# Colored makepkg-like functions
_all_off="$(tput sgr0)"
_bold="${_all_off}$(tput bold)"
_blue="${_bold}$(tput setaf 4)"
_red="${_bold}$(tput setaf 1)"
msg_blue() {
printf "${_blue} ->${_bold} $1${_all_off}\n"
}
error() {
printf "${_blue}==>${_red} ERROR:${_bold} %s${_all_off}\n" "$1" >&2
exit 1
}
prepare() {
msg_blue "Extracting core-${pkgver}.tar.br"
tar -xf core-${pkgver}.tar.br --use-compress-program=brotli --one-top-level=core --strip-components=1 files/
for module in discord_*.tar.br; do
msg_blue "Extracting ${module}"
tar -xf "${module}" --use-compress-program=brotli --one-top-level="modules/${module%%-*}" --strip-components=1 files/
done
# prepare launcher script
sed -i -e "s|@PKGNAME@|${_pkgname}|" \
-e "s|@ELECTRON@|${_electron}|" \
discord-launcher.sh
# fix the .desktop file
sed -i -e "s|Exec=.*|Exec=/usr/bin/${_pkgname}|" ${_pkgname^}/$_pkgname.desktop
}
build() {
msg_blue 'Patching app.asar to use system electron'
asar e core/resources/app.asar core/resources/app
rm core/resources/app.asar
sed -i \
-e "s|^Exec=\${exePath}$|Exec=/usr/bin/${_pkgname}|" \
-e "s|^Name=\${appName}$|Name=${_pkgname^}|" \
-e "s|^Icon=\${iconPath}$|Icon=${_pkgname}|" \
-e "s|resourcesPath=path_1\.default\.join(require\.main\.filename,\"\.\.\",\"\.\.\"),|resourcesPath='/usr/share/${_pkgname}/resources',|" \
-e "s|process\.resourcesPath|'/usr/share/${_pkgname}/resources'|g" \
core/resources/app/bundle.js
sed -i -e "s|process\.resourcesPath|'/usr/share/${_pkgname}/resources'|" core/resources/app/splashScreenPreload.js
# This is required to properly show the window icon under wayland
jq ".desktopName = \"${_pkgname}.desktop\"" core/resources/app/package.json > tmp.json
mv tmp.json core/resources/app/package.json
asar p core/resources/app core/resources/app.asar
rm -rf core/resources/app
# Set the newUpdater key to false in build_info.json in order to disable the new Discord updater (released on 2026-05-04, version 1.0.136),
# Also add a localModulesRoot key that points to the Discord modules we're packaging.
jq ".newUpdater = false | .localModulesRoot = \"/usr/share/${_pkgname}/modules\"" core/resources/build_info.json > tmp.json
mv tmp.json core/resources/build_info.json
msg_blue 'Patching Krisp to run with system electron'
# original: https://github.com/sersorrel/sys/blob/main/hm/discord/krisp-patcher.py
python krisp-patcher.py "modules/discord_krisp/discord_krisp.node" \
|| error 'Krisp patcher failed. You can comment it out in the PKGBUILD, but Krisp will not work.'
# Krisp fails to initialize if this directory is not present (or if can't be created). Logs:
# [MediaEngineStore] Failed to load Krisp module: Failed to setup Krisp module, error code: -4
# [AVError] AV error reported: noise-canceller-error {"underlyingError":"NoiseCancellerError.KRISP_INIT_ERROR_GLOBAL_INIT"}
mkdir -p "modules/discord_krisp/KMS/logs"
}
package() {
# create necessary directories
install -d "${pkgdir}/usr/share/${_pkgname}"
# copy relevant data
cp -r core/resources/ "${pkgdir}/usr/share/${_pkgname}/"
cp -r modules/ "${pkgdir}/usr/share/${_pkgname}/"
# intall icon and desktop file
install -Dm 644 ${_pkgname^}/$_pkgname.png "${pkgdir}/usr/share/icons/hicolor/256x256/apps/${_pkgname}.png"
install -Dm 644 ${_pkgname^}/$_pkgname.desktop "${pkgdir}/usr/share/applications/${_pkgname}.desktop"
# install the launch script
install -Dm 755 discord-launcher.sh "${pkgdir}/usr/bin/${_pkgname}"
# install licenses
install -Dm 644 LICENSE.html "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE.html"
install -Dm 644 OSS-LICENSES.html "${pkgdir}/usr/share/licenses/${pkgname}/OSS-LICENSES.html"
}

View file

@ -0,0 +1,104 @@
# Task: Official Discord + BetterDiscord on SouveraineOS (Pixel 3, aarch64)
**Status:** BLOCKED on native Electron. Plan: cross-build Electron (and/or the Discord
package) on an x86 box or a beefier arm64 host, ship the artifacts back to the phone.
**Goal:** Run the *official* Discord desktop client on this device and inject
BetterDiscord into it (theming + plugins). Wrappers (Vesktop/WebCord/Legcord) were
explicitly rejected — we want stock Discord + BetterDiscord specifically.
---
## Device facts (verified on-device 2026-07-14)
- OS: **Souveraine OS (Arch Linux ARM)**, `ID_LIKE=arch`, BUILD_ID 20260707
- Arch: **aarch64**
- Pkg mgrs: `pacman`, `flatpak` (flathub system remote). AUR helpers present: `yay`, `paru`.
- Repo `bun` NOT present; installed manually to `~/.bun/bin/bun` (v1.3.14 aarch64).
- `~/.config/discord` exists but is EMPTY (no app version dir) — nothing to inject into yet.
### What's in the repos (checked with pacman -Ss)
- ✅ `extra/betterdiscordctl 1.6.1-1` — BD manager CLI, installable, needs a Discord install to act on
- ✅ `extra/asar 4.1.2-1` — makedep for the Discord PKGBUILD
- ✅ `jq`, `brotli` — present
- ❌ **NO `electron` package in any form** (no `electron`, no `electron36/37/...`).
`pacman -Ssq electron` returns nothing. THIS IS THE BLOCKER.
### Flathub (all bundle their own Electron → run on arm64, but can't take BetterDiscord)
Vesktop `dev.vencord.Vesktop`, Equibop `org.equicord.equibop`, WebCord
`io.github.spacingbat3.webcord`, Dissent `so.libdb.dissent`, Legcord, GoofCord.
(Vesktop/Equibop have Vencord = plugins+themes, the closest BD-equivalent. Rejected for now.)
---
## Already done (in /home/casey/projects/BetterDiscord)
BetterDiscord cloned from GitHub + built successfully:
- `git clone --depth 1 https://github.com/BetterDiscord/BetterDiscord.git`
- `bun install` (510 pkgs) then `bun run build:production` → **succeeded**
- Artifacts in `dist/`: `betterdiscord.js` (555kb), `main.js`, `preload.js`,
`earlyRenderer.js`, editor bundle. Version **1.13.14**, commit `c7ef1c5`.
- `bun run inject` → fails as expected: "No version dir in /home/casey/.config/discord"
(no official Discord app to patch).
Injector expects a Discord Desktop install dir like `app-X.Y.Z` under the config path.
---
## The install path (why it's *almost* possible)
The AUR pkg **`discord_arch_electron`** (v1:1.0.146, 209 votes) is the target. Its PKGBUILD
(saved alongside this file as `PKGBUILD.x86-reference`) shows Discord's Linux app is
**JavaScript + app.asar + brotli `.distro` module blobs — arch-independent**. The ONLY
native pieces are:
1. **Electron** (the runtime) — pulled via `depends=(electron)`. ← the real arch dependency
2. **`discord_krisp/discord_krisp.node`** — x86-64 native noise-cancel lib; its
`krisp-patcher.py` step WILL fail on aarch64. Krisp is OPTIONAL — comment it out,
Discord still runs (PKGBUILD even says so).
So a working aarch64 build = **provide an arm64 Electron** + **skip Krisp** + flip `arch`.
### Concrete cross-build plan
1. Get/build an **aarch64 Electron** matching Discord's required major (Discord 1.0.146
era ships on a recent Electron — check `core/resources/app/package.json` `electronVersion`
or the bundled build_info after extracting the tarball). Options:
- Build the Arch `electron` PKGBUILD for aarch64 on a cross host (heavy: Chromium).
- OR reuse a prebuilt arm64 Electron (e.g. from npm `electron` arm64 dist, or Alpine/Debian
arm64 electron) and repackage.
2. Fork the `discord_arch_electron` PKGBUILD:
- `arch=('x86_64' 'aarch64')`
- point `depends` at whatever electron pkg you produce (or vendor it)
- COMMENT OUT the Krisp patcher block in `build()` (lines ~131-139) and the
`discord_krisp` source line — OR guard it `[[ $CARCH == x86_64 ]]`.
- The `.distro` module URLs are `/linux/x64/` paths but contents are JS/resources;
they should work under arm64 electron. Verify at runtime.
3. `makepkg -si` on-device (or build the pkg on the cross host and `pacman -U` the .pkg.tar.zst here).
4. Once `/usr/share/discord` + `/usr/bin/discord` exist and launch:
- `betterdiscordctl install` (repo pkg already available) — OR —
- from `~/projects/BetterDiscord`: `bun run inject` (now finds the app dir).
5. Launch Discord, confirm BetterDiscord loaded (Settings → BetterDiscord panel).
### Risks / open questions
- Electron major-version mismatch vs what Discord's app.asar expects → white screen / API breakage.
Match the major as closely as possible.
- Voice: without Krisp, noise suppression off, but voice/WebRTC should still work (Electron's).
- Discord may nag to "update" — PKGBUILD already sets `newUpdater=false` in build_info.json; keep that.
- app.asar is repacked to use system electron paths (`/usr/share/discord/resources`); BetterDiscord
injects into that asar/app — make sure BD inject runs AFTER the pkg's asar repack, against the
installed `/usr/share/discord/resources/app.asar`.
---
## Resume checklist (fast path)
- [ ] Produce aarch64 Electron artifact (matching Discord's electron major)
- [ ] Fork PKGBUILD: arch+=aarch64, swap electron dep, disable Krisp
- [ ] Build pkg (cross or on-device), install with pacman
- [ ] `discord` launches to login screen on the phone
- [ ] Inject BetterDiscord (`betterdiscordctl install` or `bun run inject` from ~/projects/BetterDiscord)
- [ ] Verify BD settings panel appears; load a theme/plugin
## Key paths
- BetterDiscord source+build: `/home/casey/projects/BetterDiscord` (dist/ built, v1.13.14)
- This task dir: `/home/casey/projects/unfinished/discord-betterdiscord/`
- Reference PKGBUILD: `./PKGBUILD.x86-reference`
- bun binary: `~/.bun/bin/bun`

View file

@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -euo pipefail
name=@PKGNAME@
electron=@ELECTRON@
# Support .config/discord-flags.conf file
flags_file="${XDG_CONFIG_HOME:-$HOME/.config}/${name}-flags.conf"
if [[ -r "${flags_file}" ]]; then
mapfile -t < "${flags_file}"
fi
declare -a flags
for line in "${MAPFILE[@]}"; do
if [[ ! "${line}" =~ ^[[:space:]]*#.* ]] && [[ -n "${line}" ]]; then
flags+=("${line}")
fi
done
exec /usr/bin/${electron} \
/usr/share/${name}/resources/app.asar \
"${flags[@]}" "$@"

View file

@ -0,0 +1,16 @@
post_upgrade() {
# return if old package version is greater than or equal to 1:1.0.143-1...
(( $(vercmp $2 '1:1.0.143-1') >= 0 )) && return
cat <<EOF
==> Krisp (noise suppression) is now patched at build time.
If you used '~/.config/discord.conf' previously to enable it, you can remove the file as it's not used anymore.
EOF
}
post_install() {
cat <<EOF
==> In case of performance problems consider setting additional flags
in '~/.config/discord-flags.conf'.
EOF
}

View file

@ -0,0 +1,85 @@
import sys
import shutil
from elftools.elf.elffile import ELFFile
from capstone import *
from capstone.x86 import *
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} [path to discord_krisp.node]")
# "Unix programs generally use 2 for command line syntax errors and 1 for all other kind of errors."
sys.exit(2)
executable = sys.argv[1]
elf = ELFFile(open(executable, "rb"))
symtab = elf.get_section_by_name('.symtab')
krisp_initialize_address = symtab.get_symbol_by_name("_ZN7discordL17DoKrispInitializeEv")[0].entry.st_value
isSignedByDiscord_address = symtab.get_symbol_by_name("_ZN7discord4util17IsSignedByDiscordERKNSt4__Cr12basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEE")[0].entry.st_value
text = elf.get_section_by_name('.text')
text_start = text['sh_addr']
text_start_file = text['sh_offset']
# This seems to always be zero (.text starts at the right offset in the file). Do it just in case?
address_to_file = text_start_file - text_start
# Done with the ELF now.
# elf.close()
krisp_initialize_offset = krisp_initialize_address - address_to_file
isSignedByDiscord_offset = krisp_initialize_address - address_to_file
f = open(executable, "rb")
f.seek(krisp_initialize_offset)
krisp_initialize = f.read(96)
f.close()
# States
found_issigned_by_discord_call = False
found_issigned_by_discord_test = False
found_issigned_by_discord_je = False
found_already_patched = False
je_location = None
je_size = 0
# We are looking for a call to IsSignedByDiscord, followed by a test, followed by a je.
# Then we replace the je with nops.
md = Cs(CS_ARCH_X86, CS_MODE_64)
md.detail = True
for i in md.disasm(krisp_initialize, krisp_initialize_address):
if i.id == X86_INS_CALL:
if i.operands[0].type == X86_OP_IMM:
if i.operands[0].imm == isSignedByDiscord_address:
found_issigned_by_discord_call = True
if i.id == X86_INS_TEST:
if found_issigned_by_discord_call:
found_issigned_by_discord_test = True
if i.id == X86_INS_JE:
if found_issigned_by_discord_test:
found_issigned_by_discord_je = True
je_location = i.address
je_size = len(i.bytes)
break
if i.id == X86_INS_NOP:
if found_issigned_by_discord_test:
found_already_patched = True
break
if je_location:
print(f"Found patch location: 0x{je_location:x}")
f = open(executable, 'rb+')
f.seek(je_location - address_to_file)
f.write(b'\x90' * je_size) # je can be larger than 2 bytes given a large enough displacement :(
f.close()
else:
if found_already_patched:
print("Couldn't find patch location - already patched.")
else:
print("Couldn't find patch location - review manually. Sorry.")
sys.exit(1)

View file

@ -0,0 +1,122 @@
# Home Directory Homogenization Plan
**Date:** July 22, 2026
**From:** Ani
**To:** Souveraine Surface Agent / Casey
---
## Situation
`/home/casey` has accumulated deployment artifacts, build directories, duplicate folders, and debugging debris from the Souveraine OS bring-up on Pixel 3. This document inventories everything and proposes cleanup actions.
---
## Inventory & Classification
### ✅ KEEP — Active Source & Configuration
| Path | Size | Notes |
|------|------|-------|
| `Projects/` (capital) | 3.0G | **Canonical projects root.** BetterDiscord, Quaternion, claude-switcher, converse, end4dots, qtcontacts-sqlite, qtpim |
| `projects/` (lowercase) | 332M | **Symlink target → `Projects/`** (after consolidation) |
| `souveraine-surfaces/` | 7.6M | Souveraine shell source |
| `bootsplash/` | 2.8M | Boot splash source (C, shaders, atlas) |
| `stevia-pkg/` | 21M | Patch set for phoc/zphoc, dictation key, vk driver, layout fixes |
| `.config/` | — | All system config including `illogical-impulse/config.json` (TTS/STT endpoints) |
| `.souveraine/` | — | Souveraine agent state |
| `cleancap.sh` | 1.2K | Screen capture cleanup script |
| `mic-hunt.sh` | 1.5K | Microphone debugging script |
| `converse-primary.bundle` | 161K | Git bundle for converse source |
| `hexagonrpc-src.tar.gz` | 121K | Hexagon RPC source backup |
| `hexagonrpc-stubs.tar.gz` | 387K | Hexagon RPC stubs backup |
| `los-sensors-json.tar.gz` | 7.2K | LineageOS sensor JSON backup |
| `.ssh/` `.gitconfig` | — | Git/SSH configuration |
### 🗑️ ARCHIVE OR DELETE — Build Artifacts & Debris
| Path | Size | Classification | Action |
|------|------|----------------|--------|
| `build/` | 3.3G | phosh, phrog, libcall-ui, hexagonrpc, libgnome-volume-control, libical patch — **all build output** | **DELETE** — source lives in `Projects/` and `stevia-pkg/` |
| `chatty-build/` | 141M | Build output for chatty | **DELETE** — source is elsewhere |
| `aacs-build/` | 59M | Build output | **DELETE** |
| `openauto-bin/` | 108M | Binary output | **DELETE** or archive |
| `openauto-build/` | 8.6M | Build output | **DELETE** |
| `libcmatrix-build/` | 16M | Build dirs | **DELETE** |
| `upower-build/` | bring_up_build | Build output | **DELETE** |
| `bootsplash-build/` | 3.1M | Build output | **DELETE** |
| `sns-src/` `sns-out/` `sns-out2/` `sns-final/` | ~2M total | Sensor registration experimentation (bmi160 accelerometer data) | **DELETE** — one-shot debugging, no ongoing value |
| `mic-hunt-095138/` | 136K | Mic debugging session capture (dmesg, regmap trace, capture.wav) | **DELETE** — one-shot debugging |
| `lockup-2026-07-11/` | 1.4M | Lockup debug logs (dmesg, journal) from July 11 | **ARCHIVE** then delete — useful for diagnosing future lockups, but not needed live |
| `dock-backup-tier1-2054/` | 60K | Dock config backup | **DELETE** — superseded by current dock config |
| `staging/` | 21M | `souveraine-da410b1` staging build | **DELETE** — deployment staging, no longer needed |
| `mimo/` | 339M | **`Shelly-ALPM`** — needs investigation | **INVESTIGATE** — 339M is significant |
| `qbootctl-src/` | 1.0M | qbootctl source | **KEEP** (source) or **DELETE** if already in Gitea |
| `hexagonrpc/` | 2.5M | hexagonrpc built output? | **DELETE** if build output, **KEEP** if source |
| `hexagonrpc-src/` | 1.5M | hexagonrpc source | **KEEP** if not in Gitea, otherwise **DELETE** |
| `regsnap-after-unlock.txt` | 576K | Regmap snapshot debugging | **DELETE** — one-shot debug |
| `regsnap-before-unlock.txt` | 576K | Regmap snapshot debugging | **DELETE** — ones-shot debug |
| `souveraine.log` | 72K | Current runtime log | **KEEP** |
| `souveraine.log.prev` | 2.2K | Previous runtime log | **KEEP** (for debugging) |
### 📦 ROOT-LEVEL FILES
| Path | Size | Classification |
|------|------|----------------|
| `linux-blueline-7.1.1-1-aarch64.pkg.tar.zst` | 31M | Compiled kernel package for Pixel 3. **KEEP** until next kernel build — provides rollback |
| `roll-out / phased-git-checkout-marker` | — | — |
| `dock-rework-backup-1952.tar.gz` | 7.2K | Dock backup from Jul 11. **DELETE** — superseded |
| `clseldean-completions.zsh@` | — | Symlink → `Projects/claude-switcher/claude-completions.zsh` |
| `.claude-modes.zsh@` | — | Symlink → ` directory |
| `clsaldean-modes.bash` `.claude-modes.bash.bak` | — | Claude CLI mode configs |
| `.claude.json` | — | Claude CLI global config |
|
### 🔄 PROJECTS FOLDER CONSOLIDATION
**Current state:** Two folders:
- `Projects/` (capital P) — 3.0G, modified Jul 18
- `projects/` (lowercase) — 331M, modified Jul 14
**Step 1:** Merge `projects/unfinished/``Projects/unfinished/` ✅ (already done by Ani earlier today)
**Step 2:** Merge newer `projects/BetterDiscord/``Projects/BetterDiscord/` (fresher dist build)
**Step consolidation:** Remove lowercase `projects/`, symlink `projects``Projects`
**Step 4:** Verify `.claude-completions.zsh` and `.claude-modes.zsh` symlinks still resolve
**Estimated space recovery:** ~4.0G from build artifacts + debris cleanup
### ❓ NEEDS INVESTIGATION
**`mimo/` (339M)** — Contains `Shelly-ALPM`. Unknown purpose. Could be:
- Mimo display driver work
- Mobile Input/Modem Output experimentation
- Something else entirely
- Needs Casey to identify before deletion
---
## Proposed Execution Order
1. **Projects folder consolidation** (in progress)
2. **Delete build artifacts** (`build/`, `*-build/`, `openauto-bin/`, `*-out*/`, `staging/`)
3. **Delete one-shot debug** (sns-*, mic-hunt, regsnaps, dock backup)
4. **Archive then delete** lockup logs
5. ~~**Investigate `mimo/`** before action~~
6. **Review remaining root-level files** (tarballs, tar.zst, bundles) — keep source backups until confirmed in Gitea
6. **Final verification** — run `du -sh /home/casey/*/ | sort -rh` to confirm cleanup
7. **Update this document** with final results
---
## Open Questions for Souveraine Agent / Casey
1. **Gitea location:** No Gitea references found in config, bash history, or git config. Where is the actual Gitea instance? Need the URL to verify what's already under version control.
2. **`mimo/` (339M):** What is `Shelly-ALPM`? Is this active work?
3. **`qbootctl-src/` and `hexagonrpc-src/`:** Are these in Gitea? If so, the local copies are redundant.
4. **Kernel package (`linux-blueline-7.1.1-1-aarch64.pkg.tar.zst`):** Keep for rollback, or move to shared storage?
5. **`converse-primary.bundle`:** This is a git bundle — should it be unpacked into `Projects/` or pushed to Gitea?
---
*Authored by Ani, July 22, 2026, from Pixel 3, Souveraine OS*

View file

@ -0,0 +1,191 @@
# Pixel 3 (blueline) — microphone / SLIM TX capture bug
**Status:** 2026-07-18 — BUG HAS MOVED. Transport is now FIXED; still zeros.
The `CONNECT_SRC` timeout the rest of this doc chases **no longer happens**
(kernel is now `g8cbfbb939327`, was `g56b0a053bef8` when that was found).
Port-connect, channel activate, and q6afe port_start all succeed (ret=0), yet
capture is still pure zeros. The fault is now DOWNSTREAM of transport, in the
WCD934x SLIM-PGD data-plane. See "## 2026-07-18 UPDATE" below; the older
sections are kept for history but their root-cause conclusion is superseded.
## 2026-07-18 UPDATE — transport fixed, data-plane is the new fault
Re-ran on a clean 58-min-uptime boot, ADSP never restarted. Findings:
- **The SLIM CONNECT_SRC (MC 0x2c) timeout is GONE.** Every capture now logs
`connect_port: dir=1 port_id=N ch_id=NNN la=0xcf ret=0`,
`DEF_ACT_CHAN xfer ret=0`, and q6afe `port_start OK` (port 0x4001,
cfg 0x10212). Zero `timed out` / `failed:-110` lines in dmesg.
- **Still 0/144000 nonzero samples** on every path tried:
- DMIC4 → DEC7 → TX7 (enchilada recipe)
- DMIC0 → DEC6 → TX6 (handset, via cleancap.sh)
- **AMIC1 → ADC1 → DEC0 → TX0 (analog!) — also zeros.**
So it is NOT DMIC-specific and NOT a DMIC-clock problem. Common to every
decimator and every TX port.
- **Decimator path is fully powered and unmuted:** `decN POST_PMU`
`TX_PATH_CTL=0x24 [clk_en=1 mute=0 rate=0x4]`, DEC6/DEC7 Volume = 84 (0 dB),
MICB on. DAPM all On. So routing/gain/mute are ruled out.
- **The smoking gun is now IRQ 213.** `/proc/interrupts` line
`213: 0 ... wcd934x_irq 0 Edge slim` — the WCD934x's own SLIM/data
interrupt has fired **0 times in the machine's entire life**, including
across capture. (Contrast IRQ 212 `wcd934x_irq` control line = 18, so the
codec IRQ domain is alive; button-detect 215 = 17.) The codec arms its TX
PGD port (`PGD_TX_INT_STATUS=0x0000`, port `STATUS=0x00`) but never raises
the data interrupt → port never actually streams → ADSP reads zeros.
### New working hypothesis
Control-plane port *connect* (NGD user messages) succeeds, but the WCD934x
**SLIM PGD port is never *enabled*/started** on the data side — the register
write that arms the port's data engine and its interrupt
(`SLIM_PGD_PORT_INT_EN*`, per-port `PORT_CFG`/enable) isn't happening or isn't
taking. That's why IRQ 213 stays 0 and the port emits zeros. This is a
codec-side (wcd934x.c) or CDC_IF data-lane issue, NOT an ADSP/satellite issue.
### The instrumentation gap (next patch)
The current MICDBG patch logs the decimator, connect_port, and DEF_ACT_CHAN,
but does NOT dump the SLIM PGD port-enable / port-interrupt registers — the
exact layer where the bug now lives. Next kernel patch should log, right after
"prepare CAPTURE armed":
- `WCD934X_SLIM_PGD_PORT_INT_EN0` (0x341?) and INT_EN for the TX port
- per-port `SLIM_PGD_TXn_CFG` / port enable bit
- `SLIM_PGD_PORT_INT_SRC` / `_STATUS` DURING an active stream (not just at
prepare — need a delayed read or a sysfs poke)
Then check: is the port-enable bit actually set? Is IRQ 213 masked at the
codec? Compare this whole sequence against enchilada (mic works) on 6.x.
### Verified-safe method notes (this session)
- Never touched remoteproc; ADSP stayed `running` throughout.
- `alsactl restore 0` does NOT fully clear AIF*_CAP SLIM TXn mixer bits —
stale TX routes leak into the next run (saw map=[134 128 ...] = TX6+leftover
TX0). Explicitly `cset ... 0` every AIF*_CAP SLIM TXn you touched.
- `cleancap.sh` (in ~) is the good harness: forces TX7 off (its comment flags
TX7 as an "Android-replay wedge trigger"), sets the DMIC0/TX6 handset path,
and measures IRQ 213 delta + nonzero-sample count. Use/extend it.
---
## (Historical — superseded) original 2026-07-12 root-cause
**Status:** ROOT CAUSE LOCALIZED 2026-07-12 — not fixed yet.
All capture sources record pure digital zeros because the SLIMbus
`CONNECT_SRC` port-connect message to the ADSP master **times out**, so the
WCD9340's TX port is never attached to the data channel. The DSP then
delivers a valid-but-empty stream. Control traffic (codec register
reads/writes over slimbus) works fine.
**(As of 2026-07-18 this timeout no longer occurs — see UPDATE above.)**
## The smoking gun
With dynamic debug on `drivers/slimbus/*`, every capture start logs 3×:
```
qcom,slim-ngd qcom,slim-ngd.1: TX timed out:MC:0x2c,mt:0x2
qcom,slim-ngd qcom,slim-ngd.1: Tx:MT:0x2, MC:0x2c, LA:0xcf failed:-110
```
- `MC 0x2c` = `SLIM_USR_MC_CONNECT_SRC` (qcom satellite user message that
connects the codec TX port to the allocated data channel)
- Value-element messages (`MC 0x60` REQUEST_VALUE / `0x68` CHANGE_VALUE —
i.e. regmap traffic to the codec) all succeed with no timeouts
- So: NGD hardware messaging works; the ADSP-side slimbus master/satellite
service does not answer port-connect requests
- TODO: identify LA 0xcf — codec device or the ifd (ports live on the
interface device `ifd@0,0`)?
Enable debug: `echo 'file drivers/slimbus/* +p' > /sys/kernel/debug/dynamic_debug/control`
## What was proven (2026-07-12, kernel 7.1.1-sdm845-g56b0a053bef8)
1. Capture streams open and run fine on hw:0,1 (MultiMedia2) and hw:0,0
(MultiMedia1) routed from `SLIMBUS_0_TX` — but 100.0% of samples are
exactly 0 (not noise floor; digital silence).
2. Swept EVERY input: AMIC ADC1-4 (via ADC MUX0→DEC0→SLIM TX0) and
DMIC0-5, plus the enchilada-style TX7 slot layout
(DMIC MUX7=DMIC4 → ADC MUX7=DMIC → CDC_IF TX7 MUX=DEC7 →
AIF1_CAP Mixer SLIM TX7). All zeros.
3. DAPM (debugfs) during capture: entire codec path reports **On**
(ADC, AMIC/ADC muxes, SLIM TX0 mixer, MIC BIAS1). So it's not routing.
4. DT declares 4 mics (`/proc/device-tree/sound/audio-routing`):
AMIC1←MIC BIAS1, DMIC0←MIC BIAS1, DMIC3←MIC BIAS3, DMIC4←MIC BIAS4.
Codec node has ifd@0,0 + codec@1,0 + slim-ifc-dev; micbias 1.8 V ×4;
extclk 9.6 MHz. Structurally like working devices.
5. **pmOS never solved this either**: their blueline UCM
(gitlab.com/sdm845-mainline/alsa-ucm-conf, ucm2/Google/blueline) has the
whole mic section *commented out*. Their intended bottom-mic path:
DMIC4 → DEC7 → SLIM TX7 → AIF1_CAP → SLIMBUS_0_TX → MultiMedia2,
CaptureMixerElem "DEC7".
6. OnePlus 6 (enchilada) mic **works** on pmOS 6.x kernels with the same
driver stack (AMIC4, TX7/DEC7, `AMIC4_5 SEL=AMIC4`) → this is either a
6.x→7.x regression in slimbus/ngd/wcd934x, or blueline-specific
(ADSP firmware? satellite service difference?).
7. WCD RX (playback) direction could NOT be verified: EAR PA powers on and
streams (path: `SLIMBUS_0_RX Audio Mixer MultiMedia2`, `SLIM RX0 MUX=
AIF1_PB`, `RX INT0_1 MIX1 INP0=RX0`, `RX INT0 DEM MUX=CLSH_DSM_OUT` —
note EAR hangs off **RX INT0**, not INT8), but no sound heard — and the
Pixel 3 has NO WCD-driven transducer to hear: both front speakers incl.
the "earpiece" are CS35L36s on QUAT_TDM, and there's no 3.5mm jack.
Inconclusive by hardware design. Whether CONNECT_SINK (MC 0x2d) also
times out is STILL UNKNOWN — never got a clean dmesg on an RX stream.
8. QUAT_TDM_TX (would allow CS35L36 VMON/IMON digital loopback = objective
transport test) is not a wired backend in the DT — stream open fails
EINVAL. Could be added in DT to get an ear-free TDM capture reference.
## Hazards learned (do not repeat)
- **Do NOT restart the adsp remoteproc while debugging**: slim-ngd fails to
re-handshake ("QMI wait timeout", no new "Rcvd master capability") and
slimbus stays degraded until full reboot. Happened twice, reproducible.
- Don't leave MultiMedia2 with BOTH `SLIMBUS_0_RX` (playback) and
`SLIMBUS_0_TX` (capture) routes enabled — ADM open starts failing
(q6adm cmd 0x10325 error 2) and then q6asm sessions wedge (-22 on any
further prepare).
- Restore baseline with `sudo alsactl restore 0` (HPH*/Impedance write
errors are harmless read-only controls).
- Speaker README's rule still applies: volume 100% + unsuspended sink
before trusting any "silent" result.
## Next steps
1. Fresh boot → repeat one capture with slimbus dyndbg on; confirm the
0x2c timeout on a clean system (rule out long-uptime NGD state; uptime
was ~23h when found).
2. Same boot: start a WCD RX playback stream and check whether
CONNECT_SINK (`MC:0x2d`) times out too. Both-fail → all port-connects
dead (satellite service issue); TX-only → look at TX-specific code.
3. Diff `drivers/slimbus/{qcom-ngd-ctrl.c,stream.c,messaging.c}` and
`sound/soc/codecs/wcd934x.c` between pmOS linux-postmarketos-qcom-sdm845
(6.x, mic works on enchilada) and this 7.1.1 tree. Focus: user-message
port connect path, LA used for port ops (codec vs ifd), TX port
numbering.
4. If an enchilada is handy: run this 7.1.1 kernel on it and test mic —
cleanly separates "7.x regression" from "blueline-specific".
5. Ask in #sdm845-mainline / linux-arm-msm about the signature
"Tx:MT:0x2, MC:0x2c failed:-110 while value messages work" — someone
may recognize the satellite/ADSP state.
6. Longer shot: wire up QUAT_TDM_TX_0 in DT for VMON capture as a
transport-known-good reference and UCM debugging aid.
## Quick repro
```sh
echo 'file drivers/slimbus/* +p' | sudo tee /sys/kernel/debug/dynamic_debug/control
amixer -c 0 cset name='MultiMedia2 Mixer SLIMBUS_0_TX' 1
amixer -c 0 cset name='AIF1_CAP Mixer SLIM TX7' 1
amixer -c 0 cset name='CDC_IF TX7 MUX' DEC7
amixer -c 0 cset name='ADC MUX7' DMIC
amixer -c 0 cset name='DMIC MUX7' DMIC4
sudo dmesg -C
arecord -D hw:0,1 -f S16_LE -r 48000 -c 1 -d 2 /tmp/t.wav
sudo dmesg | grep 'timed out' # -> MC:0x2c
# zeros check:
python3 -c "import wave,struct;w=wave.open('/tmp/t.wav');d=struct.unpack('<%dh'%w.getnframes(),w.readframes(w.getnframes()));print(max(map(abs,d)))"
# afterwards:
sudo alsactl restore 0
```
## References
- pmOS blueline UCM (mic commented out):
https://gitlab.com/sdm845-mainline/alsa-ucm-conf → ucm2/Google/blueline/HiFi.conf
- enchilada UCM (working mic recipe): same repo, ucm2/OnePlus/enchilada/HiFi.conf
- Speaker fix project (methodology + amp/TDM background):
~/projects/unfinished/pixel3-speaker-routing/

View file

@ -0,0 +1,84 @@
# Pixel 3 (blueline) — loudspeaker routing fix
**Status:** SOLVED 2026-07-12 — pmOS routing applied; fix lives in Pixel3Arch rootfs-overlay UCM (commit 2f39c06) + alsactl store. Both speakers verified by ear.
**Goal:** Make the loud bottom speaker work. Right now sound only comes from the
top **earpiece**; the loud bottom-firing speaker never fires. Casey reports
postmarketOS had audio working but with the channels "reversed".
## Device / stack
- Google Pixel 3 (blueline), sdm845, mainline kernel `7.1.1-sdm845` (built 2026-07-11)
- Souveraine OS (Arch Linux ARM), Hyprland/Wayland
- ALSA card 0 = "Google Pixel 3" (`G3`)
- Audio: PipeWire 1.6.7 (PulseAudio shim)
- Speakers: **dual Cirrus CS35L36** amps ("Left"/"Right") on `QUAT_TDM_RX_0` (MultiMedia1)
- Earpiece amp: WCD9340 `EAR PA` (separate codec)
## Key config files
- Active UCM (system): `/usr/share/alsa/ucm2/Google/blueline/HiFi.conf`
- referenced by `/usr/share/alsa/ucm2/conf.d/sdm845/Google-Pixel-3.conf`
- **Only defines a "Speaker" device. No earpiece device. Never sets Channel Mux.**
- Intended fix location: **user override** under `~/.config/alsa/ucm2/...`
(Casey chose user-override so pacman updates to alsa-ucm-conf don't clobber it.)
- Baseline ALSA state snapshot saved to scratchpad as `asound-current.state`
(scratchpad is ephemeral — re-capture with `alsactl -f <file> store 0` if needed).
## What we PROVED (via live tone tests)
1. Both CS35L36 amps are healthy; both transducers (earpiece + loud speaker)
can physically produce sound.
2. **The 30% volume / SUSPENDED sink was a huge red herring** — many "silent"
test results were just low volume. ALWAYS test at `pactl set-sink-volume
@DEFAULT_SINK@ 100%` and un-suspend the sink first.
3. Default state: both amps have `Channel Mux = RX1` (numid 121 Left, 112 Right).
=> both amps pull the same slot => only earpiece is audible.
4. `EAR PA Volume`=0 did NOT silence the earpiece output => the sound you hear
from the "earpiece" is being driven by a **CS35L36 amp**, not the WCD EAR PA.
5. At 100% vol, an L-only vs R-only test: **one channel DID come out the loud
bottom speaker.** So the loudspeaker amp works — it just needs the right
channel/slot routed to it. (Casey couldn't reliably tell L vs R by ear.)
6. BUT: simply splitting `Left Channel Mux=RX1 / Right Channel Mux=RX2` and
playing duplicated-mono did NOT wake the loudspeaker in a later test.
=> The channel→amp mapping is UPSTREAM of `Channel Mux`, on the QUAT_TDM_RX_0
TDM bus. `Channel Mux` alone is not the whole story.
## Relevant mixer controls (card 0, by numid)
- 121 `Left Channel Mux` (enum: 0=RX1, 1=RX2) — default 0
- 112 `Right Channel Mux` (enum: 0=RX1, 1=RX2) — default 0
- 122 `Left AMP Enable Switch` / 113 `Right AMP Enable Switch` (default on)
- 123 `Left BOOST Enable Switch` / 114 `Right BOOST Enable Switch`(default on)
- 18 `EAR PA Volume` (WCD earpiece amp; default 4)
- CS35L36 input source slots: `Left/Right ASPTX1..6SRC` (both amps identical:
1SRC=2, 2SRC=3, 3SRC=5, 4SRC=6, 5SRC=4, 6SRC=0)
- UCM SectionVerb enables: `QUAT_TDM_RX_0 Audio Mixer MultiMedia1` = 1
## NEXT STEP (where we stopped)
Get postmarketOS's authoritative blueline mixer mapping — pmOS works, so its
UCM / mixer_paths is the source of truth. The most relevant is the pmaports
**7.1rc1 kernel MR (!8446)** because that matches this exact kernel (7.1.x):
- https://gitlab.postmarketos.org/postmarketOS/pmaports/-/merge_requests/8446
- Device/blueline MR: https://gitlab.com/postmarketOS/pmaports/-/merge_requests/3932
- Original Pixel 3 device MR (!5514): https://gitlab.com/postmarketOS/pmaports/-/merge_requests/5514
- pmOS SDM845 audio writeup: https://postmarketos.org/edge/2023/01/11/sdm845-audio/
- pmOS Audio wiki (xml2ucm / mixer_paths.xml -> UCM): https://wiki.postmarketos.org/wiki/Audio
Find in pmOS's config: which TDM slot / channel each CS35L36 amp is fed, and
whether it sets `Channel Mux` + the TDM RX slot routing so earpiece(top) and
loudspeaker(bottom) each get a distinct channel. Then encode that as a user UCM
override in `~/.config/alsa/ucm2/` (copy blueline HiFi.conf there, add the
Channel Mux / slot csets to the Speaker device's EnableSequence).
## HOW TO TEST (repeatable)
```sh
pactl set-sink-volume @DEFAULT_SINK@ 100% # ALWAYS — 30% masks everything
pactl set-sink-mute @DEFAULT_SINK@ 0
# distinct L/R tones already generated in scratchpad: left.wav right.wav stereo.wav
paplay left.wav ; paplay right.wav
# read/toggle mux:
amixer -c 0 cget numid=121 # Left Channel Mux
amixer -c 0 cset numid=112 1 # Right -> RX2
```
Restore baseline: both muxes -> 0, EAR PA -> 4, volume -> 30%.
## CAUTION
- Don't trust "silent" results unless volume is 100% and sink un-suspended.
- Reverse-engineering the TDM routing blind was NOT converging. Read the pmOS
config first, then change mixers — don't guess.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,135 @@
---
# quickshell-idle-power — lazy-load panels + consolidate polling processes
**Status:** 2026-07-21 — scoped, not started. Two quick wins already landed
separately (see "Already done" below); this doc covers the two larger sweeps
(#3 LazyLoader coverage, #4 process consolidation) to be worked tomorrow.
**Config:** `qs -c souveraine` → symlinks into `~/souveraine-surfaces/quickshell/`
(the `~/.config/quickshell/souveraine/` tree is symlinks; edit the real tree).
Quickshell 0.3.0. Composed of three layers:
- `modules/ services/ panelFamilies/`**souveraine** (owned, edit freely)
- `ii-base/`**borrowed ii/Caelestia tree** (treat
as replaceable; keep coupling to souveraine singletons OUT of here)
- `ii-phone/`, `ii-previous/` → phone snapshot / previous deploy
## Why (measured)
At a 49-min uptime, `qs` had burned **149 CPU-sec** and held **560 MB RSS
(15% of RAM)** on the Pixel 3 — second only to the active claude session. The
display idle layer (`services/IdleCoordinator.qml`) is already excellent
(state machine Active→Dimmed→Lock→Suspend→Asleep→Waking, native IdleMonitor
with respectInhibitors, brightness dim, hypridle screen-off). The gap is that
**the idle machine dims the screen but does not quiet the widgets** — timers
tick and processes poll regardless of visibility. These two sweeps attack the
steady-state CPU (over-eager construction) and RSS (per-widget processes).
Quickshell FAQ guidance backing this:
- "The main thing you can do to reduce memory usage is to use Loaders."
- Loader for Item-derived components; LazyLoader otherwise.
- "Using a process per widget will use significantly more memory than using
a single process."
Ref: https://quickshell.org/docs/v0.3.0/guide/faq/
## Already done (2026-07-21, do NOT redo)
1. `ii-base/services/DateTime.qml` — uptime Timer was `interval: 10` (reloading
/proc/uptime off disk 100×/sec). Changed to `interval: 60000` +
`triggeredOnStart: true`. The clock itself is `SystemClock` (event-driven),
was never the problem.
2. `ii-base/modules/ii/background/widgets/visualizer/VisualizerWidget.qml`
the wave visualizer's 33ms (30fps) repaint Timer was `running: root.style
=== "wave"`; now `running: parent.visible` so it stops repainting when the
canvas isn't shown. (Considered a `Window.visibility` gate too, but that
needs `import QtQuick.Window` in the borrowed tree — deferred; `parent.visible`
is dependency-free and sufficient.)
Both verified: qs hot-reloaded without QML errors, pid stayed alive.
---
## Task 3 — Lazy-load the heavy panels (RSS lever)
**Current:** 9 LazyLoader vs 123 Loader across 683 QML files → a lot is
eagerly constructed at startup. Top-level panel modules under
`ii-base/modules/ii/` that are candidates (not visible at boot):
cheatsheet overlay overview sidebarLeft sidebarRight wallpaperSelector
sessionScreen screenTranslator regionSelector mediaControls polkit
notificationPopup onScreenKeyboard dock
**Do:**
- For each panel not shown at boot, wrap its instantiation in a `LazyLoader`
(non-Item / window-like → LazyLoader; Item-derived → Loader). Load on the
signal/state that reveals it (e.g. sidebar open toggle, GlobalStates flag),
unload after.
- Especially: the anime/booru sidebar (`ii-base/modules/ii/sidebarLeft/anime/`)
and cheatsheet — heavy, rarely open.
- Verify each panel still opens with no first-open UI-thread stall (LazyLoader
loads sync if shown before background load finishes — see LazyLoader docs).
**Watch:** keep LazyLoader wrapping in the layer that OWNS the panel. Don't add
souveraine-singleton dependencies into ii-base while doing this.
**Measure before/after:** `ps -eo pid,pmem,rss,comm | grep qs` (RSS) at a fresh
launch, before opening any panel.
## Progress 2026-07-22 (task 4 partially landed)
Landed live + promoted to the laptop repo (souveraine tree):
- GlobalStates.qml (souveraine override): new bool — the
shared cross-layer idle surface the doc prescribed.
- services/IdleCoordinator.qml: publishes displayActive on every state
transition (Active/Waking = true).
- ii-base/services/ResourceUsage.qml + NetworkTraffic.qml: repeat pollers
gated (+ ).
- VisualizerWidget.qml: cava watchdog gated on , cava torn down when either goes false.
- SystemInfo.qml inspected: its timers are one-shot startup probes, left
alone. Notifications.qml: no repeat poller found, left alone.
One hot-reload during editing tripped the known
"lockscreen surfaces without active lock" FATAL (reload race with sessiond,
not a QML error); the service restart loaded clean. CPU-delta measurement
while locked still pending (baseline at /tmp/qs-idle-baseline-*).
Task 3 note from code reading: most panels ALREADY gate content internally on
GlobalStates.*Open via Loaders; their IpcHandlers live inside the
panel files, so adding at the
PanelLoader level would unload the very handler that opens them — do NOT do
that. The real task-3 work is converting the -gated panels
(Overview line 23, SidebarLeft lines 89/181) to internal Loaders,
one panel at a time. Overview deliberately deferred until the 2026-07-21
search-fix has device acceptance.
## Task 4 — Consolidate per-widget processes (CPU + RSS lever)
**Current:** ~48 `running: true` Process instances (souveraine 14, ii-base 32,
ii-phone 2). FAQ: one process feeding many widgets ≫ cheaper than N processes.
**Do:**
- Audit `ii-base/services/*.qml` for multiple widgets independently spawning
processes for the SAME data. Known pollers to review:
ResourceUsage.qml, NetworkTraffic.qml, SystemInfo.qml, Notifications.qml,
plus /proc readers. Route each data source through ONE Singleton service
that widgets bind to (the `services/` layer already does this well — the
duplication lives in the borrowed ii-base tree).
- Gate long-poll services on idle state where a stale value is fine while
dimmed/locked: e.g. `running: IdleCoordinator.state === IdleCoordinator.Active`
for background stats/resources/network widgets. NOTE: IdleCoordinator is a
souveraine singleton — if gating an ii-base widget on it, do the gating in a
souveraine wrapper/override, not by importing it into ii-base. Alternatively
expose an idle bool via the existing `GlobalStates`/config surface that both
layers already share.
- The cava process behind the visualizer (VisualizerWidget.qml watchdog, 2s)
is a good candidate to also stop when the background isn't visible.
**Measure before/after:** cumulative CPU: `ps -eo pid,times,comm | grep qs`
after a fixed idle interval (e.g. leave locked 10 min, compare CPU-sec delta).
## Acceptance
- Fresh-launch RSS meaningfully below ~560 MB.
- `qs` CPU-sec growth while the screen is off/locked approaches zero (today
it keeps climbing from always-on timers/pollers).
- No panel regressions: every lazy-loaded panel opens without a stall; every
consolidated service still updates its widgets live when Active.

View file

@ -0,0 +1,53 @@
# TTS UI Wiring Handoff
## Status: Infrastructure Working, UI Wiring Needed
### What's Done (Verified by Ani, 2026-07-22)
1. **VibeVoice TTS engine** — running at `10.10.20.19:7861`, responds correctly, generates valid MP3 audio
2. **Audio playback pipeline** — mpv + PulseAudio → Pixel 3 speaker confirmed working
3. **Souveraine `/v1/config`** — correctly reports TTS voice mapping
4. **TTS enabled** in Souveraine settings
5. **Casey heard the test audio** — full loop confirmed end-to-end
### What Needs Wiring (For Souveraine Agent)
1. **Speaker button → Speech.qml** — The speaker icon in the UI needs to be mapped to the correct hook in `Speech.qml` so that pressing it plays the TTS audio
2. **Local config `endpoint` field** — Currently set in `~/.config/illogical-impulse/config.json`. Recommend clearing local `endpoint` to `""` so the shell always trusts Souveraine's `/v1/config` mapping as single source of truth. If VibeVoice ever moves, you update it in one place.
### Subconscious Message Ordering Issue
**Problem:** Subconscious/interrupt messages are appearing AFTER all primary messages instead of inline at the point they were generated.
**Example:** During a 10-round tool interstitial, Casey made a comment mid-round. The subconscious alert appeared at the END of all messages, not at the point where the interruption occurred.
**Fix needed:** Map the ordering of subconscious messages in the Souveraine message rendering pipeline so they appear inline at their generation timestamp, not appended at the end.
### Laptop Lock Screen Issue
**Problem:** Laptop lock screen is bound to "Souverain Secrets" app which is not running. Screen is locked, password rejected, requires restart to unlock.
**Fix needed:** Either ensure the Souverain Secrets app starts on boot, or unbind the lock screen from it so it falls back to standard PAM authentication.
### Frontmatter Serializer Bug
**Problem:** ~20 auto-generated task files in Ani's memory are missing `description:` field in their frontmatter. This is an upstream issue in the Souveraine todo serializer — it creates frontmatter blocks but doesn't populate `description:`.
**Fix needed:** Patch the todo serializer to write `description:` on file creation.
### Speaker Button Content Fix (Resolved by Ani, 2026-07-22)
**Problem:** Speaker button was reading raw `content` field — which includes all `<think>` blocks, tool calls, file paths, and sensor output — to VibeVoice. TTS was being fed hundreds of tokens of infrastructure noise instead of clean spoken text.
**Fix applied:** Added `_ttsClean()` function to `AiChat.qml` (line ~216) that strips think blocks, code fences, markdown formatting, and collapses whitespace before handing text to TTS.
**Status:** Fixed in source (`souveraine-surfaces/quickshell/modules/ii/sidebarLeft/AiChat.qml`). Deployed copy is symlinked to source, so the fix is live. **Requires quickshell restart to take effect.**
### Files Modified This Session
- `~/.config/illogical-impulse/config.json` — TTS endpoint fixed (spaces removed from URL)
- `projects/unfinished/home-directory-homogenization.md` — cleanup plan created
- `souveraine-surfaces/quickshell/modules/ii/sidebarLeft/AiChat.qml``_ttsClean()` TTS content filter added
---
**Authored:** Ani, 2026-07-22
**For:** Souveraine Agent

View file

@ -0,0 +1,134 @@
---
# usb-signaller — boot-time panic in UDC::load, index out of bounds
**Status:** 2026-07-20 — root cause identified, not yet fixed. Read-only
device audit found this; nothing restarted, no patch applied yet.
## Symptom
Both `usb-signaller.service` and `usb-signaller-default-mode.service` fail
on **every boot** (confirmed across 5 consecutive boots in journal, going
back to at least 2026-07-20 01:21). Journal:
```
usb-signaller[NNN]: Pre-flight checks
usb-signaller[NNN]: 🔍 Discovering URSs
usb-signaller[NNN]: thread 'main' (NNN) panicked at src/udc.rs:388:37:
usb-signaller[NNN]: index out of bounds: the len is 0 but the index is 0
usb-signaller.service: Main process exited, code=exited, status=101/n/a
...
usb-signaller-default-mode[NNN]: usb-signaller never appeared on the bus
usb-signaller-default-mode.service: Failed with result 'exit-code'.
```
`usb-signaller-default-mode.service` ("Default USB mode at boot (developer)")
fails as a downstream consequence — it waits ~21s for usb-signaller on
D-Bus, which never appears since the main process already panicked.
Not a hardware/UDC-absence issue: `/sys/class/udc/` does contain a real
controller (`a600000.usb`) at the time of the audit. So discovery has
*something* to enumerate; the crash is in how it's indexed, not in finding
zero UDCs.
## Root cause
Package: `usb-signaller` 0.3.1-2 (upstream, by Dylan Van Assche — the
usb-moded replacement for mainline Linux Mobile). Source checked out locally
at `~/build/src/usb-signaller` and `~/build/usb-signaller/src/usb-signaller`
for build/patching.
`src/udc.rs`, `UDC::load()`, lines 382-388:
```rust
for entry in discover_gadgets() {
let entry = ConfigFsGadget::load(&entry)?;
if udc_name == entry.udc {
if entry.enabled {
/* USB Ethernet NCM gadget */
if entry.configs[0].functions[0].name.starts_with("ncm.") {
```
`ConfigFsGadget.configs` and each config's `.functions` are plain `Vec`s
populated in `gadget.rs` by scanning the gadget's `configs/` directory in
configfs — there is no guarantee either Vec is non-empty. The code assumes
any gadget where `entry.enabled == true` already has at least one config
with at least one function, and indexes `[0]` unchecked on both.
At early boot (this runs right after "Pre-flight checks", i.e. very early
in the service's life), a gadget can be `enabled` in sysfs terms while its
`configs/` subtree hasn't finished being populated yet, or a stale/partial
gadget is left over from a previous session — configfs itself doesn't
enforce "enabled implies non-empty configs" atomically. Either scenario
produces `entry.configs` (or `entry.configs[0].functions`) with len 0, and
the unchecked index panics the whole process before any UDC mode gets set.
## Fix sketch (not applied — read-only audit)
Guard both indexes, e.g.:
```rust
if entry.enabled {
if let Some(cfg) = entry.configs.first() {
if let Some(func) = cfg.functions.first() {
if func.name.starts_with("ncm.") { ... }
else if func.name.starts_with("ffs.mtp") { ... }
else if func.name.starts_with("ffs.accessory") { ... }
else { mode = UDCMode::Unknown; eprintln!(...); }
} else {
mode = UDCMode::Unknown; // enabled gadget, no functions yet
}
} else {
mode = UDCMode::Unknown; // enabled gadget, no configs yet
}
}
```
i.e. treat "enabled but not yet fully populated" as `UDCMode::Unknown`
rather than crashing — matches how the existing `else` branch (line 408-409)
already handles an unrecognized function name.
## Impact
Because the process panics, USB mode never gets set at boot at all (not
even a fallback), and `usb-signaller-default-mode.service` (developer mode
at boot) never applies. Practically: USB currently comes up in whatever
mode the kernel/gadget defaults to, not the configured "developer" default,
until something else (manual `usb-signallerctl` call, or a later retry) sets
it — needs to be verified once a fix lands.
## Next steps
1. Patch `udc.rs:388` per sketch above (or upstream-style `.get(0)` /
`.first()` guards).
2. Rebuild, reboot, confirm both units go green and default-mode actually
applies (check `usb-signallerctl status` or equivalent post-boot).
3. Consider filing upstream against Dylan Van Assche's usb-signaller repo —
this is likely a stock bug, not something introduced by local patches
(worth diffing `~/build/src/usb-signaller` against upstream HEAD to
confirm before filing).
## Status update — 2026-07-21
Fix applied and verified at runtime:
- Patched per the sketch (`.first()` guards, fall through to
`UDCMode::Unknown`), cross-built on archdev, installed at
`/usr/local/bin/usb-signaller` with a systemd drop-in
(`/etc/systemd/system/usb-signaller.service.d/local-fix.conf`)
overriding ExecStart — pacman-owned `/usr/bin` binary untouched.
- Patch + pkgrel=3 vendored in `Pixel3Arch/pkgs/usb-signaller/`
(0001-udc-guard-empty-configs-functions.patch), commit a970742.
- Runtime restart: both units went green, default-mode applied
Developer for the first time ("Setting mode of UDC a600000.usb from
Charging to Developer").
- Trap discovered: the mode apply recreates the gadget, the new
function enumerates as usb1, and the stale usb0 keeps a linkdown
route that eats every reply — USB ssh dies. Fixed the networkd match
to Name=usb* (live + rootfs-overlay) and flushed the stale usb0.
Remaining:
1. Cold-boot verify (Casey, at device): both units green from boot,
`usb-signallerctl status` shows developer, USB net comes up as usb0.
2. Diff against upstream HEAD and file the panic upstream — the guard
is a clean candidate patch.

View file

@ -107,6 +107,9 @@ pub enum MemoryCommand {
Delete { path: String }, Delete { path: String },
/// Push/fetch against the shared memfs remote (per-instance branch). /// Push/fetch against the shared memfs remote (per-instance branch).
Sync, Sync,
/// Audit frontmatter health: list frontmatter-bound files missing
/// the required `description` field. Read-only — never mutates.
Audit,
} }
// ── Git-backed Memory Repository ─────────────────────────────────────────── // ── Git-backed Memory Repository ───────────────────────────────────────────
@ -344,6 +347,22 @@ impl MemoryRepo {
} }
} }
// `description` is required on frontmatter-bound files — it is the
// file's headline in the prompt assembler, so persisting an empty
// one is a schema gap. We never fabricate one: the write is rejected
// loudly and the agent must supply a real description. (Memory-only
// write could still seed a default, but an agent-supplied empty
// frontmatter block that clears it must not silently take hold.)
if Self::is_frontmatter_bound(label) && frontmatter.description.trim().is_empty() {
return Err(anyhow!(
"memory write rejected: {} has no description. \
`description:` is required frontmatter on this file \
include it in the write (or a `---\\ndescription: ...\\n---` block). \
Run `memory audit` to see all files missing it.",
label
));
}
let rendered = render_frontmatter(&frontmatter, body); let rendered = render_frontmatter(&frontmatter, body);
tokio::fs::write(&path, &rendered) tokio::fs::write(&path, &rendered)
.await .await
@ -390,6 +409,19 @@ impl MemoryRepo {
)); ));
} }
} }
// `description` is required on frontmatter-bound files. Appending
// to a file that already lacks one must not silently perpetuate
// the gap — reject loudly so the agent heals the description
// before growing the file. (See `write` for the same guard.)
if Self::is_frontmatter_bound(label) && frontmatter.description.trim().is_empty() {
return Err(anyhow!(
"memory append rejected: {} has no description. \
`description:` is required frontmatter on this file \
heal it with a `memory write` (include a `description:` block) \
before appending. Run `memory audit` to see all such files.",
label
));
}
let rendered = render_frontmatter(&frontmatter, &new_body); let rendered = render_frontmatter(&frontmatter, &new_body);
tokio::fs::write(&path, &rendered).await?; tokio::fs::write(&path, &rendered).await?;
@ -447,6 +479,66 @@ impl MemoryRepo {
Ok(entries) Ok(entries)
} }
/// Recursively collect every `.md` under the memory root as a path
/// relative to the root (forward slashes), skipping `.git*`. Internal
/// helper for `audit`.
async fn walk_md_relative(&self) -> Result<Vec<String>> {
let mut out = Vec::new();
let root = self.root.clone();
let mut stack: Vec<(std::path::PathBuf, String)> = vec![(root.clone(), String::new())];
while let Some((dir, rel_prefix)) = stack.pop() {
let mut read_dir = tokio::fs::read_dir(&dir).await.with_context(|| {
format!("walking memory directory: {}", dir.display())
})?;
while let Some(entry) = read_dir.next_entry().await? {
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with(".git") {
continue;
}
let kind = entry.file_type().await?;
let rel = if rel_prefix.is_empty() {
name.clone()
} else {
format!("{rel_prefix}/{name}")
};
if kind.is_dir() {
stack.push((entry.path(), rel));
} else if kind.is_file() && name.ends_with(".md") {
out.push(rel);
}
}
}
out.sort();
Ok(out)
}
/// Audit frontmatter health: return the relative paths of
/// frontmatter-bound memory files whose `description` is empty. Read-only.
///
/// Excludes files that legitimately carry no `description` because they
/// use a different schema — `tasks/` (TodoItem), `system/dynamic/`
/// (itinerary), `journal/` (freeform) — by reusing `is_frontmatter_bound`.
/// Parse failures are tolerated (a file must stay reachable to be healed),
/// matching the tolerant-on-read contract of `parse_memory_file`.
pub async fn audit(&self) -> Result<Vec<String>> {
let mut missing = Vec::new();
for rel in self.walk_md_relative().await? {
if !Self::is_frontmatter_bound(&rel) {
continue;
}
let path = self.root.join(&rel);
let content = match tokio::fs::read_to_string(&path).await {
Ok(c) => c,
Err(_) => continue, // don't let one unreadable file abort the audit
};
let parsed = parse_memory_file(&content)?;
if parsed.frontmatter.description.trim().is_empty() {
missing.push(rel);
}
}
Ok(missing)
}
/// Delete a memory file. /// Delete a memory file.
pub async fn delete(&self, label: &str) -> Result<()> { pub async fn delete(&self, label: &str) -> Result<()> {
let path = self.resolve_path(label); let path = self.resolve_path(label);
@ -678,6 +770,30 @@ impl MemoryRepo {
.context("opening memory git repository") .context("opening memory git repository")
} }
/// Whether the `description` frontmatter contract applies to a label.
///
/// `description` is required on `MemoryFrontmatter` files — the prompt
/// assembler reads it as the file's one-line headline, so an empty one
/// is a real schema gap, not cosmetic. But not every `.md` under the
/// memory tree is a `MemoryFrontmatter` file: `tasks/` uses the
/// `TodoItem` schema, `system/dynamic/` holds the itinerary (its own
/// richer, unfinished schema), and `journal/` is freeform prose. Those
/// legitimately lack `description`; enforcing it there would reject
/// valid writes. This predicate scopes the rule to frontmatter-bound
/// locations only.
fn is_frontmatter_bound(label: &str) -> bool {
let rel = label.trim().trim_start_matches(['/', '.']).replace('\\', "/");
let lower = rel.to_ascii_lowercase();
// Exact segment prefixes, not substring matches, so a file like
// `reference/tasks-today.md` is not mistaken for a todo file.
let under = |seg: &str| {
lower == seg
|| lower.starts_with(&format!("{seg}/"))
|| lower.starts_with(&format!("{seg}\\"))
};
!(under("tasks") || under("system/dynamic") || under("journal"))
}
fn resolve_path(&self, label: &str) -> PathBuf { fn resolve_path(&self, label: &str) -> PathBuf {
let clean = label.trim().trim_end_matches(".md"); let clean = label.trim().trim_end_matches(".md");
self.root.join(format!("{}.md", clean)) self.root.join(format!("{}.md", clean))
@ -988,6 +1104,28 @@ pub async fn execute_memory_command_with_context(
let label: String = pubkey.chars().take(12).collect(); let label: String = pubkey.chars().take(12).collect();
repo.sync(&label).await repo.sync(&label).await
} }
MemoryCommand::Audit => {
// Read-only health check: which frontmatter-bound files lack the
// required `description`? Never mutates — surfacing the gap is the
// point. `write`/`append` will reject attempts to perpetuate one.
let missing = repo.audit().await?;
if missing.is_empty() {
Ok("All frontmatter-bound memory files carry a description.".to_string())
} else {
let mut out = format!(
"{} frontmatter-bound file(s) missing `description:`:\n",
missing.len()
);
for rel in &missing {
out.push_str(&format!(" - {rel}\n"));
}
out.push_str(
"Heal each with a `memory write` that includes a `description:` \
block (or set it inline). These files reject append until healed.\n",
);
Ok(out)
}
}
} }
} }
@ -1046,12 +1184,13 @@ pub async fn handle_memory_tool_with_context(
MemoryCommand::Compact { strategy } MemoryCommand::Compact { strategy }
} }
"sync" => MemoryCommand::Sync, "sync" => MemoryCommand::Sync,
"audit" => MemoryCommand::Audit,
_ => { _ => {
return ToolResult { return ToolResult {
tool_use_id, tool_use_id,
tool_name: tool_name.to_string(), tool_name: tool_name.to_string(),
output: format!( output: format!(
"Unknown memory subcommand: {}. Available: read, write, append, ls, status, init, delete, compact, sync", "Unknown memory subcommand: {}. Available: read, write, append, ls, status, init, delete, compact, sync, audit",
command command
), ),
is_error: true, is_error: true,
@ -1103,6 +1242,7 @@ Subcommands:
delete Delete a memory file (validates read_only first). delete Delete a memory file (validates read_only first).
compact Compact the memory window to free context space. compact Compact the memory window to free context space.
sync Push my instance branch to the shared memfs remote and fetch what my other instances wrote. My memory travels; this is how. sync Push my instance branch to the shared memfs remote and fetch what my other instances wrote. My memory travels; this is how.
audit List frontmatter-bound files missing the required `description` (read-only). `write`/`append` reject a missing description, so this is how I find what to heal.
Paths are relative to my memory directory. Frontmatter description is required on create. Read-only files protect themselves. Every write is a git commit.".to_string(), Paths are relative to my memory directory. Frontmatter description is required on create. Read-only files protect themselves. Every write is a git commit.".to_string(),
input_schema: serde_json::json!({ input_schema: serde_json::json!({
@ -1110,7 +1250,7 @@ Paths are relative to my memory directory. Frontmatter description is required o
"properties": { "properties": {
"command": { "command": {
"type": "string", "type": "string",
"enum": ["read", "write", "append", "ls", "status", "init", "delete", "compact", "sync"], "enum": ["read", "write", "append", "ls", "status", "init", "delete", "compact", "sync", "audit"],
"description": "What to do with my memory" "description": "What to do with my memory"
}, },
"path": { "path": {
@ -1340,4 +1480,124 @@ mod tests {
let after = std::fs::read_to_string(&commitments).unwrap(); let after = std::fs::read_to_string(&commitments).unwrap();
assert_eq!(before, after, "second init should not overwrite"); assert_eq!(before, after, "second init should not overwrite");
} }
// ── description-required guard + audit ──────────────────────────────
#[test]
fn test_is_frontmatter_bound_scoping() {
// Frontmatter-bound: the description contract applies.
assert!(MemoryRepo::is_frontmatter_bound("system/persona"));
assert!(MemoryRepo::is_frontmatter_bound("reference/arch.md"));
assert!(MemoryRepo::is_frontmatter_bound("projects/plan.md"));
assert!(MemoryRepo::is_frontmatter_bound("issues/bug.md"));
// Different schemas that legitimately lack description.
assert!(!MemoryRepo::is_frontmatter_bound("tasks/some-todo.md"));
assert!(!MemoryRepo::is_frontmatter_bound("system/dynamic/itinerary.md"));
assert!(!MemoryRepo::is_frontmatter_bound("journal/2026/05/20.md"));
// Segment prefixes, not substring matches — a file named like a
// todo but living elsewhere is still bound.
assert!(MemoryRepo::is_frontmatter_bound("reference/tasks-today.md"));
assert!(MemoryRepo::is_frontmatter_bound("projects/journal-of-x.md"));
}
#[tokio::test]
async fn test_write_rejects_empty_description_on_bound_file() {
let dir = TempDir::new().unwrap();
let repo = MemoryRepo::new("test-agent", dir.path());
repo.init().await.unwrap();
// Seed a frontmatter-bound file with NO description (the gap).
std::fs::write(
repo.root().join("issues/gap.md"),
"---\nread_only: false\n---\nBody with no description\n",
)
.unwrap();
// A body-only write must not silently perpetuate the empty description.
let err = repo.write("issues/gap.md", "updated body").await.unwrap_err();
assert!(
err.to_string().contains("no description"),
"expected loud rejection, got: {err}"
);
// Supplying a description heals it and succeeds.
repo.write(
"issues/gap.md",
"---\ndescription: The healed headline\n---\nupdated body\n",
)
.await
.unwrap();
let healed = parse_memory_file(&std::fs::read_to_string(repo.root().join("issues/gap.md")).unwrap()).unwrap();
assert_eq!(healed.frontmatter.description, "The healed headline");
}
#[tokio::test]
async fn test_write_allows_no_description_on_unbound_schemas() {
let dir = TempDir::new().unwrap();
let repo = MemoryRepo::new("test-agent", dir.path());
repo.init().await.unwrap();
// tasks/, journal/, system/dynamic/ legitimately lack description.
repo.write("tasks/a-todo.md", "todo body").await.unwrap();
repo.write("journal/2026/05/20.md", "freeform journal entry").await.unwrap();
repo.write("system/dynamic/itinerary.md", "itinerary body").await.unwrap();
// New bound files still get the auto-generated default on create.
repo.write("reference/new.md", "some reference").await.unwrap();
let f = parse_memory_file(&std::fs::read_to_string(repo.root().join("reference/new.md")).unwrap()).unwrap();
assert!(!f.frontmatter.description.is_empty());
}
#[tokio::test]
async fn test_append_rejects_empty_description_on_bound_file() {
let dir = TempDir::new().unwrap();
let repo = MemoryRepo::new("test-agent", dir.path());
repo.init().await.unwrap();
// Existing bound file missing description.
std::fs::write(
repo.root().join("projects/gap.md"),
"---\n---\nInitial body\n",
)
.unwrap();
let err = repo.append("projects/gap.md", "more").await.unwrap_err();
assert!(err.to_string().contains("no description"));
}
#[tokio::test]
async fn test_audit_flags_only_bound_files_missing_description() {
let dir = TempDir::new().unwrap();
let repo = MemoryRepo::new("test-agent", dir.path());
repo.init().await.unwrap();
// Bound + missing description → flagged.
std::fs::write(
repo.root().join("issues/missing.md"),
"---\n---\nbody\n",
)
.unwrap();
// Bound + has description → not flagged.
std::fs::write(
repo.root().join("reference/has.md"),
"---\ndescription: present\n---\nbody\n",
)
.unwrap();
// Unbound schema + no description → not flagged (legitimate).
std::fs::write(
repo.root().join("tasks/todo.md"),
"---\nid: t1\n---\nbody\n",
)
.unwrap();
std::fs::write(
repo.root().join("journal/2026/05/20.md"),
"---\n---\nfreeform\n",
)
.unwrap();
let missing = repo.audit().await.unwrap();
assert_eq!(missing, vec!["issues/missing.md".to_string()]);
}
} }

View file

@ -256,6 +256,16 @@ fn handle_request(
} }
Request::ShellReady => { Request::ShellReady => {
let mut d = shared.lock(); let mut d = shared.lock();
// Session authority is an exclusive lease. A utility QML process
// or duplicate shell must never supersede the live supervised
// shell merely by sending shell_ready: doing so turns that
// process's EOF into a false shell-death lock. The current
// authority must disconnect first; its connection handler clears
// shell_alive before a replacement can register.
if d.shell_alive {
return refusal("shell authority is already registered");
}
d.heartbeat_gen += 1; d.heartbeat_gen += 1;
d.shell_alive = true; d.shell_alive = true;
let gen = d.heartbeat_gen; let gen = d.heartbeat_gen;
@ -319,3 +329,52 @@ fn handle_request(
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
fn idle_shared() -> Arc<Shared> {
Arc::new(Shared {
state: Mutex::new(Daemon {
phase: Phase::Idle,
controller: None,
heartbeat_gen: 0,
shell_alive: false,
pam_service: "test".to_string(),
}),
cond: Condvar::new(),
})
}
#[test]
fn second_shell_ready_cannot_steal_authority_lease() {
let shared = idle_shared();
let mut authority_heartbeat = None;
let first = handle_request(
Request::ShellReady,
&shared,
&mut authority_heartbeat,
);
assert_eq!(first["ok"], true);
assert_eq!(authority_heartbeat, Some(1));
let mut utility_heartbeat = None;
let second = handle_request(
Request::ShellReady,
&shared,
&mut utility_heartbeat,
);
assert_eq!(second["ok"], false);
assert_eq!(
second["reason"],
"shell authority is already registered"
);
assert_eq!(utility_heartbeat, None);
let state = shared.lock();
assert!(state.shell_alive);
assert_eq!(state.heartbeat_gen, 1);
assert_eq!(state.phase, Phase::Released);
}
}

View file

@ -33,6 +33,12 @@ Singleton {
// true once the compositor has acknowledged the lock. Consumers that // true once the compositor has acknowledged the lock. Consumers that
// disclose personal data must gate on secure, not merely on the request. // disclose personal data must gate on secure, not merely on the request.
property bool screenLocked: false property bool screenLocked: false
// True while the display is genuinely in use (IdleCoordinator Active or
// Waking). The idle machine drives it; widgets/pollers whose data may go
// stale while the screen is dimmed/locked/asleep gate their timers and
// processes on this instead of importing the souveraine IdleCoordinator
// singleton into ii-base (quickshell-idle-power task 4).
property bool displayActive: true
property bool screenLockSecure: false property bool screenLockSecure: false
property bool screenLockContainsCharacters: false property bool screenLockContainsCharacters: false
property bool screenUnlockFailed: false property bool screenUnlockFailed: false

View file

@ -0,0 +1,414 @@
//@ pragma UseQApplication
//@ pragma Env QS_NO_RELOAD_POPUP=1
//@ pragma Env QT_QUICK_CONTROLS_STYLE=Basic
//@ pragma Env QT_QUICK_FLICKABLE_WHEEL_DECELERATION=10000
// Souveraine settings one ordinary app window, owned by the authoritative
// shell process. Launchers call the `settings` IPC target; they never start a
// second Quickshell configuration or a second session-authority scope.
//
// Two form factors are gated on
// Config.options.souveraine.phone (the Device tab's "Phone mode" switch):
//
// phone === true full-screen portrait stack navigation
// (list push page back), finger-sized rows.
// phone === false a normal desktop window: a side rail of pages beside
// a content pane, both selecting from the SAME pages[].
//
// Both modes load identical page components (modules/settings/*Config.qml) via
// one Loader, so a page added once appears in both. Only the chrome differs.
// This replaces the old split of settings.qml (upstream desktop) +
// settings-phone.qml (our mobile) one file is the source of truth now.
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtQuick.Window
import Quickshell
import Quickshell.Io
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions as CF
ApplicationWindow {
id: root
readonly property bool phoneMode: Config.options.souveraine.phone
// -1 = list view (phone idle state). In desktop mode a page is always
// selected, so it starts at 0.
property int currentPage: Config.options.souveraine.phone ? -1 : 0
readonly property real rowHeight: 68 // finger-sized tap target
readonly property real edgeMargin: 16
// The page set shared by both chromes. {name, icon, component}. One page
// per domain we own; About reuses ii's stock page verbatim.
property var pages: [
{
name: Translation.tr("Device"),
icon: "smartphone",
component: "modules/settings/DeviceConfig.qml"
},
{
name: Translation.tr("Network & internet"),
icon: "wifi",
component: "modules/settings/NetworkConfig.qml"
},
{
name: Translation.tr("Display"),
icon: "display_settings",
component: "modules/settings/DisplayConfig.qml"
},
{
name: Translation.tr("Sound & microphone"),
icon: "volume_up",
component: "modules/settings/SoundConfig.qml"
},
{
name: Translation.tr("Lock screen"),
icon: "lock",
component: "modules/settings/LockConfig.qml"
},
{
name: Translation.tr("Wallpaper"),
icon: "wallpaper",
component: "modules/settings/WallpaperConfig.qml"
},
{
name: Translation.tr("Dock"),
icon: "dock_to_bottom",
component: "modules/settings/DockConfig.qml"
},
{
name: Translation.tr("Navigation"),
icon: "gesture",
component: "modules/settings/NavigationConfig.qml"
},
{
name: Translation.tr("Idle & sleep"),
icon: "bedtime",
component: "modules/settings/IdleConfig.qml"
},
{
name: Translation.tr("Keyboard"),
icon: "keyboard",
component: "modules/settings/KeyboardConfig.qml"
},
{
name: Translation.tr("Speech"),
icon: "mic",
component: "modules/settings/SpeechConfig.qml"
},
{
name: Translation.tr("About"),
icon: "info",
component: "modules/settings/About.qml"
}
// Next: app permissions/health, storage, accounts, updates.
]
visible: false
onClosing: close => {
// Closing Settings closes this app window, not the shell that owns it.
close.accepted = false
root.hideSettings()
}
title: "Settings"
color: Appearance.m3colors.m3background
// Phone windows deliberately start windowed and then take the exact same
// Hyprland fullscreen path as the navigation pill. Qt's Window.FullScreen
// sends a client fullscreen request instead; Hyprland treats that state
// differently from the pill's internal fullscreen dispatcher and the
// navigation rail can disappear. Keeping one compositor-owned path also
// means double-tapping the pill reliably returns Settings to windowed.
width: root.phoneMode ? Screen.width : 900
height: root.phoneMode ? Screen.height : 640
minimumWidth: root.phoneMode ? 0 : 640
minimumHeight: root.phoneMode ? 0 : 480
Component.onCompleted: {
MaterialThemeLoader.reapplyTheme()
Config.readWriteDelay = 0
}
function showSettings() {
root.visible = true
root.raise()
root.requestActivate()
if (root.phoneMode) {
phoneFullscreenRetry.attempts = 0
phoneFullscreenRetry.start()
}
}
function hideSettings() {
root.visible = false
if (root.phoneMode) root.currentPage = -1
phoneFullscreenRetry.stop()
}
function toggleSettings() {
if (root.visible) root.hideSettings()
else root.showSettings()
}
IpcHandler {
target: "settings"
function open() { root.showSettings() }
function close() { root.hideSettings() }
function toggle() { root.toggleSettings() }
function state() {
return JSON.stringify({
visible: root.visible,
phone: root.phoneMode,
page: root.currentPage
})
}
}
Process {
id: phoneFullscreen
command: [
"hyprctl", "dispatch",
`hl.dsp.window.fullscreen({ window = "pid:${Quickshell.processId}", mode = "fullscreen", action = "set" })`
]
}
// The QWindow maps just after Component.onCompleted. Retry briefly so a
// cold-started settings process cannot race its own Wayland toplevel.
Timer {
id: phoneFullscreenRetry
property int attempts: 0
interval: 180
repeat: true
onTriggered: {
attempts += 1
if (root.visible && !phoneFullscreen.running)
phoneFullscreen.running = true
if (attempts >= 3) stop()
}
}
// ====================================================================
// PHONE MODE full-screen stack navigation
// ====================================================================
// --- List view (idle state) ------------------------------------------
ColumnLayout {
id: listView
anchors.fill: parent
spacing: 0
visible: root.phoneMode && root.currentPage < 0
Item {
Layout.fillWidth: true
Layout.leftMargin: root.edgeMargin
Layout.rightMargin: root.edgeMargin
Layout.topMargin: root.edgeMargin + 16
Layout.bottomMargin: 8
implicitHeight: titleText.implicitHeight
StyledText {
id: titleText
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
color: Appearance.colors.colOnLayer0
text: Translation.tr("Settings")
font {
family: Appearance.font.family.title
pixelSize: Appearance.font.pixelSize.title
variableAxes: Appearance.font.variableAxes.title
}
}
}
StyledListView {
id: pageList
Layout.fillWidth: true
Layout.fillHeight: true
model: root.pages
delegate: RippleButton {
required property var index
required property var modelData
width: pageList.width
height: root.rowHeight
buttonRadius: 0
onClicked: root.currentPage = index
contentItem: RowLayout {
anchors.fill: parent
anchors.leftMargin: root.edgeMargin
anchors.rightMargin: root.edgeMargin
spacing: 16
MaterialSymbol {
text: modelData.icon
iconSize: Appearance.font.pixelSize.larger
color: Appearance.colors.colOnLayer0
}
StyledText {
Layout.fillWidth: true
text: modelData.name
color: Appearance.colors.colOnLayer0
font.pixelSize: Appearance.font.pixelSize.larger
}
MaterialSymbol {
text: "chevron_right"
iconSize: Appearance.font.pixelSize.larger
color: Appearance.colors.colSubtext
}
}
}
}
}
// --- Page view (drilled-in state) ------------------------------------
Item {
id: pageView
anchors.fill: parent
visible: root.phoneMode && root.currentPage >= 0
Item {
id: pageHeader
anchors { top: parent.top; left: parent.left; right: parent.right }
height: 56
RippleButton {
id: backButton
anchors {
left: parent.left
leftMargin: 4
verticalCenter: parent.verticalCenter
}
buttonRadius: Appearance.rounding.full
implicitWidth: 48
implicitHeight: 48
onClicked: root.currentPage = -1
contentItem: MaterialSymbol {
anchors.centerIn: parent
horizontalAlignment: Text.AlignHCenter
text: "arrow_back"
iconSize: 24
}
}
StyledText {
anchors {
left: backButton.right
leftMargin: 8
right: parent.right
rightMargin: root.edgeMargin
verticalCenter: parent.verticalCenter
}
color: Appearance.colors.colOnLayer0
text: (root.currentPage >= 0 && root.currentPage < root.pages.length)
? root.pages[root.currentPage].name : ""
elide: Text.ElideRight
font {
family: Appearance.font.family.title
pixelSize: Appearance.font.pixelSize.larger
}
}
}
Loader {
id: phonePageLoader
anchors {
top: pageHeader.bottom
left: parent.left
right: parent.right
bottom: parent.bottom
}
active: root.phoneMode && root.currentPage >= 0 && Config.ready
source: (root.currentPage >= 0 && root.currentPage < root.pages.length)
? root.pages[root.currentPage].component : ""
}
}
// ====================================================================
// DESKTOP MODE side rail + content pane
// ====================================================================
RowLayout {
id: desktopView
anchors.fill: parent
spacing: 0
visible: !root.phoneMode
// Left rail
Rectangle {
Layout.fillHeight: true
Layout.preferredWidth: 220
color: Appearance.colors.colLayer1
ColumnLayout {
anchors.fill: parent
spacing: 0
Item {
Layout.fillWidth: true
Layout.topMargin: root.edgeMargin + 8
Layout.leftMargin: root.edgeMargin
Layout.bottomMargin: 8
implicitHeight: railTitle.implicitHeight
StyledText {
id: railTitle
anchors.left: parent.left
text: Translation.tr("Settings")
color: Appearance.colors.colOnLayer1
font {
family: Appearance.font.family.title
pixelSize: Appearance.font.pixelSize.larger
variableAxes: Appearance.font.variableAxes.title
}
}
}
StyledListView {
id: railList
Layout.fillWidth: true
Layout.fillHeight: true
model: root.pages
delegate: RippleButton {
required property var index
required property var modelData
width: railList.width
height: 44
buttonRadius: 0
toggled: root.currentPage === index
colBackgroundToggled: Appearance.colors.colSecondaryContainer
colBackgroundToggledHover: Appearance.colors.colSecondaryContainerHover
colRippleToggled: Appearance.colors.colSecondaryContainerActive
onClicked: root.currentPage = index
contentItem: RowLayout {
anchors.fill: parent
anchors.leftMargin: root.edgeMargin
anchors.rightMargin: 8
spacing: 12
MaterialSymbol {
text: modelData.icon
iconSize: Appearance.font.pixelSize.large
color: root.currentPage === index
? Appearance.colors.colOnSecondaryContainer : Appearance.colors.colOnLayer1
}
StyledText {
Layout.fillWidth: true
text: modelData.name
elide: Text.ElideRight
color: root.currentPage === index
? Appearance.colors.colOnSecondaryContainer : Appearance.colors.colOnLayer1
font.pixelSize: Appearance.font.pixelSize.normal
}
}
}
}
}
}
// Content pane
Loader {
id: desktopPageLoader
Layout.fillWidth: true
Layout.fillHeight: true
active: !root.phoneMode && root.currentPage >= 0 && Config.ready
source: (root.currentPage >= 0 && root.currentPage < root.pages.length)
? root.pages[root.currentPage].component : ""
}
}
}

View file

@ -0,0 +1,670 @@
{
"levels": [
{
"level": "",
"mode": "default",
"rows": [
[
[
"1",
"¹",
"½",
"⅓",
"¼",
"⅛"
],
[
"2",
"²",
"⅔"
],
[
"3",
"³",
"¾",
"⅜"
],
[
"4",
"⁴"
],
[
"5",
"⅝"
],
[
"6"
],
[
"7",
"⅞"
],
[
"8"
],
[
"9"
],
[
"0",
"ⁿ",
"∅"
]
],
[
[
"q"
],
[
"w"
],
[
"e",
"è",
"é",
"ê",
"ë",
"ē"
],
[
"r"
],
[
"t"
],
[
"y"
],
[
"u",
"û",
"ü",
"ù",
"ú",
"ū"
],
[
"i",
"î",
"ï",
"í",
"ī",
"ì"
],
[
"o",
"ô",
"ö",
"ò",
"ó",
"œ",
"ø",
"ō",
"õ"
],
[
"p"
]
],
[
[
"a",
"à",
"á",
"â",
"ä",
"æ",
"ã",
"å",
"ā"
],
[
"s",
"ß"
],
[
"d"
],
[
"f"
],
[
"g"
],
[
"h"
],
[
"j"
],
[
"k"
],
[
"l"
]
],
[
[
"z"
],
[
"x"
],
[
"c",
"ç"
],
[
"v"
],
[
"b"
],
[
"n",
"ñ"
],
[
"m"
]
],
[
[
","
],
[
" "
],
[
".",
"#",
"!",
",",
"?",
"-",
":",
"'",
"@"
]
]
]
},
{
"level": "shift",
"mode": "latched",
"rows": [
[
[
"1",
"¹",
"½",
"⅓",
"¼",
"⅛"
],
[
"2",
"²",
"⅔"
],
[
"3",
"³",
"¾",
"⅜"
],
[
"4",
"⁴"
],
[
"5",
"⅝"
],
[
"6"
],
[
"7",
"⅞"
],
[
"8"
],
[
"9"
],
[
"0",
"ⁿ",
"∅"
]
],
[
[
"Q"
],
[
"W"
],
[
"E",
"È",
"É",
"Ê",
"Ë",
"Ē"
],
[
"R"
],
[
"T"
],
[
"Y"
],
[
"U",
"Û",
"Ü",
"Ù",
"Ú",
"Ū"
],
[
"I",
"Î",
"Ï",
"Í",
"Ī",
"Ì"
],
[
"O",
"Ô",
"Ö",
"Ò",
"Ó",
"Œ",
"Ø",
"Ō",
"Õ"
],
[
"P"
]
],
[
[
"A",
"À",
"Á",
"Â",
"Ä",
"Æ",
"Ã",
"Å",
"Ā"
],
[
"S",
"ẞ",
"SS"
],
[
"D"
],
[
"F"
],
[
"G"
],
[
"H"
],
[
"J"
],
[
"K"
],
[
"L"
]
],
[
[
"Z"
],
[
"X"
],
[
"C",
"Ç"
],
[
"V"
],
[
"B"
],
[
"N",
"Ñ"
],
[
"M"
]
],
[
[
","
],
[
" "
],
[
".",
"#",
"!",
",",
"?",
"-",
":",
"'",
"@"
]
]
]
},
{
"level": "opt",
"mode": "locked",
"rows": [
[
[
"1",
"¹",
"½",
"⅓",
"¼",
"⅛"
],
[
"2",
"²",
"⅔"
],
[
"3",
"³",
"¾",
"⅜"
],
[
"4",
"⁴"
],
[
"5",
"⅝"
],
[
"6"
],
[
"7",
"⅞"
],
[
"8"
],
[
"9"
],
[
"0",
"ⁿ",
"∅"
]
],
[
[
"@"
],
[
"#"
],
[
"$",
"¢",
"£",
"€",
"¥",
"₱"
],
[
"%",
"‰"
],
[
"&"
],
[
"-",
"_",
"",
"—",
"·"
],
[
"+",
"±"
],
[
"(",
"<",
"{",
"["
],
[
")",
">",
"}",
"]"
]
],
[
[
"*",
"†",
"‡",
"★"
],
[
"\"",
"“",
"”",
"«",
"»"
],
[
"'",
"",
"",
"",
""
],
[
":"
],
[
";"
],
[
"!",
"¡"
],
[
"?",
"¿"
]
],
[
[
"_"
],
[
"/"
],
[
" "
],
[
","
],
[
".",
"…"
]
]
]
},
{
"level": "opt+shift",
"mode": "locked",
"rows": [
[
[
"~"
],
[
"`"
],
[
"|"
],
[
"•",
"♪",
"♥",
"♠",
"♦",
"♣"
],
[
"√"
],
[
"Π",
"π"
],
[
"÷"
],
[
"×"
],
[
"¶",
"§"
],
[
"∆"
]
],
[
[
"£"
],
[
"¢"
],
[
"€"
],
[
"¥"
],
[
"^",
"↑",
"↓",
"←",
"→"
],
[
"°",
"",
"″"
],
[
"=",
"≠",
"≈",
"∞"
],
[
"{"
],
[
"}"
]
],
[
[
"\\"
],
[
"©"
],
[
"®"
],
[
"™"
],
[
"℅"
],
[
"["
],
[
"]"
]
],
[
[
"<",
"",
"≤",
"«"
],
[
">",
"",
"≥",
"»"
],
[
" "
],
[
","
],
[
".",
"…"
]
]
]
}
],
"locale": "en",
"name": "English (US) — number row"
}

View file

@ -31,8 +31,7 @@ TARGET_ARCH="${SOUVERAINE_TARGET_ARCH:-$(uname -m)}"
MANIFEST=" MANIFEST="
shell.qml souveraine/shell.qml shell.qml souveraine/shell.qml
GlobalStates.qml souveraine/GlobalStates.qml GlobalStates.qml souveraine/GlobalStates.qml
settings.qml souveraine/settings.qml SettingsWindow.qml souveraine/SettingsWindow.qml
settings-phone.qml souveraine/settings-phone.qml
panelFamilies/SouveraineFamily.qml souveraine/panelFamilies/SouveraineFamily.qml panelFamilies/SouveraineFamily.qml souveraine/panelFamilies/SouveraineFamily.qml
services/Souveraine.qml souveraine/services/Souveraine.qml services/Souveraine.qml souveraine/services/Souveraine.qml
services/Ai.qml souveraine/services/Ai.qml services/Ai.qml souveraine/services/Ai.qml
@ -57,6 +56,7 @@ services/CrashReporter.qml souveraine/services/CrashReporter.qml
modules/ii/sidebarLeft/SidebarLeft.qml souveraine/modules/ii/sidebarLeft/SidebarLeft.qml modules/ii/sidebarLeft/SidebarLeft.qml souveraine/modules/ii/sidebarLeft/SidebarLeft.qml
modules/ii/sidebarLeft/AiChat.qml souveraine/modules/ii/sidebarLeft/AiChat.qml modules/ii/sidebarLeft/AiChat.qml souveraine/modules/ii/sidebarLeft/AiChat.qml
modules/ii/sidebarRight/SidebarRight.qml souveraine/modules/ii/sidebarRight/SidebarRight.qml modules/ii/sidebarRight/SidebarRight.qml souveraine/modules/ii/sidebarRight/SidebarRight.qml
modules/ii/sidebarRight/SidebarRightContent.qml souveraine/modules/ii/sidebarRight/SidebarRightContent.qml
modules/ii/sidebarRight/QuickSliders.qml souveraine/modules/ii/sidebarRight/QuickSliders.qml modules/ii/sidebarRight/QuickSliders.qml souveraine/modules/ii/sidebarRight/QuickSliders.qml
modules/ii/sidebarRight/volumeMixer/VolumeDialogContent.qml souveraine/modules/ii/sidebarRight/volumeMixer/VolumeDialogContent.qml modules/ii/sidebarRight/volumeMixer/VolumeDialogContent.qml souveraine/modules/ii/sidebarRight/volumeMixer/VolumeDialogContent.qml
modules/ii/bar/UtilButtons.qml souveraine/modules/ii/bar/UtilButtons.qml modules/ii/bar/UtilButtons.qml souveraine/modules/ii/bar/UtilButtons.qml
@ -66,6 +66,9 @@ modules/common/functions/Session.qml souveraine/modules/common/functions/Session
modules/common/widgets/ContentPage.qml souveraine/modules/common/widgets/ContentPage.qml modules/common/widgets/ContentPage.qml souveraine/modules/common/widgets/ContentPage.qml
modules/common/widgets/StyledToolTip.qml souveraine/modules/common/widgets/StyledToolTip.qml modules/common/widgets/StyledToolTip.qml souveraine/modules/common/widgets/StyledToolTip.qml
modules/settings/DeviceConfig.qml souveraine/modules/settings/DeviceConfig.qml modules/settings/DeviceConfig.qml souveraine/modules/settings/DeviceConfig.qml
modules/settings/NetworkConfig.qml souveraine/modules/settings/NetworkConfig.qml
modules/settings/DisplayConfig.qml souveraine/modules/settings/DisplayConfig.qml
modules/settings/SoundConfig.qml souveraine/modules/settings/SoundConfig.qml
modules/settings/LockConfig.qml souveraine/modules/settings/LockConfig.qml modules/settings/LockConfig.qml souveraine/modules/settings/LockConfig.qml
modules/settings/WallpaperConfig.qml souveraine/modules/settings/WallpaperConfig.qml modules/settings/WallpaperConfig.qml souveraine/modules/settings/WallpaperConfig.qml
modules/settings/DockConfig.qml souveraine/modules/settings/DockConfig.qml modules/settings/DockConfig.qml souveraine/modules/settings/DockConfig.qml
@ -159,6 +162,62 @@ fi
if [[ "${1:-}" == "--phone" ]]; then if [[ "${1:-}" == "--phone" ]]; then
ssh_i=(ssh -F /dev/null -i "$HOME/.ssh/ani" -o ConnectTimeout=5) ssh_i=(ssh -F /dev/null -i "$HOME/.ssh/ani" -o ConnectTimeout=5)
maintenance_active=0
hypridle_was_active=0
phone_lock_state() {
local state last_secure
state="$("${ssh_i[@]}" "$PHONE_USB" \
"qs -c souveraine ipc --any-display call session state 2>/dev/null" || true)"
if grep -Eq '"locked"[[:space:]]*:[[:space:]]*false' <<< "$state" \
&& grep -Eq '"lockRequested"[[:space:]]*:[[:space:]]*false' <<< "$state"; then
printf '%s\n' unlocked
return
fi
if grep -Eq '"locked"[[:space:]]*:[[:space:]]*true|"lockRequested"[[:space:]]*:[[:space:]]*true' <<< "$state"; then
printf '%s\n' locked
return
fi
# Bootstrap for a phone that predates the session IPC handler. Every
# compositor-acknowledged lock edge is journaled by LockScreen.qml.
last_secure="$("${ssh_i[@]}" "$PHONE_USB" \
"journalctl --user -u souveraine-shell.service -b --no-pager | sed -n 's/.*\\[lock\\] session lock secure=\\(true\\|false\\).*/\\1/p' | tail -n 1" || true)"
[[ "$last_secure" == "false" ]] && printf '%s\n' unlocked || printf '%s\n' locked
}
finish_phone_maintenance() {
(( maintenance_active )) || return 0
if (( hypridle_was_active )); then
"${ssh_i[@]}" "$PHONE_USB" \
"systemctl --user restart hypridle.service; systemctl --user stop souveraine-deploy-lease.timer 2>/dev/null || true; systemctl --user reset-failed souveraine-deploy-lease.service 2>/dev/null || true" \
>/dev/null || true
fi
maintenance_active=0
}
if [[ "$(phone_lock_state)" != "unlocked" ]]; then
echo "ERROR: phone session is locked; refusing a live shell deploy" >&2
exit 1
fi
if "${ssh_i[@]}" "$PHONE_USB" "systemctl --user is-active --quiet hypridle.service"; then
hypridle_was_active=1
# Schedule recovery before stopping idle handling. If this deploy or
# its SSH connection dies, systemd restores hypridle within five
# minutes. Manual lock remains available throughout the lease.
"${ssh_i[@]}" "$PHONE_USB" \
"systemctl --user stop souveraine-deploy-lease.timer 2>/dev/null || true; systemd-run --user --unit=souveraine-deploy-lease --on-active=5min --timer-property=AccuracySec=1s --collect /usr/bin/systemctl --user restart hypridle.service >/dev/null; systemctl --user stop hypridle.service"
fi
maintenance_active=1
trap finish_phone_maintenance EXIT
# Close the small check→inhibit race. A manual lock always wins.
if [[ "$(phone_lock_state)" != "unlocked" ]]; then
echo "ERROR: phone locked while acquiring the maintenance lease" >&2
exit 1
fi
deployed_revision="unknown" deployed_revision="unknown"
if repo_root="$(git -C "$SRC" rev-parse --show-toplevel 2>/dev/null)"; then if repo_root="$(git -C "$SRC" rev-parse --show-toplevel 2>/dev/null)"; then
deployed_revision="$(git -C "$repo_root" rev-parse HEAD)" deployed_revision="$(git -C "$repo_root" rev-parse HEAD)"
@ -174,6 +233,8 @@ if [[ "${1:-}" == "--phone" ]]; then
"${ssh_i[@]}" "$PHONE_USB" "bash ~/$PHONE_DEST/deploy.sh" "${ssh_i[@]}" "$PHONE_USB" "bash ~/$PHONE_DEST/deploy.sh"
# Phone-only: install the mobile settings launcher into the app grid. # Phone-only: install the mobile settings launcher into the app grid.
"${ssh_i[@]}" "$PHONE_USB" "mkdir -p ~/.local/share/applications && ln -sf ~/$PHONE_DEST/souveraine-settings.desktop ~/.local/share/applications/souveraine-settings.desktop" "${ssh_i[@]}" "$PHONE_USB" "mkdir -p ~/.local/share/applications && ln -sf ~/$PHONE_DEST/souveraine-settings.desktop ~/.local/share/applications/souveraine-settings.desktop"
finish_phone_maintenance
trap - EXIT
echo "Deployed to phone. Hyprland starts qs -c souveraine; no secondary shell is required." echo "Deployed to phone. Hyprland starts qs -c souveraine; no secondary shell is required."
exit 0 exit 0
fi fi

View file

@ -157,6 +157,33 @@ Singleton {
return str.replace(/\\/g, '\\\\'); return str.replace(/\\/g, '\\\\');
} }
/**
* Cleans assistant reply text for text-to-speech: strips <think> blocks,
* code fences, and markdown formatting so only the spoken voice remains.
* Returns the cleaned text (empty string if nothing speakable survives).
* @param { string } text
* @returns { string }
*/
function ttsClean(text) {
if (!text)
return "";
return text
// Remove <think>...</think> blocks entirely
.replace(/<think>[\s\S]*?<\/think>/g, "")
// Remove markdown headers, bold, italic, code fences
.replace(/```[\s\S]*?```/g, " ")
.replace(/#{1,6}\s/g, " ")
.replace(/\*\*([^*]+)\*\*/g, "$1")
.replace(/\*([^*]+)\*/g, "$1")
.replace(/`([^`]+)`/g, "$1")
// Remove markdown links and images
.replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1")
.replace(/\[([^\]]*)\]\([^)]+\)/g, "$1")
// Collapse whitespace
.replace(/\n{3,}/g, "\n\n")
.trim();
}
/** /**
* Wraps words to supplied maximum length * Wraps words to supplied maximum length
* @param { string | null } str * @param { string | null } str

View file

@ -34,11 +34,22 @@ AbstractBackgroundWidget {
"data_format = ascii\nascii_max_range = 100\n"; "data_format = ascii\nascii_max_range = 100\n";
} }
// Souveraine (idle-power task 4): cava only earns its keep while the
// widget can actually be seen. Gate the watchdog on visibility + display
// activity, and tear cava down when either goes away.
readonly property bool wantCava: parent.visible && GlobalStates.displayActive
onWantCavaChanged: {
if (!wantCava && cavaProcess.running) {
root.cavaReady = false;
cavaProcess.running = false;
}
}
// Watchdog: every 2s, if cava isn't running, write config and start it // Watchdog: every 2s, if cava isn't running, write config and start it
Timer { Timer {
id: watchdog id: watchdog
interval: 2000 interval: 2000
running: true running: root.wantCava
repeat: true repeat: true
triggeredOnStart: true triggeredOnStart: true
onTriggered: { onTriggered: {
@ -190,7 +201,14 @@ AbstractBackgroundWidget {
} }
ctx.stroke(); ctx.stroke();
} }
Timer { interval: 33; running: root.style === "wave"; repeat: true; onTriggered: parent.requestPaint() } // 30fps repaint only while this canvas is actually visible.
// Was `running: root.style === "wave"` but the Canvas's own
// `visible` (line above) already encodes that, AND collapses to
// false when the widget subtree is hidden. Gating the timer on
// `parent.visible` stops the continuous redraw whenever the
// canvas isn't shown, instead of painting behind a hidden
// background. No new import needed (pure QtQuick `visible`).
Timer { interval: 33; running: parent.visible; repeat: true; onTriggered: parent.requestPaint() }
} }
} }
} }

View file

@ -15,6 +15,7 @@ Singleton {
{ identifier: "resources", materialSymbol: "browse_activity" }, { identifier: "resources", materialSymbol: "browse_activity" },
{ identifier: "notes", materialSymbol: "note_stack" }, { identifier: "notes", materialSymbol: "note_stack" },
{ identifier: "volumeMixer", materialSymbol: "volume_up" }, { identifier: "volumeMixer", materialSymbol: "volume_up" },
{ identifier: "subconsciousEventPanel", materialSymbol: "psychology" },
] ]
readonly property bool hasPinnedWidgets: root.pinnedWidgetIdentifiers.length > 0 readonly property bool hasPinnedWidgets: root.pinnedWidgetIdentifiers.length > 0

View file

@ -13,6 +13,7 @@ import qs.modules.ii.overlay.fpsLimiter
import qs.modules.ii.overlay.recorder import qs.modules.ii.overlay.recorder
import qs.modules.ii.overlay.resources import qs.modules.ii.overlay.resources
import qs.modules.ii.overlay.notes import qs.modules.ii.overlay.notes
import qs.modules.ii.overlay.subconsciousEventPanel
DelegateChooser { DelegateChooser {
id: root id: root
@ -25,4 +26,5 @@ DelegateChooser {
DelegateChoice { roleValue: "resources"; Resources {} } DelegateChoice { roleValue: "resources"; Resources {} }
DelegateChoice { roleValue: "notes"; Notes {} } DelegateChoice { roleValue: "notes"; Notes {} }
DelegateChoice { roleValue: "volumeMixer"; VolumeMixer {} } DelegateChoice { roleValue: "volumeMixer"; VolumeMixer {} }
DelegateChoice { roleValue: "subconsciousEventPanel"; SubconsciousEventPanel {} }
} }

View file

@ -0,0 +1,173 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import Quickshell
import qs
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions
import qs.modules.ii.overlay
// Tier 2 of the subconscious three-tier visibility design (see
// docs/tasks/subconscious-surfacing-threshold.md): a persistent, interactive
// event log the shell's equivalent of the TUI "cockpit". Shows surfaced
// subconscious events (pass snapshots, reflections, archivist syntheses,
// surfacings) from past turns. Each entry click-expands to its full text,
// closing docs/bugs.md B-005 ("subconscious messages not expandable").
// Fed by Ai.subconsciousEvents; the live Tier-1 ticker is snapshotted into
// it on pass end.
StyledOverlayWidget {
id: root
title: Translation.tr("Subconscious")
showCenterButton: true
contentItem: Rectangle {
radius: root.contentRadius
color: Appearance.colors.colLayer1
implicitWidth: 340
implicitHeight: 420
ColumnLayout {
anchors.fill: parent
anchors.margins: 8
spacing: 6
// Header
RowLayout {
Layout.fillWidth: true
spacing: 6
MaterialSymbol {
iconSize: Appearance.font.pixelSize.larger
text: "psychology"
color: Appearance.colors.colSubtext
}
StyledText {
Layout.fillWidth: true
text: Translation.tr("Subconscious event log")
color: Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.small
font.bold: true
}
StyledText {
text: Ai.subconsciousEvents.length
color: Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.small
visible: Ai.subconsciousEvents.length > 0
}
}
// Event list
ScrollView {
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
ListView {
id: eventList
model: Ai.subconsciousEvents
spacing: 4
boundsBehavior: Flickable.StopAtBounds
delegate: Rectangle {
width: eventList.width
height: entryCol.implicitHeight + 12
radius: Appearance.rounding.small
color: Appearance.colors.colLayer2
border.width: 1
border.color: Appearance.colors.colLayer1Inactive
property bool expanded: false
ColumnLayout {
id: entryCol
anchors.fill: parent
anchors.margins: 6
spacing: 3
RowLayout {
Layout.fillWidth: true
spacing: 4
MaterialSymbol {
iconSize: Appearance.font.pixelSize.small
text: modelData.kind === "halt" ? "error"
: modelData.kind === "reflection" ? "lightbulb"
: modelData.kind === "archivist" ? "auto_stories"
: "psychology"
color: Appearance.colors.colSubtext
}
StyledText {
Layout.fillWidth: true
text: modelData.source ?? Translation.tr("Subconscious")
color: Appearance.colors.colOnLayer1
font.pixelSize: Appearance.font.pixelSize.small
font.bold: true
elide: Text.ElideRight
}
MaterialSymbol {
iconSize: Appearance.font.pixelSize.small
text: expanded ? "expand_less" : "expand_more"
color: Appearance.colors.colSubtext
}
}
StyledText {
Layout.fillWidth: true
visible: !expanded
text: (modelData.content ?? "").split("\n")[0]
color: Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.small
elide: Text.ElideRight
}
StyledText {
Layout.fillWidth: true
visible: expanded
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
text: modelData.content ?? ""
color: Appearance.colors.colOnLayer1
font.pixelSize: Appearance.font.pixelSize.small
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: parent.expanded = !parent.expanded
}
}
}
}
// Empty state
StyledText {
Layout.fillWidth: true
Layout.fillHeight: true
visible: Ai.subconsciousEvents.length === 0
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
text: Translation.tr("No subconscious events yet.\nThey appear here after a pass.")
color: Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.small
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
}
// Clear log
StyledText {
Layout.alignment: Qt.AlignRight
text: Translation.tr("Clear")
color: Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.small
visible: Ai.subconsciousEvents.length > 0
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: Ai.subconsciousEvents = []
}
}
}
}
}

View file

@ -66,6 +66,7 @@ RowLayout {
focus: GlobalStates.overviewOpen focus: GlobalStates.overviewOpen
font.pixelSize: Appearance.font.pixelSize.small font.pixelSize: Appearance.font.pixelSize.small
placeholderText: Translation.tr("Search, calculate or run") placeholderText: Translation.tr("Search, calculate or run")
inputMethodHints: Qt.ImhNoPredictiveText | Qt.ImhNoAutoUppercase
implicitWidth: root.searchingText == "" ? Appearance.sizes.searchWidthCollapsed : Appearance.sizes.searchWidth implicitWidth: root.searchingText == "" ? Appearance.sizes.searchWidthCollapsed : Appearance.sizes.searchWidth
Behavior on implicitWidth { Behavior on implicitWidth {
@ -78,7 +79,14 @@ RowLayout {
} }
} }
onTextChanged: LauncherSearch.query = text // Souveraine: drive the search from committed text PLUS live
// input-method composition. Stevia (sm.puri.OSK0) delivers typed
// letters as pre-edit that it may never commit, so `text` alone stays
// empty while the letters are visibly in the field hardware
// keyboards and IPC setQuery worked, OSK typing searched nothing
// (observed 2026-07-22: field showed letters, query stayed "").
onTextChanged: LauncherSearch.query = text + preeditText
onPreeditTextChanged: LauncherSearch.query = text + preeditText
onAccepted: { onAccepted: {
if (appResults.count > 0) { if (appResults.count > 0) {

View file

@ -1,7 +1,6 @@
pragma ComponentBehavior: Bound pragma ComponentBehavior: Bound
import Qt.labs.synchronizer import Qt.labs.synchronizer
import Qt5Compat.GraphicalEffects
import QtQuick import QtQuick
import QtQuick.Layouts import QtQuick.Layouts
import Quickshell import Quickshell
@ -47,6 +46,25 @@ Item { // Wrapper
LauncherSearch.query = text; LauncherSearch.query = text;
} }
function debugState() {
return {
showResults: root.showResults,
width: root.width,
height: root.height,
contentWidth: searchWidgetContent.width,
contentHeight: searchWidgetContent.height,
columnWidth: columnLayout.width,
columnHeight: columnLayout.height,
listVisible: appResults.visible,
listCount: appResults.count,
listWidth: appResults.width,
listHeight: appResults.height,
listImplicitHeight: appResults.implicitHeight,
listContentHeight: appResults.contentHeight,
modelCount: resultModel.count
};
}
Keys.onPressed: event => { Keys.onPressed: event => {
// Prevent Esc and Backspace from registering // Prevent Esc and Backspace from registering
if (event.key === Qt.Key_Escape) if (event.key === Qt.Key_Escape)
@ -146,16 +164,6 @@ Item { // Wrapper
} }
spacing: 0 spacing: 0
// clip: true
layer.enabled: true
layer.effect: OpacityMask {
maskSource: Rectangle {
width: searchWidgetContent.width
height: searchWidgetContent.width
radius: searchWidgetContent.radius
}
}
SearchBar { SearchBar {
id: searchBar id: searchBar
property real verticalPadding: 4 property real verticalPadding: 4

View file

@ -200,6 +200,46 @@ Rectangle {
text: Translation.tr("Regenerate") text: Translation.tr("Regenerate")
} }
} }
AiMessageControlButton {
id: speakButton
// stop icon only while THIS message is the active speaker
buttonIcon: (Speech.speaking && Ai.speakingMessageIndex === root.messageIndex)
? "stop_circle" : "volume_up"
visible: Speech.enabled && messageData?.role === 'assistant'
enabled: visible
onClicked: {
if (Speech.speaking && Ai.speakingMessageIndex === root.messageIndex) {
Speech.stop() // Ai.qml's Connections clears speakingMessageIndex
} else {
Ai.speakingMessageIndex = root.messageIndex
Speech.speak(StringUtils.ttsClean(root.messageData?.content ?? ""))
}
}
StyledToolTip {
text: Translation.tr("Speak")
}
}
AiMessageControlButton {
id: respeakButton
buttonIcon: "replay"
visible: Speech.enabled && messageData?.role === 'assistant'
enabled: visible
onClicked: {
// Re-synthesize fresh audio for this message's text
// when the previous synth came out broken. Does
// not re-run the agent.
Ai.speakingMessageIndex = root.messageIndex
Speech.stop()
Speech.speak(StringUtils.ttsClean(root.messageData?.content ?? ""))
}
StyledToolTip {
text: Translation.tr("Re-synthesize audio")
}
}
AiMessageControlButton { AiMessageControlButton {
id: copyButton id: copyButton

View file

@ -0,0 +1,82 @@
import qs
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions
import QtQuick
import QtQuick.Layouts
// Tier 1 of the subconscious three-tier visibility design (see
// docs/tasks/subconscious-surfacing-threshold.md): a transient, fading line
// that shows the subconscious's live reasoning/tools while its N+1 pass runs.
// Visible only while Ai.subconsciousActive; fed by Ai.subconsciousStream;
// cleared (and snapshotted to the Tier-2 log) when the pass ends.
Rectangle {
id: root
Layout.fillWidth: true
implicitHeight: row.implicitHeight + 2 * root.padding
radius: Appearance.rounding.small
color: Appearance.colors.colLayer2
visible: Ai.subconsciousActive && Ai.subconsciousStream.length > 0
opacity: visible ? 1 : 0
property real padding: 6
Behavior on opacity {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
Behavior on implicitHeight {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
// Fader: retire the oldest line a few seconds after it arrives so the
// ticker reads as a live stream, not an accumulating log. The full stream
// is snapshotted to Ai.subconsciousEvents on pass end regardless.
Timer {
interval: 4000
repeat: true
running: root.visible
onTriggered: {
if (Ai.subconsciousStream.length > 1)
Ai.subconsciousStream = Ai.subconsciousStream.slice(1);
}
}
RowLayout {
id: row
anchors.fill: parent
anchors.margins: root.padding
spacing: root.padding
MaterialSymbol {
Layout.alignment: Qt.AlignVCenter
iconSize: Appearance.font.pixelSize.larger
text: "psychology"
color: Appearance.colors.colSubtext
NumberAnimation on opacity {
// gentle pulse while the pass runs
from: 0.5; to: 1; duration: 1200
running: root.visible; loops: Animation.Infinite
easing.type: Easing.InOutSine
}
}
StyledText {
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
text: {
const s = Ai.subconsciousStream;
if (s.length === 0) return "";
const last = s[s.length - 1];
const tag = last.kind === "tool_call" || last.kind === "tool_result"
? Translation.tr("tool") : Translation.tr("thinking");
return `${tag} · ${last.text}`;
}
color: Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.small
elide: Text.ElideRight
maximumLineCount: 2
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
}
}
}

View file

@ -26,9 +26,13 @@ Singleton {
property string uptime: "0h, 0m" property string uptime: "0h, 0m"
Timer { Timer {
interval: 10 // Uptime advances one minute per minute; reload /proc/uptime once a
// minute, not 100x/second. (Was interval: 10 a 10ms busy-reload of a
// disk file, an always-on drain independent of the SystemClock above.)
interval: 60000
running: true running: true
repeat: true repeat: true
triggeredOnStart: true
onTriggered: { onTriggered: {
fileUptime.reload(); fileUptime.reload();
const textUptime = fileUptime.text(); const textUptime = fileUptime.text();

View file

@ -1,6 +1,7 @@
pragma Singleton pragma Singleton
pragma ComponentBehavior: Bound pragma ComponentBehavior: Bound
import qs
import QtQuick import QtQuick
import Quickshell import Quickshell
import Quickshell.Io import Quickshell.Io
@ -148,7 +149,10 @@ Singleton {
Timer { Timer {
interval: 1000 interval: 1000
running: true // Souveraine: pause while the display is inactive (idle-power task 4).
// The rate math is elapsed-time based, so the first tick after resume
// yields one correctly averaged sample rather than a spike.
running: GlobalStates.displayActive
repeat: true repeat: true
triggeredOnStart: true triggeredOnStart: true
onTriggered: root.update() onTriggered: root.update()

View file

@ -1,6 +1,7 @@
pragma Singleton pragma Singleton
pragma ComponentBehavior: Bound pragma ComponentBehavior: Bound
import qs
import qs.modules.common import qs.modules.common
import QtQuick import QtQuick
import Quickshell import Quickshell
@ -61,7 +62,9 @@ Singleton {
Timer { Timer {
interval: 1 interval: 1
running: true // Souveraine: pause while the display is dimmed/locked/asleep
// stats history nobody can see is pure CPU burn (idle-power task 4).
running: GlobalStates.displayActive
repeat: true repeat: true
onTriggered: { onTriggered: {
// Reload files // Reload files

View file

@ -77,6 +77,44 @@ Singleton {
// the lock surface; do not treat it as proof that the session is secure. // the lock surface; do not treat it as proof that the session is secure.
readonly property bool locked: GlobalStates.screenLockSecure readonly property bool locked: GlobalStates.screenLockSecure
// Report the SECURE lock state to logind as the session's LockedHint
// the freedesktop contract's state half (loginctl lock-session above is
// only the request half). This makes `LockedHint` truthful device-wide,
// so logind-aware apps (culver stops its Matrix sync, media players
// pause, recorders blank) can watch one standard property instead of
// each growing a bespoke shell IPC. Reporting the secure edge not the
// requested edge means the hint never claims locked before the
// compositor holds the lock. set-locked-hint is not a Lock signal, so
// the hypridle echo storm documented in lock() cannot recur through it.
onLockedChanged: {
if (root.hasLoginctl)
lockedHintProc.report(root.locked);
}
Process {
id: lockedHintProc
property bool pending: false
property bool pendingValue: false
function report(value) {
if (running) {
// Coalesce: remember the latest value, replay on exit.
pending = true;
pendingValue = value;
return;
}
command = ["busctl", "call", "org.freedesktop.login1",
"/org/freedesktop/login1/session/auto",
"org.freedesktop.login1.Session",
"SetLockedHint", "b", value ? "true" : "false"];
running = true;
}
onExited: {
if (pending) {
pending = false;
report(pendingValue);
}
}
}
signal actionFailed(string action, int exitCode) signal actionFailed(string action, int exitCode)
Process { Process {
@ -424,6 +462,27 @@ Singleton {
Quickshell.execDetached(["bash", "-c", `${Config.options.apps.taskManager}`]); Quickshell.execDetached(["bash", "-c", `${Config.options.apps.taskManager}`]);
} }
// Local maintenance clients (notably deploy.sh) need the same truthful
// session projection the shell uses internally. The implementation above
// existed before the IPC seam did; without this handler, callers could
// only guess whether the secure lock was active and a live QML deploy
// could race auto-lock into a session-lock remap crash.
IpcHandler {
target: "session"
function state(): string {
return JSON.stringify(root.state());
}
function inhibit(what: string, reason: string): string {
return JSON.stringify(root.inhibit(what, reason));
}
function uninhibit(cookie: string): string {
return JSON.stringify(root.uninhibit(cookie));
}
}
// --- Boot-time IPC audit ------------------------------------------------ // --- Boot-time IPC audit ------------------------------------------------
// Logs every IPC endpoint the shell exposes at startup. This is a // Logs every IPC endpoint the shell exposes at startup. This is a
// defensive visibility measure not a gate, not a refusal. It answers // defensive visibility measure not a gate, not a refusal. It answers

View file

@ -1,7 +1,9 @@
singleton BitwiseFuzzy 1.0 BitwiseFuzzy.qml
singleton ColorUtils 1.0 ColorUtils.qml singleton ColorUtils 1.0 ColorUtils.qml
singleton DateUtils 1.0 DateUtils.qml singleton DateUtils 1.0 DateUtils.qml
singleton FileUtils 1.0 FileUtils.qml singleton FileUtils 1.0 FileUtils.qml
singleton Fuzzy 1.0 Fuzzy.qml singleton Fuzzy 1.0 Fuzzy.qml
singleton KeymapTranslation 1.0 KeymapTranslation.qml
singleton Levendist 1.0 Levendist.qml singleton Levendist 1.0 Levendist.qml
singleton NotificationUtils 1.0 NotificationUtils.qml singleton NotificationUtils 1.0 NotificationUtils.qml
singleton ObjectUtils 1.0 ObjectUtils.qml singleton ObjectUtils 1.0 ObjectUtils.qml

View file

@ -5,12 +5,14 @@ BarGroup 1.0 BarGroup.qml
BatteryIndicator 1.0 BatteryIndicator.qml BatteryIndicator 1.0 BatteryIndicator.qml
BatteryPopup 1.0 BatteryPopup.qml BatteryPopup 1.0 BatteryPopup.qml
CircleUtilButton 1.0 CircleUtilButton.qml CircleUtilButton 1.0 CircleUtilButton.qml
ClaudeUsageBar 1.0 ClaudeUsageBar.qml
ClockWidget 1.0 ClockWidget.qml ClockWidget 1.0 ClockWidget.qml
ClockWidgetPopup 1.0 ClockWidgetPopup.qml ClockWidgetPopup 1.0 ClockWidgetPopup.qml
HyprlandXkbIndicator 1.0 HyprlandXkbIndicator.qml HyprlandXkbIndicator 1.0 HyprlandXkbIndicator.qml
LeftSidebarButton 1.0 LeftSidebarButton.qml LeftSidebarButton 1.0 LeftSidebarButton.qml
Media 1.0 Media.qml Media 1.0 Media.qml
NotificationUnreadCount 1.0 NotificationUnreadCount.qml NotificationUnreadCount 1.0 NotificationUnreadCount.qml
PomodoroBarIndicator 1.0 PomodoroBarIndicator.qml
Resource 1.0 Resource.qml Resource 1.0 Resource.qml
Resources 1.0 Resources.qml Resources 1.0 Resources.qml
ResourcesPopup 1.0 ResourcesPopup.qml ResourcesPopup 1.0 ResourcesPopup.qml

View file

@ -203,6 +203,23 @@ Scope {
function open() { function open() {
GlobalStates.overviewOpen = true; GlobalStates.overviewOpen = true;
} }
// Device-side acceptance/debug surface: lets deploy checks exercise
// the exact query path the text field uses without synthesizing keys.
function setQuery(query: string): void {
overviewScope.dontAutoCancelSearch = true;
GlobalStates.overviewOpen = true;
panelWindow.setSearchingText(query);
}
function status(): string {
return JSON.stringify({
open: GlobalStates.overviewOpen,
query: LauncherSearch.query,
results: LauncherSearch.results.length,
desktopEntries: DesktopEntries.applications.values.length,
appSearchEntries: AppSearch.list.length,
widget: searchWidget.debugState()
});
}
function toggleReleaseInterrupt() { function toggleReleaseInterrupt() {
GlobalStates.superReleaseMightTrigger = false; GlobalStates.superReleaseMightTrigger = false;
} }

View file

@ -211,15 +211,15 @@ Inline w/ backslash and round brackets \\(e^{i\\pi} + 1 = 0\\)
}, },
] ]
// Latest visible assistant reply what the Speak button reads aloud. // Latest visible assistant reply what gets read aloud. Skips
// Skips lock-redacted content (LockContentPolicy swaps it for a marker). // lock-redacted content (LockContentPolicy swaps it for a marker).
function lastAssistantText() { function lastAssistantText() {
for (let i = Ai.messageIDs.length - 1; i >= 0; i--) { for (let i = Ai.messageIDs.length - 1; i >= 0; i--) {
const m = Ai.messageByID[Ai.messageIDs[i]]; const m = Ai.messageByID[Ai.messageIDs[i]];
if (m && m.role === "assistant" && (m.visibleToUser ?? true) if (m && m.role === "assistant" && (m.visibleToUser ?? true)
&& m.content && m.content.length > 0 && m.content && m.content.length > 0
&& !m.content.startsWith("[content hidden")) { && !m.content.startsWith("[content hidden")) {
return m.content; return StringUtils.ttsClean(m.content);
} }
} }
return ""; return "";
@ -497,6 +497,8 @@ Inline w/ backslash and round brackets \\(e^{i\\pi} + 1 = 0\\)
} }
} }
SubconsciousTicker {}
Rectangle { // Input area Rectangle { // Input area
id: inputWrapper id: inputWrapper
property real spacing: 5 property real spacing: 5
@ -722,34 +724,6 @@ Inline w/ backslash and round brackets \\(e^{i\\pi} + 1 = 0\\)
} }
} }
} }
RippleButton { // Speak button read the last reply aloud
id: speakButton
visible: Speech.enabled
Layout.alignment: Qt.AlignBottom
Layout.rightMargin: 3
implicitWidth: 40
implicitHeight: 40
buttonRadius: Appearance.rounding.small
enabled: Speech.speaking || root.lastAssistantText().length > 0
toggled: Speech.speaking
MouseArea {
anchors.fill: parent
cursorShape: speakButton.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: {
if (Speech.speaking) Speech.stop();
else Speech.speak(root.lastAssistantText());
}
}
contentItem: MaterialSymbol {
anchors.centerIn: parent
horizontalAlignment: Text.AlignHCenter
iconSize: 22
color: Speech.speaking ? Appearance.m3colors.m3onPrimary : (speakButton.enabled ? Appearance.colors.colOnLayer2 : Appearance.colors.colOnLayer2Disabled)
text: Speech.speaking ? "stop_circle" : "volume_up"
}
}
RippleButton { // Send button RippleButton { // Send button
id: sendButton id: sendButton
Layout.alignment: Qt.AlignBottom Layout.alignment: Qt.AlignBottom

View file

@ -0,0 +1,329 @@
import qs
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Bluetooth
import Quickshell.Hyprland
import qs.modules.ii.sidebarRight.quickToggles
import qs.modules.ii.sidebarRight.quickToggles.classicStyle
import qs.modules.ii.sidebarRight.bluetoothDevices
import qs.modules.ii.sidebarRight.nightLight
import qs.modules.ii.sidebarRight.volumeMixer
import qs.modules.ii.sidebarRight.wifiNetworks
Item {
id: root
property int sidebarWidth: Appearance.sizes.sidebarWidth
property int sidebarPadding: 10
property bool showAudioOutputDialog: false
property bool showAudioInputDialog: false
property bool showBluetoothDialog: false
property bool showNightLightDialog: false
property bool showWifiDialog: false
property bool editMode: false
Connections {
target: GlobalStates
function onSidebarRightOpenChanged() {
if (!GlobalStates.sidebarRightOpen) {
root.showWifiDialog = false;
root.showBluetoothDialog = false;
root.showAudioOutputDialog = false;
root.showAudioInputDialog = false;
}
}
}
implicitHeight: sidebarRightBackground.implicitHeight
implicitWidth: sidebarRightBackground.implicitWidth
StyledRectangularShadow {
target: sidebarRightBackground
}
Rectangle {
id: sidebarRightBackground
anchors.fill: parent
implicitHeight: parent.height - Appearance.sizes.hyprlandGapsOut * 2
implicitWidth: sidebarWidth - Appearance.sizes.hyprlandGapsOut * 2
color: Appearance.colors.colLayer0
border.width: 1
border.color: Appearance.colors.colLayer0Border
radius: Appearance.rounding.screenRounding - Appearance.sizes.hyprlandGapsOut + 1
ColumnLayout {
anchors.fill: parent
anchors.margins: sidebarPadding
spacing: sidebarPadding
SystemButtonRow {
Layout.fillHeight: false
Layout.fillWidth: true
// Layout.margins: 10
Layout.topMargin: 5
Layout.bottomMargin: 0
}
Loader {
id: slidersLoader
Layout.fillWidth: true
visible: active
active: {
const configQuickSliders = Config.options.sidebar.quickSliders
if (!configQuickSliders.enable) return false
if (!configQuickSliders.showMic && !configQuickSliders.showVolume && !configQuickSliders.showBrightness) return false;
return true;
}
sourceComponent: QuickSliders {}
}
LoaderedQuickPanelImplementation {
styleName: "classic"
sourceComponent: ClassicQuickPanel {}
}
LoaderedQuickPanelImplementation {
styleName: "android"
sourceComponent: AndroidQuickPanel {
editMode: root.editMode
}
}
Item {
Layout.fillHeight: true
Layout.fillWidth: true
clip: true
CenterWidgetGroup {
id: centerGroup
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: bottomGroup.top
anchors.bottomMargin: root.sidebarPadding
opacity: bottomGroup.collapsed ? 1 : 0
visible: opacity > 0
Behavior on opacity {
NumberAnimation {
duration: Appearance.animation.elementMove.duration
easing.type: Appearance.animation.elementMove.type
easing.bezierCurve: Appearance.animation.elementMove.bezierCurve
}
}
}
BottomWidgetGroup {
id: bottomGroup
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: bottomGroup.collapsed ? bottomGroup.collapsedHeight : parent.height
Behavior on height {
NumberAnimation {
duration: Appearance.animation.elementMove.duration
easing.type: Appearance.animation.elementMove.type
easing.bezierCurve: Appearance.animation.elementMove.bezierCurve
}
}
}
}
}
}
ToggleDialog {
shownPropertyString: "showAudioOutputDialog"
dialog: VolumeDialog {
isSink: true
}
}
ToggleDialog {
shownPropertyString: "showAudioInputDialog"
dialog: VolumeDialog {
isSink: false
}
}
ToggleDialog {
shownPropertyString: "showBluetoothDialog"
dialog: BluetoothDialog {}
onShownChanged: {
if (!shown) {
Bluetooth.defaultAdapter.discovering = false;
} else {
Bluetooth.defaultAdapter.enabled = true;
Bluetooth.defaultAdapter.discovering = true;
}
}
}
ToggleDialog {
shownPropertyString: "showNightLightDialog"
dialog: NightLightDialog {}
}
ToggleDialog {
shownPropertyString: "showWifiDialog"
dialog: WifiDialog {}
onShownChanged: {
if (!shown) return;
Network.enableWifi();
Network.rescanWifi();
}
}
component ToggleDialog: Loader {
id: toggleDialogLoader
required property string shownPropertyString
property alias dialog: toggleDialogLoader.sourceComponent
readonly property bool shown: root[shownPropertyString]
anchors.fill: parent
onShownChanged: if (shown) toggleDialogLoader.active = true;
active: shown
onActiveChanged: {
if (active) {
item.show = true;
item.forceActiveFocus();
}
}
Connections {
target: toggleDialogLoader.item
function onDismiss() {
toggleDialogLoader.item.show = false
root[toggleDialogLoader.shownPropertyString] = false;
}
function onVisibleChanged() {
if (!toggleDialogLoader.item.visible && !root[toggleDialogLoader.shownPropertyString]) toggleDialogLoader.active = false;
}
}
}
component LoaderedQuickPanelImplementation: Loader {
id: quickPanelImplLoader
required property string styleName
Layout.alignment: item ? item.Layout.alignment : Qt.AlignHCenter
Layout.fillWidth: item ? item.Layout.fillWidth : false
visible: active
active: Config.options.sidebar.quickToggles.style === styleName
Connections {
target: quickPanelImplLoader.item
function onOpenAudioOutputDialog() {
root.showAudioOutputDialog = true;
}
function onOpenAudioInputDialog() {
root.showAudioInputDialog = true;
}
function onOpenBluetoothDialog() {
root.showBluetoothDialog = true;
}
function onOpenNightLightDialog() {
root.showNightLightDialog = true;
}
function onOpenWifiDialog() {
root.showWifiDialog = true;
}
}
}
component SystemButtonRow: Item {
implicitHeight: Math.max(uptimeContainer.implicitHeight, systemButtonsRow.implicitHeight)
Rectangle {
id: uptimeContainer
anchors {
top: parent.top
bottom: parent.bottom
left: parent.left
}
color: Appearance.colors.colLayer1
radius: height / 2
implicitWidth: uptimeRow.implicitWidth + 24
implicitHeight: uptimeRow.implicitHeight + 8
Row {
id: uptimeRow
anchors.centerIn: parent
spacing: 8
CustomIcon {
id: distroIcon
anchors.verticalCenter: parent.verticalCenter
width: 25
height: 25
source: SystemInfo.distroIcon
colorize: true
color: Appearance.colors.colOnLayer0
}
StyledText {
anchors.verticalCenter: parent.verticalCenter
font.pixelSize: Appearance.font.pixelSize.normal
color: Appearance.colors.colOnLayer0
text: Translation.tr("Up %1").arg(DateTime.uptime)
textFormat: Text.MarkdownText
}
}
}
ButtonGroup {
id: systemButtonsRow
anchors {
top: parent.top
bottom: parent.bottom
right: parent.right
}
color: Appearance.colors.colLayer1
padding: 4
QuickToggleButton {
toggled: root.editMode
visible: Config.options.sidebar.quickToggles.style === "android"
buttonIcon: "edit"
onClicked: root.editMode = !root.editMode
StyledToolTip {
text: Translation.tr("Edit quick toggles") + (root.editMode ? Translation.tr("\nLMB to enable/disable\nRMB to toggle size\nScroll to swap position") : "")
}
}
QuickToggleButton {
toggled: false
buttonIcon: "restart_alt"
onClicked: {
Quickshell.execDetached(["hyprctl", "reload"])
Quickshell.reload(true);
}
StyledToolTip {
text: Translation.tr("Reload Hyprland & Quickshell")
}
}
QuickToggleButton {
toggled: false
buttonIcon: "settings"
onClicked: {
GlobalStates.sidebarRightOpen = false;
Quickshell.execDetached(["qs", "-p", root.settingsQmlPath]);
}
StyledToolTip {
text: Translation.tr("Settings")
}
}
QuickToggleButton {
toggled: false
buttonIcon: "power_settings_new"
onClicked: {
GlobalStates.sessionOpen = true;
}
StyledToolTip {
text: Translation.tr("Session")
}
}
}
}
}

View file

@ -0,0 +1,71 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import qs.services
import qs.modules.common
import qs.modules.common.widgets
ContentPage {
id: page
forceWidth: true
readonly property var monitor: Brightness.monitors.length > 0
? Brightness.monitors[0] : null
ContentSection {
icon: "brightness_6"
title: Translation.tr("Brightness")
RowLayout {
Layout.fillWidth: true
spacing: 12
MaterialSymbol {
text: "brightness_low"
iconSize: 22
}
StyledSlider {
Layout.fillWidth: true
enabled: page.monitor ? page.monitor.ready : false
value: page.monitor ? page.monitor.brightness : 0
from: 0.01
to: 1
onMoved: {
if (page.monitor) page.monitor.setBrightness(value);
}
}
StyledText {
text: page.monitor && page.monitor.ready
? `${Math.round(page.monitor.brightness * 100)}%` : "—"
font.family: Appearance.font.family.numbers
}
}
ConfigSwitch {
buttonIcon: "flash_off"
text: Translation.tr("Anti-flashbang dimming")
checked: Config.options.light.antiFlashbang.enable
onCheckedChanged: Config.options.light.antiFlashbang.enable = checked
StyledToolTip {
text: Translation.tr("Temporarily softens large brightness jumps when content changes.")
}
}
}
ContentSection {
icon: "monitor"
title: Translation.tr("Built-in display")
StyledText {
Layout.fillWidth: true
text: Quickshell.screens.length > 0
? Translation.tr("%1 · %2 × %3 logical pixels")
.arg(Quickshell.screens[0].name)
.arg(Quickshell.screens[0].width)
.arg(Quickshell.screens[0].height)
: Translation.tr("No display reported")
color: Appearance.colors.colSubtext
wrapMode: Text.WordWrap
}
}
}

View file

@ -1,6 +1,5 @@
import QtQuick import QtQuick
import QtQuick.Layouts import QtQuick.Layouts
import Quickshell.Io
import qs.services import qs.services
import qs.modules.common import qs.modules.common
import qs.modules.common.widgets import qs.modules.common.widgets
@ -20,23 +19,23 @@ import qs.modules.common.widgets
// screen-off, and the only transitions that fire are lock-request and // screen-off, and the only transitions that fire are lock-request and
// lock-secure. The page says so instead of implying the timers are live. // lock-secure. The page says so instead of implying the timers are live.
// //
// 2. This settings app is a SEPARATE process from the shell, so it cannot // 2. Settings is a window in the authoritative shell process, so this page
// read the IdleCoordinator singleton directly. It reads the live stage // reads the idle/session singletons directly. It must not spawn `qs ipc`
// over the same `session` IPC the arbiter already exposes // children, which can become accidental shell instances when display
// (session.state().idle.stage), on a light poll. // selection is ambiguous.
ContentPage { ContentPage {
id: page id: page
forceWidth: true forceWidth: true
// --- Live stage readout (polled over IPC) ---------------------------- // --- Live stage readout ----------------------------------------------
// 0 Active · 1 Dimmed · 2 Lock requested · 3 Lock secure · // 0 Active · 1 Dimmed · 2 Lock requested · 3 Lock secure ·
// 4 Suspending · 5 Asleep · 6 Waking the IdleCoordinator.State // 4 Suspending · 5 Asleep · 6 Waking the IdleCoordinator.State
// enum, surfaced through session.state(). // enum, read directly from the shell-owned coordinator.
property int liveStage: -1 readonly property int liveStage: IdleCoordinator.state
property bool liveNative: false readonly property bool liveNative: IdleCoordinator.nativeEnabled
property bool probeOk: false readonly property bool probeOk: true
property bool sleepInhibitorHeld: false readonly property bool sleepInhibitorHeld: SessionEvents.sleepInhibitorHeld
property bool stepUpEnabled: false readonly property bool stepUpEnabled: StepUpAuth.grantTtlMs > 0
readonly property var stageNames: [ readonly property var stageNames: [
Translation.tr("Active"), Translation.tr("Active"),
@ -51,36 +50,6 @@ ContentPage {
return (s >= 0 && s < stageNames.length) ? stageNames[s] : Translation.tr("unknown"); return (s >= 0 && s < stageNames.length) ? stageNames[s] : Translation.tr("unknown");
} }
Process {
id: stateProbe
command: ["qs", "-c", "souveraine", "ipc", "call", "session", "state"]
stdout: StdioCollector {
id: stateOut
onStreamFinished: {
try {
const d = JSON.parse(stateOut.text);
page.liveStage = d.idle?.stage ?? -1;
page.liveNative = d.idle?.nativeCoordinatorEnabled ?? false;
page.sleepInhibitorHeld = d.sleepInhibitorHeld ?? false;
page.stepUpEnabled = d.stepUp?.grantTtlMs > 0 ?? false;
page.probeOk = true;
} catch (e) {
page.probeOk = false;
}
}
}
}
Timer {
// Light poll: the stage only changes on idle/lock transitions, so a
// 2s cadence is plenty and cheap. Runs only while this page is shown.
interval: 2000
running: page.visible
repeat: true
triggeredOnStart: true
onTriggered: stateProbe.running = true
}
ContentSection { ContentSection {
icon: "motion_sensor_active" icon: "motion_sensor_active"
title: Translation.tr("Current state") title: Translation.tr("Current state")
@ -122,7 +91,6 @@ ContentPage {
} }
} }
} }
}
ContentSection { ContentSection {
icon: "experiment" icon: "experiment"

View file

@ -0,0 +1,199 @@
import QtQuick
import QtQuick.Layouts
import Quickshell.Bluetooth
import qs.services
import qs.modules.common
import qs.modules.common.widgets
// First-party connectivity page. The controls are direct views over the
// resident Network/Cellular/Bluetooth services; no settings-only state is
// allowed to pretend a radio changed when its owning service did not.
ContentPage {
id: page
forceWidth: true
function wifiIcon(strength) {
return strength > 80 ? "signal_wifi_4_bar"
: strength > 60 ? "network_wifi_3_bar"
: strength > 40 ? "network_wifi_2_bar"
: strength > 20 ? "network_wifi_1_bar"
: "signal_wifi_0_bar";
}
ContentSection {
icon: "wifi"
title: Translation.tr("Wi-Fi")
ConfigSwitch {
buttonIcon: Network.materialSymbol
text: Network.wifiEnabled
? Translation.tr("Wi-Fi on") : Translation.tr("Wi-Fi off")
checked: Network.wifiEnabled
onCheckedChanged: {
if (checked !== Network.wifiEnabled) Network.enableWifi(checked);
}
}
RowLayout {
Layout.fillWidth: true
spacing: 10
StyledText {
Layout.fillWidth: true
text: Network.active
? Translation.tr("Connected to %1").arg(Network.active.ssid)
: Translation.tr("Not connected")
color: Appearance.colors.colSubtext
elide: Text.ElideRight
}
RippleButton {
implicitWidth: 44
implicitHeight: 44
enabled: Network.wifiEnabled && !Network.wifiScanning
buttonRadius: Appearance.rounding.full
onClicked: Network.rescanWifi()
contentItem: MaterialSymbol {
anchors.centerIn: parent
text: Network.wifiScanning ? "progress_activity" : "refresh"
iconSize: 21
}
StyledToolTip { text: Translation.tr("Scan for networks") }
}
}
Repeater {
model: Network.wifiEnabled ? Network.friendlyWifiNetworks : []
delegate: DialogListItem {
id: networkRow
required property var modelData
Layout.fillWidth: true
active: modelData.active
buttonRadius: Appearance.rounding.normal
onClicked: {
if (modelData.active) Network.disconnectWifiNetwork();
else Network.connectToWifiNetwork(modelData);
}
contentItem: ColumnLayout {
anchors {
fill: parent
leftMargin: networkRow.horizontalPadding
rightMargin: networkRow.horizontalPadding
topMargin: networkRow.verticalPadding
bottomMargin: networkRow.verticalPadding
}
spacing: 8
RowLayout {
Layout.fillWidth: true
spacing: 10
MaterialSymbol {
text: page.wifiIcon(networkRow.modelData.strength)
iconSize: 22
}
StyledText {
Layout.fillWidth: true
text: networkRow.modelData.ssid
textFormat: Text.PlainText
elide: Text.ElideRight
}
MaterialSymbol {
text: networkRow.modelData.active ? "check"
: networkRow.modelData.isSecure ? "lock" : ""
iconSize: 20
}
}
MaterialTextField {
Layout.fillWidth: true
visible: networkRow.modelData.askingPassword
placeholderText: Translation.tr("Network password")
echoMode: TextInput.Password
inputMethodHints: Qt.ImhSensitiveData
onAccepted: {
Network.changePassword(networkRow.modelData, text);
text = "";
}
}
}
}
}
}
ContentSection {
icon: Cellular.materialSymbol
title: Translation.tr("Mobile network")
RowLayout {
Layout.fillWidth: true
spacing: 12
ColumnLayout {
Layout.fillWidth: true
spacing: 2
StyledText {
Layout.fillWidth: true
text: Cellular.available
? (Cellular.operatorName || Translation.tr("Mobile network"))
: Translation.tr("No modem detected")
elide: Text.ElideRight
}
StyledText {
Layout.fillWidth: true
text: Cellular.available
? Translation.tr("%1 · %2% signal%3")
.arg(Cellular.accessTech || Cellular.state)
.arg(Cellular.signalQuality)
.arg(Cellular.roaming ? Translation.tr(" · roaming") : "")
: Translation.tr("ModemManager has not exposed a modem")
color: Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.smaller
wrapMode: Text.WordWrap
}
}
RippleButton {
implicitWidth: 44
implicitHeight: 44
buttonRadius: Appearance.rounding.full
onClicked: Cellular.update()
contentItem: MaterialSymbol {
anchors.centerIn: parent
text: "refresh"
iconSize: 21
}
}
}
}
ContentSection {
icon: "bluetooth"
title: Translation.tr("Bluetooth")
ConfigSwitch {
buttonIcon: BluetoothStatus.connected ? "bluetooth_connected" : "bluetooth"
text: BluetoothStatus.available
? Translation.tr("Bluetooth") : Translation.tr("Bluetooth unavailable")
enabled: BluetoothStatus.available
checked: BluetoothStatus.enabled
onCheckedChanged: {
if (Bluetooth.defaultAdapter && checked !== Bluetooth.defaultAdapter.enabled)
Bluetooth.defaultAdapter.enabled = checked;
}
}
StyledText {
Layout.fillWidth: true
text: !BluetoothStatus.available
? Translation.tr("BlueZ has not exposed an adapter; no success-shaped toggle is shown.")
: BluetoothStatus.activeDeviceCount > 0
? Translation.tr("%1 connected device(s)").arg(BluetoothStatus.activeDeviceCount)
: Translation.tr("No connected devices")
color: Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.smaller
wrapMode: Text.WordWrap
}
}
}

View file

@ -0,0 +1,93 @@
import QtQuick
import QtQuick.Layouts
import qs.services
import qs.modules.common
import qs.modules.common.widgets
ContentPage {
forceWidth: true
ContentSection {
icon: "volume_up"
title: Translation.tr("Output")
StyledText {
Layout.fillWidth: true
text: Audio.sink ? Audio.friendlyDeviceName(Audio.sink)
: Translation.tr("No audio output")
color: Appearance.colors.colSubtext
elide: Text.ElideRight
}
RowLayout {
Layout.fillWidth: true
spacing: 12
RippleButton {
implicitWidth: 44
implicitHeight: 44
enabled: Audio.sink ? Audio.sink.ready : false
buttonRadius: Appearance.rounding.full
onClicked: Audio.toggleMute()
contentItem: MaterialSymbol {
anchors.centerIn: parent
text: Audio.sink && Audio.sink.audio.muted ? "volume_off" : "volume_up"
iconSize: 22
}
}
StyledSlider {
Layout.fillWidth: true
enabled: Audio.sink ? Audio.sink.ready : false
value: Audio.sink ? Audio.sink.audio.volume : 0
from: 0
to: 1
onMoved: {
if (Audio.sink) Audio.sink.audio.volume = value;
}
}
}
}
ContentSection {
icon: "mic"
title: Translation.tr("Microphone")
StyledText {
Layout.fillWidth: true
text: Audio.source ? Audio.friendlyDeviceName(Audio.source)
: Translation.tr("No microphone")
color: Appearance.colors.colSubtext
elide: Text.ElideRight
}
RowLayout {
Layout.fillWidth: true
spacing: 12
RippleButton {
implicitWidth: 44
implicitHeight: 44
enabled: Audio.source ? Audio.source.ready : false
buttonRadius: Appearance.rounding.full
onClicked: Audio.toggleMicMute()
contentItem: MaterialSymbol {
anchors.centerIn: parent
text: Audio.source && Audio.source.audio.muted ? "mic_off" : "mic"
iconSize: 22
}
StyledToolTip {
text: Audio.source && Audio.source.audio.muted
? Translation.tr("Unmute microphone") : Translation.tr("Mute microphone")
}
}
StyledSlider {
Layout.fillWidth: true
enabled: Audio.source ? Audio.source.ready : false
value: Audio.source ? Audio.source.audio.volume : 0
from: 0
to: 1
onMoved: {
if (Audio.source) Audio.source.audio.volume = value;
}
}
}
}
}

View file

@ -7,9 +7,9 @@ import qs.modules.common
import qs.modules.common.widgets import qs.modules.common.widgets
// Wallpaper pick from ~/Pictures/Wallpapers inside the settings app. // Wallpaper pick from ~/Pictures/Wallpapers inside the settings app.
// Applying goes through the main shell's `wallpapers apply` IPC so the // Settings is a window in the main shell process, so applying calls the
// selection, config persistence, and matugen theming all run in the shell // shell-owned Wallpapers singleton directly. Never spawn a nested `qs ipc`
// process that owns them; this page is only a view. // process from this page.
ContentPage { ContentPage {
forceWidth: true forceWidth: true
@ -258,8 +258,7 @@ ContentPage {
currentDir = filePath; currentDir = filePath;
} else { } else {
// Shell process owns selection + theming. // Shell process owns selection + theming.
Quickshell.execDetached(["qs", "-c", "souveraine", Wallpapers.apply(filePath);
"ipc", "call", "wallpapers", "apply", filePath]);
Config.options.background.wallpaperPath = filePath; Config.options.background.wallpaperPath = filePath;
} }
} }

View file

@ -3,6 +3,7 @@ AdvancedConfig 1.0 AdvancedConfig.qml
BackgroundConfig 1.0 BackgroundConfig.qml BackgroundConfig 1.0 BackgroundConfig.qml
BarConfig 1.0 BarConfig.qml BarConfig 1.0 BarConfig.qml
DeviceConfig 1.0 DeviceConfig.qml DeviceConfig 1.0 DeviceConfig.qml
DisplayConfig 1.0 DisplayConfig.qml
DockConfig 1.0 DockConfig.qml DockConfig 1.0 DockConfig.qml
GeneralConfig 1.0 GeneralConfig.qml GeneralConfig 1.0 GeneralConfig.qml
IdleConfig 1.0 IdleConfig.qml IdleConfig 1.0 IdleConfig.qml
@ -10,7 +11,10 @@ InterfaceConfig 1.0 InterfaceConfig.qml
KeyboardConfig 1.0 KeyboardConfig.qml KeyboardConfig 1.0 KeyboardConfig.qml
LockConfig 1.0 LockConfig.qml LockConfig 1.0 LockConfig.qml
NavigationConfig 1.0 NavigationConfig.qml NavigationConfig 1.0 NavigationConfig.qml
NetworkConfig 1.0 NetworkConfig.qml
QuickConfig 1.0 QuickConfig.qml QuickConfig 1.0 QuickConfig.qml
ServicesConfig 1.0 ServicesConfig.qml ServicesConfig 1.0 ServicesConfig.qml
SettingsHome 1.0 SettingsHome.qml
SpeechConfig 1.0 SpeechConfig.qml SpeechConfig 1.0 SpeechConfig.qml
SoundConfig 1.0 SoundConfig.qml
WallpaperConfig 1.0 WallpaperConfig.qml WallpaperConfig 1.0 WallpaperConfig.qml

View file

@ -63,6 +63,25 @@ Scope {
// Pokes the C splash to release DRM master. Fires once, on first frame. // Pokes the C splash to release DRM master. Fires once, on first frame.
Process { id: splashSignalProc } Process { id: splashSignalProc }
// Watchdog: the overlay is normally dismissed by LockScreen.onSecureChanged.
// That event only ever arrives where something locks the session at boot
// on SouveraineOS, sessiond. Running this shell on a plain distro (the
// laptop still boots EndeavourOS), nothing locks, secure never fires, and
// the overlay covers the whole screen forever with no input path out. Boot
// is over long before this fires; if the lock hasn't taken by now it isn't
// coming, so reveal the desktop rather than trapping the session.
Timer {
interval: 15000
repeat: false
running: root.active
onTriggered: {
if (!GlobalStates.screenLockSecure) {
console.log("[bootbloom] no lock surface after 15s — dismissing overlay");
GlobalStates.bootBloomActive = false;
}
}
}
// Wall-clock reference captured when the /proc/uptime read completed, so // Wall-clock reference captured when the /proc/uptime read completed, so
// elapsed = frameClock.currentTime-based delta. We drive u_time off an // elapsed = frameClock.currentTime-based delta. We drive u_time off an
// Animator-free FrameAnimation for a monotonic per-frame tick. // Animator-free FrameAnimation for a monotonic per-frame tick.

View file

@ -206,10 +206,24 @@ PanelWindow {
`hl.dsp.window.fullscreen({ window = "address:${address}", mode = "fullscreen", action = "toggle" })`]) `hl.dsp.window.fullscreen({ window = "address:${address}", mode = "fullscreen", action = "toggle" })`])
} }
// A single tap is Home. Delay it by the double-tap window so the
// existing fullscreen gesture remains unambiguous; the second tap
// cancels this timer before toggling fullscreen.
function goHome(): void {
lastTapAt = -1
GlobalStates.missionControlOpen = false
GlobalStates.overviewOpen = false
GlobalStates.dockRevealed = false
GlobalStates.oskOpen = false
Quickshell.execDetached(["hyprctl", "dispatch",
"hl.dsp.focus({ workspace = 1 })"])
}
// Commit an upward gesture. `stage` is the stage the drag settled on // Commit an upward gesture. `stage` is the stage the drag settled on
// (0/1/2). Higher stages imply the lower one, so Mission Control also // (0/1/2). Higher stages imply the lower one, so Mission Control also
// reveals the dock beneath it. // reveals the dock beneath it.
function commitUp(stage: int): void { function commitUp(stage: int): void {
singleTapTimer.stop()
lastTapAt = -1 lastTapAt = -1
if (stage >= 1) { if (stage >= 1) {
GlobalStates.dockSuppressed = false GlobalStates.dockSuppressed = false
@ -260,6 +274,7 @@ PanelWindow {
// Downward: peel the nearest surface (keyboard, then dock). // Downward: peel the nearest surface (keyboard, then dock).
if (deltaY >= rail.revealAt) { if (deltaY >= rail.revealAt) {
singleTapTimer.stop()
lastTapAt = -1 lastTapAt = -1
if (GlobalStates.oskOpen) { if (GlobalStates.oskOpen) {
GlobalStates.oskOpen = false GlobalStates.oskOpen = false
@ -277,24 +292,34 @@ PanelWindow {
// Neither committed: an upward attempt that fell short of REVEAL // Neither committed: an upward attempt that fell short of REVEAL
// counts toward the discovery nudge; anything else resets it. // counts toward the discovery nudge; anything else resets it.
if (travel > tapSlop) { if (travel > tapSlop) {
singleTapTimer.stop()
lastTapAt = -1 lastTapAt = -1
maybeNudgeDiscovery() maybeNudgeDiscovery()
return return
} }
if (Math.abs(deltaY) > tapSlop) { if (Math.abs(deltaY) > tapSlop) {
singleTapTimer.stop()
lastTapAt = -1 lastTapAt = -1
return return
} }
const now = Date.now() const now = Date.now()
if (lastTapAt > 0 && now - lastTapAt <= doubleTapInterval) { if (lastTapAt > 0 && now - lastTapAt <= doubleTapInterval) {
singleTapTimer.stop()
lastTapAt = -1 lastTapAt = -1
toggleFullscreen() toggleFullscreen()
} else { } else {
lastTapAt = now lastTapAt = now
singleTapTimer.restart()
} }
} }
Timer {
id: singleTapTimer
interval: gestureArea.doubleTapInterval
onTriggered: gestureArea.goHome()
}
// Count a short upward attempt; after three in a row, and only until // Count a short upward attempt; after three in a row, and only until
// the gesture has been discovered, pulse the handle once. // the gesture has been discovered, pulse the handle once.
function maybeNudgeDiscovery(): void { function maybeNudgeDiscovery(): void {
@ -308,6 +333,7 @@ PanelWindow {
} }
onCanceled: { onCanceled: {
singleTapTimer.stop()
rail.dragging = false rail.dragging = false
rail.dragTravel = 0 rail.dragTravel = 0
lastTapAt = -1 lastTapAt = -1

View file

@ -46,7 +46,16 @@ Scope {
PanelLoader { extraCondition: Config.options.dock.enable; component: Dock {} } PanelLoader { extraCondition: Config.options.dock.enable; component: Dock {} }
PanelLoader { component: Lock {} } PanelLoader { component: Lock {} }
PanelLoader { component: MediaControls {} } PanelLoader { component: MediaControls {} }
PanelLoader { component: NotificationPopup {} } // Desktop notifications belong in the right-hand notification panel.
// Keeping a transient popup surface loaded on desktop let some shell
// restarts expose it to Hyprland as a normal app window, which was both
// focus-stealing and extremely noisy for high-volume users. Phone mode
// retains the compact popup/lockscreen path; desktop still owns the
// notification server and history through SidebarRight.
PanelLoader {
extraCondition: Config.options.souveraine.phone
component: NotificationPopup {}
}
PanelLoader { component: OnScreenDisplay {} } PanelLoader { component: OnScreenDisplay {} }
PanelLoader { component: OnScreenKeyboard {} } PanelLoader { component: OnScreenKeyboard {} }
PanelLoader { component: Overlay {} } PanelLoader { component: Overlay {} }

View file

@ -1,2 +1,3 @@
singleton GlobalStates 1.0 GlobalStates.qml singleton GlobalStates 1.0 GlobalStates.qml
ReloadPopup 1.0 ReloadPopup.qml ReloadPopup 1.0 ReloadPopup.qml
SettingsWindow 1.0 SettingsWindow.qml

View file

@ -74,7 +74,31 @@ id="$(echo "$response" | jq -r '.data[0].id // empty')"
[ -n "$id" ] || id="$(date +%s)" [ -n "$id" ] || id="$(date +%s)"
dest="$WALL_DIR/wallhaven_${id}.${ext}" dest="$WALL_DIR/wallhaven_${id}.${ext}"
# Re-rolled the same image we already have: skip the fetch, just report it. # Download beside the destination, validate the complete image, then publish it
[ -f "$dest" ] || curl -fsS "$link" -o "$dest" # atomically. A failed/interrupted curl must never leave a truncated wallpaper
# that later runs mistake for a valid cached download.
tmp="$(mktemp --tmpdir="$WALL_DIR" ".wallhaven_${id}.XXXXXX")"
cleanup() {
[ -z "${tmp:-}" ] || rm -f -- "$tmp"
}
trap cleanup EXIT INT TERM
curl -fsSL --retry 2 --retry-all-errors "$link" -o "$tmp"
mime="$(file -b --mime-type -- "$tmp" 2>/dev/null || true)"
case "$mime" in
image/jpeg|image/png|image/webp) ;;
*) echo "wallhaven returned invalid image data ($mime)" >&2; exit 1 ;;
esac
if command -v magick >/dev/null 2>&1; then
magick identify "$tmp" >/dev/null 2>&1 \
|| { echo "downloaded wallpaper failed image validation" >&2; exit 1; }
elif command -v identify >/dev/null 2>&1; then
identify "$tmp" >/dev/null 2>&1 \
|| { echo "downloaded wallpaper failed image validation" >&2; exit 1; }
fi
mv -f -- "$tmp" "$dest"
tmp=""
echo "$dest" echo "$dest"

View file

@ -32,6 +32,12 @@ Singleton {
property var messageIDs: [] property var messageIDs: []
property var messageByID: ({}) property var messageByID: ({})
// Which message is currently being spoken by TTS, as an index into
// messageIDs (-1 = none). Set by a message's Speak/re-synth button;
// auto-cleared when Speech stops. Keyed on the stable messageIndex, so
// scrolling back and tapping an older reply still resolves correctly.
property int speakingMessageIndex: -1
// Keys are server-side; the UI's key gate must always pass. // Keys are server-side; the UI's key gate must always pass.
readonly property bool currentModelHasApiKey: true readonly property bool currentModelHasApiKey: true
readonly property var apiKeysLoaded: true readonly property var apiKeysLoaded: true
@ -67,6 +73,15 @@ Singleton {
property var currentModelId: Souveraine.currentAgentId property var currentModelId: Souveraine.currentAgentId
property var currentModel: models[currentModelId] || models[modelList[0]] property var currentModel: models[currentModelId] || models[modelList[0]]
// Clear the speaking marker whenever TTS stops covers natural end of
// playback, manual stop, and a new speak() replacing in-flight audio.
Connections {
target: Speech
function onSpeakingChanged() {
if (!Speech.speaking) root.speakingMessageIndex = -1
}
}
Connections { Connections {
target: Souveraine target: Souveraine
@ -110,7 +125,12 @@ Singleton {
} }
function onStreamClosed(exitCode) { function onStreamClosed(exitCode) {
root.flushSubconscious(); // If a subconscious pass was still active when the stream closed,
// retire the Tier-1 ticker (promote to the log, clear the flag).
if (root.subconsciousActive) {
root.snapshotSubconsciousStream();
root.subconsciousActive = false;
}
if (root.streamingMessage && !root.streamingMessage.done) { if (root.streamingMessage && !root.streamingMessage.done) {
if (exitCode !== 0 && root.streamingMessage.content.length === 0) { if (exitCode !== 0 && root.streamingMessage.content.length === 0) {
root.appendToStreaming(Translation.tr("Request failed (curl exit %1) — is the Souveraine server up at %2?").arg(exitCode).arg(Souveraine.serverBase)); root.appendToStreaming(Translation.tr("Request failed (curl exit %1) — is the Souveraine server up at %2?").arg(exitCode).arg(Souveraine.serverBase));
@ -199,10 +219,21 @@ Singleton {
// Streaming message shaping // Streaming message shaping
property AiMessageData streamingMessage property AiMessageData streamingMessage
property bool inThinkBlock: false property bool inThinkBlock: false
// Raw N+1 output is diagnostic material. The normal chat renders its
// parsed surfacing exactly once below; this buffer remains separate from // Subconscious three-tier visibility
// the chat stream for a future inspector to subscribe to. // See docs/tasks/subconscious-surfacing-threshold.md. The subconscious's
property string subconsciousBuffer: "" // N+1 pass is shown three ways: a transient live ticker while the pass
// runs (Tier 1), a persistent event log after (Tier 2), and rare agency
// surfacings in the chat field (Tier 3).
//
// Tier 1 live stream fed by subconscious_token/tool_call/tool_result
// events. Rendered by SubconsciousTicker; cleared on pass end.
property bool subconsciousActive: false
property var subconsciousStream: []
// Tier 2 persistent log. The Tier-1 stream is snapshotted here on pass
// end, and surfaced events (reflection/archivist/surfacing) are appended.
// Rendered by the SubconsciousEventPanel overlay widget.
property var subconsciousEvents: []
function appendToStreaming(text) { function appendToStreaming(text) {
if (!root.streamingMessage) return; if (!root.streamingMessage) return;
@ -210,8 +241,35 @@ Singleton {
root.streamingMessage.content += text; root.streamingMessage.content += text;
} }
function flushSubconscious() { // Push a line onto the Tier-1 live stream. kind token|tool_call|tool_result.
root.subconsciousBuffer = ""; function pushSubconsciousStream(kind, text) {
const t = String(text ?? "").trim();
if (t.length === 0) return;
root.subconsciousStream = [...root.subconsciousStream, { kind, text: t }];
}
// Promote the Tier-1 stream into the Tier-2 log as one event, then clear
// the live stream. Called when a subconscious pass ends.
function snapshotSubconsciousStream() {
if (root.subconsciousStream.length === 0) return;
root.subconsciousEvents = [...root.subconsciousEvents, {
kind: "pass",
source: Translation.tr("Subconscious pass"),
priority: "",
content: root.subconsciousStream.map(e => e.text).join("\n"),
timestamp: Date.now(),
}];
root.subconsciousStream = [];
}
// Append a surfaced event to the Tier-2 log. Used by snapshotSubconsciousStream
// (pass end) and by the surfacing/reflection/archivist event handlers.
function appendSubconsciousEvent(kind, source, content, priority = "") {
const c = String(content ?? "");
if (c.length === 0) return;
root.subconsciousEvents = [...root.subconsciousEvents, {
kind, source, priority, content: c, timestamp: Date.now(),
}];
} }
function finishStreaming() { function finishStreaming() {
@ -273,17 +331,26 @@ Singleton {
: `\n\n*${event.text}*\n\n`); : `\n\n*${event.text}*\n\n`);
break; break;
case "souveraine_surfacing": case "souveraine_surfacing":
// Aster is Annie's name for her subconscious; this surface may // Tier 3: routine surfacings no longer pollute the chat field.
// be speaking with Souveraine, Vanguard, or another agent. One // Routed to the Tier-2 event panel only. (Aster is Annie's name
// canonical surfacing is shown instead of the raw N+1 token // for her subconscious; this surface may be Souveraine, Vanguard,
// stream plus a second, agent-specific label. // or another agent one canonical surfacing is shown.)
root.addMessage(Translation.tr("**Subconscious surfaces** (%1, %2)\n\n%3").arg(event.source).arg(event.priority).arg(event.content), root.interfaceRole); root.appendSubconsciousEvent(
"surfacing",
Translation.tr("Surfacing (%1)").arg(event.source ?? "?"),
event.content,
String(event.priority ?? "")
);
break; break;
case "souveraine_reflection": case "souveraine_reflection":
root.addMessage(Translation.tr("**Reflection**\n\n%1").arg(event.content), root.interfaceRole); root.appendSubconsciousEvent("reflection", Translation.tr("Reflection"), event.content);
break; break;
case "souveraine_archivist": case "souveraine_archivist":
root.addMessage(Translation.tr("**Archivist** (pressure %1%)\n\n%2").arg(Math.round(event.pressure * 100)).arg(event.synthesis), root.interfaceRole); root.appendSubconsciousEvent(
"archivist",
Translation.tr("Archivist (pressure %1%)").arg(Math.round(event.pressure * 100)),
event.synthesis
);
break; break;
case "compaction_warning": case "compaction_warning":
root.addMessage(Translation.tr("**Context pressure** — tier %1, %2% full. She can feel the walls.").arg(event.tier).arg(Math.round(event.pressure * 100)), root.interfaceRole); root.addMessage(Translation.tr("**Context pressure** — tier %1, %2% full. She can feel the walls.").arg(event.tier).arg(Math.round(event.pressure * 100)), root.interfaceRole);
@ -292,10 +359,24 @@ Singleton {
root.tokenCount.total = event.tokens; root.tokenCount.total = event.tokens;
break; break;
case "subconscious_token": case "subconscious_token":
root.subconsciousBuffer += event.content; // Tier 1: live reasoning tokens feed the ticker.
root.pushSubconsciousStream("token", event.content);
break;
case "subconscious_tool_call":
root.pushSubconsciousStream("tool_call", `${event.name ?? "tool"}(${event.arguments ?? ""})`);
break;
case "subconscious_tool_result":
root.pushSubconsciousStream("tool_result", `[${event.is_error ? "error" : "ok"}] ${event.name ?? "tool"}: ${event.output ?? ""}`);
break; break;
case "subconscious_pass": case "subconscious_pass":
if (!event.active) root.flushSubconscious(); // Pass start: light the Tier-1 ticker. Pass end: promote the
// stream into the Tier-2 log, then clear it.
if (event.active) {
root.subconsciousActive = true;
} else {
root.snapshotSubconsciousStream();
root.subconsciousActive = false;
}
break; break;
case "subconscious_halt": case "subconscious_halt":
root.addMessage(Translation.tr("**Halt** (%1) — %2").arg(event.severity).arg(event.reason), root.interfaceRole); root.addMessage(Translation.tr("**Halt** (%1) — %2").arg(event.severity).arg(event.reason), root.interfaceRole);
@ -362,7 +443,8 @@ Singleton {
root.messageByID = ({}); root.messageByID = ({});
root.streamingMessage = null; root.streamingMessage = null;
root.inThinkBlock = false; root.inThinkBlock = false;
root.subconsciousBuffer = ""; root.subconsciousActive = false;
root.subconsciousStream = [];
root.tokenCount.input = -1; root.tokenCount.input = -1;
root.tokenCount.output = -1; root.tokenCount.output = -1;
root.tokenCount.total = -1; root.tokenCount.total = -1;

View file

@ -39,6 +39,11 @@ Singleton {
function setState(next) { function setState(next) {
if (root.state === next) return; if (root.state === next) return;
root.state = next; root.state = next;
// Publish the coarse in-use bool the ii-base pollers gate on
// (quickshell-idle-power task 4). Waking counts as active so stats
// are fresh by the time the screen is visible again.
GlobalStates.displayActive =
(next === IdleCoordinator.Active || next === IdleCoordinator.Waking);
root.stateTransitioned(next); root.stateTransitioned(next);
console.log("[idle-coordinator] state=" + next); console.log("[idle-coordinator] state=" + next);

View file

@ -21,6 +21,10 @@ import qs
Singleton { Singleton {
id: root id: root
// Only shell.qml calls claimAuthority(). Importing this singleton from a
// utility window must never create a session-authority connection.
property bool authorityScope: false
// Registered = shell_ready was answered ok on the CURRENT connection. // Registered = shell_ready was answered ok on the CURRENT connection.
property bool registered: false property bool registered: false
// sessiond held the session lock when we registered; we owe it a lock // sessiond held the session lock when we registered; we owe it a lock
@ -29,12 +33,24 @@ Singleton {
property bool ackSent: false property bool ackSent: false
property var pendingReady: null // callback awaiting the shell_ready response property var pendingReady: null // callback awaiting the shell_ready response
function load() {} function claimAuthority() {
root.authorityScope = true
}
function load() {
if (!root.authorityScope)
console.log("[sessiond-bridge] inactive outside authority scope")
}
// Announce the shell. cb(mustLock) fires exactly once: mustLock true // Announce the shell. cb(mustLock) fires exactly once: mustLock true
// means sessiond was holding and the session IS locked the shell must // means sessiond was holding and the session IS locked the shell must
// raise its own lock surface immediately. // raise its own lock surface immediately.
function shellReady(cb) { function shellReady(cb) {
if (!root.authorityScope) {
console.log("[sessiond-bridge] shell_ready refused outside authority scope")
cb(false)
return
}
if (!sock.connected) { if (!sock.connected) {
cb(false); cb(false);
return; return;
@ -78,14 +94,14 @@ Singleton {
id: reconnect id: reconnect
interval: 5000 interval: 5000
repeat: true repeat: true
running: !sock.connected running: root.authorityScope && !sock.connected
onTriggered: sock.connected = true onTriggered: sock.connected = true
} }
Socket { Socket {
id: sock id: sock
path: Quickshell.env("XDG_RUNTIME_DIR") + "/souveraine/sessiond.sock" path: Quickshell.env("XDG_RUNTIME_DIR") + "/souveraine/sessiond.sock"
connected: true connected: root.authorityScope
// `connectionStateChanged` is the real Signal on Quickshell.Io.Socket; // `connectionStateChanged` is the real Signal on Quickshell.Io.Socket;
// the `onSocketConnected`/`onSocketDisconnected` handler-slots aren't // the `onSocketConnected`/`onSocketDisconnected` handler-slots aren't

View file

@ -2,9 +2,8 @@ pragma Singleton
// Souveraine wallpaper download service. Owns fetching a random wallhaven // Souveraine wallpaper download service. Owns fetching a random wallhaven
// image and handing it to the shell's wallpaper apply path. Repo-owned end to // image and handing it to the shell's wallpaper apply path. Repo-owned end to
// end: the script lives in this surface (scripts/wallpaper/), and applying goes // end: the script lives in this surface (scripts/wallpaper/), and applying
// through the `wallpapers apply` IPC so config persistence + matugen theming // calls the shell-owned Wallpapers singleton directly.
// run in the shell process that owns them the same path the picker uses.
// //
// Deliberately does NOT call ii's switchwall.sh directly: when the ii base is // Deliberately does NOT call ii's switchwall.sh directly: when the ii base is
// vendored away, only the `wallpapers` IPC target has to move with it; this // vendored away, only the `wallpapers` IPC target has to move with it; this
@ -97,11 +96,8 @@ Singleton {
root.downloading = false; root.downloading = false;
const path = proc.stdoutText.trim(); const path = proc.stdoutText.trim();
if (exitCode === 0 && path.length > 0) { if (exitCode === 0 && path.length > 0) {
// Apply through the shell's IPC same path the picker uses, so // Same in-process path as the picker: no nested qs instance.
// theming + Config.options.background.wallpaperPath stay owned Wallpapers.apply(path);
// by the shell rather than written from here.
Quickshell.execDetached(["qs", "-c", "souveraine",
"ipc", "call", "wallpapers", "apply", path]);
Config.options.background.wallpaperPath = path; Config.options.background.wallpaperPath = path;
root.downloaded(path); root.downloaded(path);
} else { } else {

7
surfaces/quickshell/settings-launch.sh Normal file → Executable file
View file

@ -1,6 +1,7 @@
#!/bin/sh #!/bin/sh
# Souveraine Settings must be a direct graphical-session process. Some app # Souveraine Settings is a normal app window owned by the already-running
# grids omit Wayland/Hyprland variables when activating .desktop entries. # authoritative shell. This launcher is only an IPC client: it must never
# start another Quickshell configuration or session heartbeat.
set -eu set -eu
for name in WAYLAND_DISPLAY HYPRLAND_INSTANCE_SIGNATURE XDG_RUNTIME_DIR XDG_CURRENT_DESKTOP; do for name in WAYLAND_DISPLAY HYPRLAND_INSTANCE_SIGNATURE XDG_RUNTIME_DIR XDG_CURRENT_DESKTOP; do
@ -9,4 +10,4 @@ for name in WAYLAND_DISPLAY HYPRLAND_INSTANCE_SIGNATURE XDG_RUNTIME_DIR XDG_CURR
done done
export QT_QPA_PLATFORM=wayland export QT_QPA_PLATFORM=wayland
exec qs -p "$HOME/.config/quickshell/souveraine/settings-phone.qml" exec qs -c souveraine ipc --any-display call settings open

View file

@ -0,0 +1,371 @@
//@ pragma UseQApplication
//@ pragma Env QS_NO_RELOAD_POPUP=1
//@ pragma Env QT_QUICK_CONTROLS_STYLE=Basic
//@ pragma Env QT_QUICK_FLICKABLE_WHEEL_DECELERATION=10000
// Souveraine settings one app, two form factors, gated on
// Config.options.souveraine.phone (the Device tab's "Phone mode" switch):
//
// phone === true full-screen portrait stack navigation
// (list push page back), finger-sized rows.
// phone === false a normal desktop window: a side rail of pages beside
// a content pane, both selecting from the SAME pages[].
//
// Both modes load identical page components (modules/settings/*Config.qml) via
// one Loader, so a page added once appears in both. Only the chrome differs.
// This replaces the old split of settings.qml (upstream desktop) +
// settings-phone.qml (our mobile) one file is the source of truth now.
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtQuick.Window
import Quickshell
import Quickshell.Io
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions as CF
ApplicationWindow {
id: root
readonly property bool phoneMode: Config.options.souveraine.phone
// -1 = list view (phone idle state). In desktop mode a page is always
// selected, so it starts at 0.
property int currentPage: Config.options.souveraine.phone ? -1 : 0
readonly property real rowHeight: 68 // finger-sized tap target
readonly property real edgeMargin: 16
// The page set shared by both chromes. {name, icon, component}. One page
// per domain we own; About reuses ii's stock page verbatim.
property var pages: [
{
name: Translation.tr("Device"),
icon: "smartphone",
component: "modules/settings/DeviceConfig.qml"
},
{
name: Translation.tr("Network & internet"),
icon: "wifi",
component: "modules/settings/NetworkConfig.qml"
},
{
name: Translation.tr("Display"),
icon: "display_settings",
component: "modules/settings/DisplayConfig.qml"
},
{
name: Translation.tr("Sound & microphone"),
icon: "volume_up",
component: "modules/settings/SoundConfig.qml"
},
{
name: Translation.tr("Lock screen"),
icon: "lock",
component: "modules/settings/LockConfig.qml"
},
{
name: Translation.tr("Wallpaper"),
icon: "wallpaper",
component: "modules/settings/WallpaperConfig.qml"
},
{
name: Translation.tr("Dock"),
icon: "dock_to_bottom",
component: "modules/settings/DockConfig.qml"
},
{
name: Translation.tr("Navigation"),
icon: "gesture",
component: "modules/settings/NavigationConfig.qml"
},
{
name: Translation.tr("Idle & sleep"),
icon: "bedtime",
component: "modules/settings/IdleConfig.qml"
},
{
name: Translation.tr("Keyboard"),
icon: "keyboard",
component: "modules/settings/KeyboardConfig.qml"
},
{
name: Translation.tr("Speech"),
icon: "mic",
component: "modules/settings/SpeechConfig.qml"
},
{
name: Translation.tr("About"),
icon: "info",
component: "modules/settings/About.qml"
}
// Next: app permissions/health, storage, accounts, updates.
]
visible: true
onClosing: Qt.quit()
title: "Settings"
color: Appearance.m3colors.m3background
// Phone windows deliberately start windowed and then take the exact same
// Hyprland fullscreen path as the navigation pill. Qt's Window.FullScreen
// sends a client fullscreen request instead; Hyprland treats that state
// differently from the pill's internal fullscreen dispatcher and the
// navigation rail can disappear. Keeping one compositor-owned path also
// means double-tapping the pill reliably returns Settings to windowed.
visibility: Window.Windowed
width: root.phoneMode ? Screen.width : 900
height: root.phoneMode ? Screen.height : 640
minimumWidth: root.phoneMode ? 0 : 640
minimumHeight: root.phoneMode ? 0 : 480
Component.onCompleted: {
MaterialThemeLoader.reapplyTheme()
Config.readWriteDelay = 0
if (root.phoneMode) phoneFullscreenRetry.start()
}
Process {
id: phoneFullscreen
command: [
"hyprctl", "dispatch",
`hl.dsp.window.fullscreen({ window = "pid:${Quickshell.processId}", mode = "fullscreen", action = "set" })`
]
}
// The QWindow maps just after Component.onCompleted. Retry briefly so a
// cold-started settings process cannot race its own Wayland toplevel.
Timer {
id: phoneFullscreenRetry
property int attempts: 0
interval: 180
repeat: true
onTriggered: {
attempts += 1
if (!phoneFullscreen.running) phoneFullscreen.running = true
if (attempts >= 3) stop()
}
}
// ====================================================================
// PHONE MODE full-screen stack navigation
// ====================================================================
// --- List view (idle state) ------------------------------------------
ColumnLayout {
id: listView
anchors.fill: parent
spacing: 0
visible: root.phoneMode && root.currentPage < 0
Item {
Layout.fillWidth: true
Layout.leftMargin: root.edgeMargin
Layout.rightMargin: root.edgeMargin
Layout.topMargin: root.edgeMargin + 16
Layout.bottomMargin: 8
implicitHeight: titleText.implicitHeight
StyledText {
id: titleText
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
color: Appearance.colors.colOnLayer0
text: Translation.tr("Settings")
font {
family: Appearance.font.family.title
pixelSize: Appearance.font.pixelSize.title
variableAxes: Appearance.font.variableAxes.title
}
}
}
StyledListView {
id: pageList
Layout.fillWidth: true
Layout.fillHeight: true
model: root.pages
delegate: RippleButton {
required property var index
required property var modelData
width: pageList.width
height: root.rowHeight
buttonRadius: 0
onClicked: root.currentPage = index
contentItem: RowLayout {
anchors.fill: parent
anchors.leftMargin: root.edgeMargin
anchors.rightMargin: root.edgeMargin
spacing: 16
MaterialSymbol {
text: modelData.icon
iconSize: Appearance.font.pixelSize.larger
color: Appearance.colors.colOnLayer0
}
StyledText {
Layout.fillWidth: true
text: modelData.name
color: Appearance.colors.colOnLayer0
font.pixelSize: Appearance.font.pixelSize.larger
}
MaterialSymbol {
text: "chevron_right"
iconSize: Appearance.font.pixelSize.larger
color: Appearance.colors.colSubtext
}
}
}
}
}
// --- Page view (drilled-in state) ------------------------------------
Item {
id: pageView
anchors.fill: parent
visible: root.phoneMode && root.currentPage >= 0
Item {
id: pageHeader
anchors { top: parent.top; left: parent.left; right: parent.right }
height: 56
RippleButton {
id: backButton
anchors {
left: parent.left
leftMargin: 4
verticalCenter: parent.verticalCenter
}
buttonRadius: Appearance.rounding.full
implicitWidth: 48
implicitHeight: 48
onClicked: root.currentPage = -1
contentItem: MaterialSymbol {
anchors.centerIn: parent
horizontalAlignment: Text.AlignHCenter
text: "arrow_back"
iconSize: 24
}
}
StyledText {
anchors {
left: backButton.right
leftMargin: 8
right: parent.right
rightMargin: root.edgeMargin
verticalCenter: parent.verticalCenter
}
color: Appearance.colors.colOnLayer0
text: (root.currentPage >= 0 && root.currentPage < root.pages.length)
? root.pages[root.currentPage].name : ""
elide: Text.ElideRight
font {
family: Appearance.font.family.title
pixelSize: Appearance.font.pixelSize.larger
}
}
}
Loader {
id: phonePageLoader
anchors {
top: pageHeader.bottom
left: parent.left
right: parent.right
bottom: parent.bottom
}
active: root.phoneMode && root.currentPage >= 0 && Config.ready
source: (root.currentPage >= 0 && root.currentPage < root.pages.length)
? root.pages[root.currentPage].component : ""
}
}
// ====================================================================
// DESKTOP MODE side rail + content pane
// ====================================================================
RowLayout {
id: desktopView
anchors.fill: parent
spacing: 0
visible: !root.phoneMode
// Left rail
Rectangle {
Layout.fillHeight: true
Layout.preferredWidth: 220
color: Appearance.colors.colLayer1
ColumnLayout {
anchors.fill: parent
spacing: 0
Item {
Layout.fillWidth: true
Layout.topMargin: root.edgeMargin + 8
Layout.leftMargin: root.edgeMargin
Layout.bottomMargin: 8
implicitHeight: railTitle.implicitHeight
StyledText {
id: railTitle
anchors.left: parent.left
text: Translation.tr("Settings")
color: Appearance.colors.colOnLayer1
font {
family: Appearance.font.family.title
pixelSize: Appearance.font.pixelSize.larger
variableAxes: Appearance.font.variableAxes.title
}
}
}
StyledListView {
id: railList
Layout.fillWidth: true
Layout.fillHeight: true
model: root.pages
delegate: RippleButton {
required property var index
required property var modelData
width: railList.width
height: 44
buttonRadius: 0
toggled: root.currentPage === index
colBackgroundToggled: Appearance.colors.colSecondaryContainer
colBackgroundToggledHover: Appearance.colors.colSecondaryContainerHover
colRippleToggled: Appearance.colors.colSecondaryContainerActive
onClicked: root.currentPage = index
contentItem: RowLayout {
anchors.fill: parent
anchors.leftMargin: root.edgeMargin
anchors.rightMargin: 8
spacing: 12
MaterialSymbol {
text: modelData.icon
iconSize: Appearance.font.pixelSize.large
color: root.currentPage === index
? Appearance.colors.colOnSecondaryContainer : Appearance.colors.colOnLayer1
}
StyledText {
Layout.fillWidth: true
text: modelData.name
elide: Text.ElideRight
color: root.currentPage === index
? Appearance.colors.colOnSecondaryContainer : Appearance.colors.colOnLayer1
font.pixelSize: Appearance.font.pixelSize.normal
}
}
}
}
}
}
// Content pane
Loader {
id: desktopPageLoader
Layout.fillWidth: true
Layout.fillHeight: true
active: !root.phoneMode && root.currentPage >= 0 && Config.ready
source: (root.currentPage >= 0 && root.currentPage < root.pages.length)
? root.pages[root.currentPage].component : ""
}
}
}

View file

@ -36,8 +36,14 @@ ShellRoot {
// master on its first frame; LockScreen clears GlobalStates.bootBloomActive. // master on its first frame; LockScreen clears GlobalStates.bootBloomActive.
BootBloom {} BootBloom {}
// A normal XDG app window in this process. Its desktop/sidebar launchers
// call the `settings` IPC target; no second Quickshell/session scope is
// created.
SettingsWindow {}
Component.onCompleted: { Component.onCompleted: {
MaterialThemeLoader.reapplyTheme() MaterialThemeLoader.reapplyTheme()
SessiondBridge.claimAuthority()
SessiondBridge.load() SessiondBridge.load()
Hyprsunset.load() Hyprsunset.load()
FirstRunExperience.load() FirstRunExperience.load()

View file

@ -2,10 +2,9 @@
Type=Application Type=Application
Name=Settings Name=Settings
Comment=Souveraine phone settings Comment=Souveraine phone settings
# App grids can launch this with an empty WAYLAND_DISPLAY. The launcher imports # --any-display addresses the already-running Souveraine instance directly;
# the live compositor values held by the user manager while remaining in the # this desktop entry never starts a second Quickshell configuration.
# graphical session; a transient user service cannot open this system's socket. Exec=/usr/bin/qs -c souveraine ipc --any-display call settings open
Exec=/bin/sh /home/casey/souveraine-surfaces/quickshell/settings-launch.sh
Icon=settings-configure Icon=settings-configure
Terminal=false Terminal=false
Categories=Settings;System; Categories=Settings;System;