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:
parent
9ccefd6c73
commit
19fe6b1d0e
59 changed files with 19450 additions and 139 deletions
160
.handoff-from-phone/discord-betterdiscord/PKGBUILD.x86-reference
Normal file
160
.handoff-from-phone/discord-betterdiscord/PKGBUILD.x86-reference
Normal 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"
|
||||
}
|
||||
104
.handoff-from-phone/discord-betterdiscord/TASK.md
Normal file
104
.handoff-from-phone/discord-betterdiscord/TASK.md
Normal 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`
|
||||
|
|
@ -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[@]}" "$@"
|
||||
|
|
@ -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
|
||||
}
|
||||
85
.handoff-from-phone/discord-betterdiscord/krisp-patcher.py
Normal file
85
.handoff-from-phone/discord-betterdiscord/krisp-patcher.py
Normal 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)
|
||||
122
.handoff-from-phone/home-directory-homogenization.md
Normal file
122
.handoff-from-phone/home-directory-homogenization.md
Normal 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*
|
||||
191
.handoff-from-phone/pixel3-microphone/README.md
Normal file
191
.handoff-from-phone/pixel3-microphone/README.md
Normal 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/
|
||||
84
.handoff-from-phone/pixel3-speaker-routing/README.md
Normal file
84
.handoff-from-phone/pixel3-speaker-routing/README.md
Normal 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.
|
||||
15091
.handoff-from-phone/pixel3-speaker-routing/asound-baseline.state
Normal file
15091
.handoff-from-phone/pixel3-speaker-routing/asound-baseline.state
Normal file
File diff suppressed because it is too large
Load diff
BIN
.handoff-from-phone/pixel3-speaker-routing/test-tones/left.wav
Normal file
BIN
.handoff-from-phone/pixel3-speaker-routing/test-tones/left.wav
Normal file
Binary file not shown.
BIN
.handoff-from-phone/pixel3-speaker-routing/test-tones/mono2.wav
Normal file
BIN
.handoff-from-phone/pixel3-speaker-routing/test-tones/mono2.wav
Normal file
Binary file not shown.
BIN
.handoff-from-phone/pixel3-speaker-routing/test-tones/right.wav
Normal file
BIN
.handoff-from-phone/pixel3-speaker-routing/test-tones/right.wav
Normal file
Binary file not shown.
BIN
.handoff-from-phone/pixel3-speaker-routing/test-tones/stereo.wav
Normal file
BIN
.handoff-from-phone/pixel3-speaker-routing/test-tones/stereo.wav
Normal file
Binary file not shown.
135
.handoff-from-phone/quickshell-idle-power/TASK.md
Normal file
135
.handoff-from-phone/quickshell-idle-power/TASK.md
Normal 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.
|
||||
53
.handoff-from-phone/tts-ui-wiring-handoff.md
Normal file
53
.handoff-from-phone/tts-ui-wiring-handoff.md
Normal 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
|
||||
134
.handoff-from-phone/usb-signaller-boot-panic/README.md
Normal file
134
.handoff-from-phone/usb-signaller-boot-panic/README.md
Normal 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.
|
||||
|
|
@ -107,6 +107,9 @@ pub enum MemoryCommand {
|
|||
Delete { path: String },
|
||||
/// Push/fetch against the shared memfs remote (per-instance branch).
|
||||
Sync,
|
||||
/// Audit frontmatter health: list frontmatter-bound files missing
|
||||
/// the required `description` field. Read-only — never mutates.
|
||||
Audit,
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
tokio::fs::write(&path, &rendered)
|
||||
.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);
|
||||
tokio::fs::write(&path, &rendered).await?;
|
||||
|
||||
|
|
@ -447,6 +479,66 @@ impl MemoryRepo {
|
|||
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.
|
||||
pub async fn delete(&self, label: &str) -> Result<()> {
|
||||
let path = self.resolve_path(label);
|
||||
|
|
@ -678,6 +770,30 @@ impl MemoryRepo {
|
|||
.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 {
|
||||
let clean = label.trim().trim_end_matches(".md");
|
||||
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();
|
||||
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 }
|
||||
}
|
||||
"sync" => MemoryCommand::Sync,
|
||||
"audit" => MemoryCommand::Audit,
|
||||
_ => {
|
||||
return ToolResult {
|
||||
tool_use_id,
|
||||
tool_name: tool_name.to_string(),
|
||||
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
|
||||
),
|
||||
is_error: true,
|
||||
|
|
@ -1103,6 +1242,7 @@ Subcommands:
|
|||
delete — Delete a memory file (validates read_only first).
|
||||
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.
|
||||
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(),
|
||||
input_schema: serde_json::json!({
|
||||
|
|
@ -1110,7 +1250,7 @@ Paths are relative to my memory directory. Frontmatter description is required o
|
|||
"properties": {
|
||||
"command": {
|
||||
"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"
|
||||
},
|
||||
"path": {
|
||||
|
|
@ -1340,4 +1480,124 @@ mod tests {
|
|||
let after = std::fs::read_to_string(&commitments).unwrap();
|
||||
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()]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -256,6 +256,16 @@ fn handle_request(
|
|||
}
|
||||
Request::ShellReady => {
|
||||
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.shell_alive = true;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,12 @@ Singleton {
|
|||
// true once the compositor has acknowledged the lock. Consumers that
|
||||
// disclose personal data must gate on secure, not merely on the request.
|
||||
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 screenLockContainsCharacters: false
|
||||
property bool screenUnlockFailed: false
|
||||
|
|
|
|||
414
surfaces/quickshell/SettingsWindow.qml
Normal file
414
surfaces/quickshell/SettingsWindow.qml
Normal 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 : ""
|
||||
}
|
||||
}
|
||||
}
|
||||
670
surfaces/quickshell/assets/keyboard/us.json
Normal file
670
surfaces/quickshell/assets/keyboard/us.json
Normal 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"
|
||||
}
|
||||
|
|
@ -31,8 +31,7 @@ TARGET_ARCH="${SOUVERAINE_TARGET_ARCH:-$(uname -m)}"
|
|||
MANIFEST="
|
||||
shell.qml souveraine/shell.qml
|
||||
GlobalStates.qml souveraine/GlobalStates.qml
|
||||
settings.qml souveraine/settings.qml
|
||||
settings-phone.qml souveraine/settings-phone.qml
|
||||
SettingsWindow.qml souveraine/SettingsWindow.qml
|
||||
panelFamilies/SouveraineFamily.qml souveraine/panelFamilies/SouveraineFamily.qml
|
||||
services/Souveraine.qml souveraine/services/Souveraine.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/AiChat.qml souveraine/modules/ii/sidebarLeft/AiChat.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/volumeMixer/VolumeDialogContent.qml souveraine/modules/ii/sidebarRight/volumeMixer/VolumeDialogContent.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/StyledToolTip.qml souveraine/modules/common/widgets/StyledToolTip.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/WallpaperConfig.qml souveraine/modules/settings/WallpaperConfig.qml
|
||||
modules/settings/DockConfig.qml souveraine/modules/settings/DockConfig.qml
|
||||
|
|
@ -159,6 +162,62 @@ fi
|
|||
|
||||
if [[ "${1:-}" == "--phone" ]]; then
|
||||
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"
|
||||
if repo_root="$(git -C "$SRC" rev-parse --show-toplevel 2>/dev/null)"; then
|
||||
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"
|
||||
# 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"
|
||||
finish_phone_maintenance
|
||||
trap - EXIT
|
||||
echo "Deployed to phone. Hyprland starts qs -c souveraine; no secondary shell is required."
|
||||
exit 0
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -157,6 +157,33 @@ Singleton {
|
|||
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
|
||||
* @param { string | null } str
|
||||
|
|
|
|||
|
|
@ -34,11 +34,22 @@ AbstractBackgroundWidget {
|
|||
"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
|
||||
Timer {
|
||||
id: watchdog
|
||||
interval: 2000
|
||||
running: true
|
||||
running: root.wantCava
|
||||
repeat: true
|
||||
triggeredOnStart: true
|
||||
onTriggered: {
|
||||
|
|
@ -190,7 +201,14 @@ AbstractBackgroundWidget {
|
|||
}
|
||||
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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ Singleton {
|
|||
{ identifier: "resources", materialSymbol: "browse_activity" },
|
||||
{ identifier: "notes", materialSymbol: "note_stack" },
|
||||
{ identifier: "volumeMixer", materialSymbol: "volume_up" },
|
||||
{ identifier: "subconsciousEventPanel", materialSymbol: "psychology" },
|
||||
]
|
||||
|
||||
readonly property bool hasPinnedWidgets: root.pinnedWidgetIdentifiers.length > 0
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import qs.modules.ii.overlay.fpsLimiter
|
|||
import qs.modules.ii.overlay.recorder
|
||||
import qs.modules.ii.overlay.resources
|
||||
import qs.modules.ii.overlay.notes
|
||||
import qs.modules.ii.overlay.subconsciousEventPanel
|
||||
|
||||
DelegateChooser {
|
||||
id: root
|
||||
|
|
@ -25,4 +26,5 @@ DelegateChooser {
|
|||
DelegateChoice { roleValue: "resources"; Resources {} }
|
||||
DelegateChoice { roleValue: "notes"; Notes {} }
|
||||
DelegateChoice { roleValue: "volumeMixer"; VolumeMixer {} }
|
||||
DelegateChoice { roleValue: "subconsciousEventPanel"; SubconsciousEventPanel {} }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -66,6 +66,7 @@ RowLayout {
|
|||
focus: GlobalStates.overviewOpen
|
||||
font.pixelSize: Appearance.font.pixelSize.small
|
||||
placeholderText: Translation.tr("Search, calculate or run")
|
||||
inputMethodHints: Qt.ImhNoPredictiveText | Qt.ImhNoAutoUppercase
|
||||
implicitWidth: root.searchingText == "" ? Appearance.sizes.searchWidthCollapsed : Appearance.sizes.searchWidth
|
||||
|
||||
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: {
|
||||
if (appResults.count > 0) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
pragma ComponentBehavior: Bound
|
||||
|
||||
import Qt.labs.synchronizer
|
||||
import Qt5Compat.GraphicalEffects
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
|
|
@ -47,6 +46,25 @@ Item { // Wrapper
|
|||
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 => {
|
||||
// Prevent Esc and Backspace from registering
|
||||
if (event.key === Qt.Key_Escape)
|
||||
|
|
@ -146,16 +164,6 @@ Item { // Wrapper
|
|||
}
|
||||
spacing: 0
|
||||
|
||||
// clip: true
|
||||
layer.enabled: true
|
||||
layer.effect: OpacityMask {
|
||||
maskSource: Rectangle {
|
||||
width: searchWidgetContent.width
|
||||
height: searchWidgetContent.width
|
||||
radius: searchWidgetContent.radius
|
||||
}
|
||||
}
|
||||
|
||||
SearchBar {
|
||||
id: searchBar
|
||||
property real verticalPadding: 4
|
||||
|
|
|
|||
|
|
@ -200,6 +200,46 @@ Rectangle {
|
|||
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 {
|
||||
id: copyButton
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -26,9 +26,13 @@ Singleton {
|
|||
property string uptime: "0h, 0m"
|
||||
|
||||
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
|
||||
repeat: true
|
||||
triggeredOnStart: true
|
||||
onTriggered: {
|
||||
fileUptime.reload();
|
||||
const textUptime = fileUptime.text();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import qs
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
|
|
@ -148,7 +149,10 @@ Singleton {
|
|||
|
||||
Timer {
|
||||
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
|
||||
triggeredOnStart: true
|
||||
onTriggered: root.update()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import qs
|
||||
import qs.modules.common
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
|
|
@ -61,7 +62,9 @@ Singleton {
|
|||
|
||||
Timer {
|
||||
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
|
||||
onTriggered: {
|
||||
// Reload files
|
||||
|
|
|
|||
|
|
@ -77,6 +77,44 @@ Singleton {
|
|||
// the lock surface; do not treat it as proof that the session is secure.
|
||||
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)
|
||||
|
||||
Process {
|
||||
|
|
@ -424,6 +462,27 @@ Singleton {
|
|||
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 ------------------------------------------------
|
||||
// Logs every IPC endpoint the shell exposes at startup. This is a
|
||||
// defensive visibility measure — not a gate, not a refusal. It answers
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
singleton BitwiseFuzzy 1.0 BitwiseFuzzy.qml
|
||||
singleton ColorUtils 1.0 ColorUtils.qml
|
||||
singleton DateUtils 1.0 DateUtils.qml
|
||||
singleton FileUtils 1.0 FileUtils.qml
|
||||
singleton Fuzzy 1.0 Fuzzy.qml
|
||||
singleton KeymapTranslation 1.0 KeymapTranslation.qml
|
||||
singleton Levendist 1.0 Levendist.qml
|
||||
singleton NotificationUtils 1.0 NotificationUtils.qml
|
||||
singleton ObjectUtils 1.0 ObjectUtils.qml
|
||||
|
|
|
|||
|
|
@ -5,12 +5,14 @@ BarGroup 1.0 BarGroup.qml
|
|||
BatteryIndicator 1.0 BatteryIndicator.qml
|
||||
BatteryPopup 1.0 BatteryPopup.qml
|
||||
CircleUtilButton 1.0 CircleUtilButton.qml
|
||||
ClaudeUsageBar 1.0 ClaudeUsageBar.qml
|
||||
ClockWidget 1.0 ClockWidget.qml
|
||||
ClockWidgetPopup 1.0 ClockWidgetPopup.qml
|
||||
HyprlandXkbIndicator 1.0 HyprlandXkbIndicator.qml
|
||||
LeftSidebarButton 1.0 LeftSidebarButton.qml
|
||||
Media 1.0 Media.qml
|
||||
NotificationUnreadCount 1.0 NotificationUnreadCount.qml
|
||||
PomodoroBarIndicator 1.0 PomodoroBarIndicator.qml
|
||||
Resource 1.0 Resource.qml
|
||||
Resources 1.0 Resources.qml
|
||||
ResourcesPopup 1.0 ResourcesPopup.qml
|
||||
|
|
|
|||
|
|
@ -203,6 +203,23 @@ Scope {
|
|||
function open() {
|
||||
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() {
|
||||
GlobalStates.superReleaseMightTrigger = false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
// Skips lock-redacted content (LockContentPolicy swaps it for a marker).
|
||||
// Latest visible assistant reply — what gets read aloud. Skips
|
||||
// lock-redacted content (LockContentPolicy swaps it for a marker).
|
||||
function lastAssistantText() {
|
||||
for (let i = Ai.messageIDs.length - 1; i >= 0; i--) {
|
||||
const m = Ai.messageByID[Ai.messageIDs[i]];
|
||||
if (m && m.role === "assistant" && (m.visibleToUser ?? true)
|
||||
&& m.content && m.content.length > 0
|
||||
&& !m.content.startsWith("[content hidden")) {
|
||||
return m.content;
|
||||
return StringUtils.ttsClean(m.content);
|
||||
}
|
||||
}
|
||||
return "";
|
||||
|
|
@ -497,6 +497,8 @@ Inline w/ backslash and round brackets \\(e^{i\\pi} + 1 = 0\\)
|
|||
}
|
||||
}
|
||||
|
||||
SubconsciousTicker {}
|
||||
|
||||
Rectangle { // Input area
|
||||
id: inputWrapper
|
||||
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
|
||||
id: sendButton
|
||||
Layout.alignment: Qt.AlignBottom
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
71
surfaces/quickshell/modules/settings/DisplayConfig.qml
Normal file
71
surfaces/quickshell/modules/settings/DisplayConfig.qml
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell.Io
|
||||
import qs.services
|
||||
import qs.modules.common
|
||||
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
|
||||
// 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
|
||||
// read the IdleCoordinator singleton directly. It reads the live stage
|
||||
// over the same `session` IPC the arbiter already exposes
|
||||
// (session.state().idle.stage), on a light poll.
|
||||
// 2. Settings is a window in the authoritative shell process, so this page
|
||||
// reads the idle/session singletons directly. It must not spawn `qs ipc`
|
||||
// children, which can become accidental shell instances when display
|
||||
// selection is ambiguous.
|
||||
ContentPage {
|
||||
id: page
|
||||
forceWidth: true
|
||||
|
||||
// --- Live stage readout (polled over IPC) ----------------------------
|
||||
// --- Live stage readout ----------------------------------------------
|
||||
// 0 Active · 1 Dimmed · 2 Lock requested · 3 Lock secure ·
|
||||
// 4 Suspending · 5 Asleep · 6 Waking — the IdleCoordinator.State
|
||||
// enum, surfaced through session.state().
|
||||
property int liveStage: -1
|
||||
property bool liveNative: false
|
||||
property bool probeOk: false
|
||||
property bool sleepInhibitorHeld: false
|
||||
property bool stepUpEnabled: false
|
||||
// enum, read directly from the shell-owned coordinator.
|
||||
readonly property int liveStage: IdleCoordinator.state
|
||||
readonly property bool liveNative: IdleCoordinator.nativeEnabled
|
||||
readonly property bool probeOk: true
|
||||
readonly property bool sleepInhibitorHeld: SessionEvents.sleepInhibitorHeld
|
||||
readonly property bool stepUpEnabled: StepUpAuth.grantTtlMs > 0
|
||||
|
||||
readonly property var stageNames: [
|
||||
Translation.tr("Active"),
|
||||
|
|
@ -51,36 +50,6 @@ ContentPage {
|
|||
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 {
|
||||
icon: "motion_sensor_active"
|
||||
title: Translation.tr("Current state")
|
||||
|
|
@ -122,7 +91,6 @@ ContentPage {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ContentSection {
|
||||
icon: "experiment"
|
||||
|
|
|
|||
199
surfaces/quickshell/modules/settings/NetworkConfig.qml
Normal file
199
surfaces/quickshell/modules/settings/NetworkConfig.qml
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
93
surfaces/quickshell/modules/settings/SoundConfig.qml
Normal file
93
surfaces/quickshell/modules/settings/SoundConfig.qml
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -7,9 +7,9 @@ import qs.modules.common
|
|||
import qs.modules.common.widgets
|
||||
|
||||
// Wallpaper — pick from ~/Pictures/Wallpapers inside the settings app.
|
||||
// Applying goes through the main shell's `wallpapers apply` IPC so the
|
||||
// selection, config persistence, and matugen theming all run in the shell
|
||||
// process that owns them; this page is only a view.
|
||||
// Settings is a window in the main shell process, so applying calls the
|
||||
// shell-owned Wallpapers singleton directly. Never spawn a nested `qs ipc`
|
||||
// process from this page.
|
||||
|
||||
ContentPage {
|
||||
forceWidth: true
|
||||
|
|
@ -258,8 +258,7 @@ ContentPage {
|
|||
currentDir = filePath;
|
||||
} else {
|
||||
// Shell process owns selection + theming.
|
||||
Quickshell.execDetached(["qs", "-c", "souveraine",
|
||||
"ipc", "call", "wallpapers", "apply", filePath]);
|
||||
Wallpapers.apply(filePath);
|
||||
Config.options.background.wallpaperPath = filePath;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ AdvancedConfig 1.0 AdvancedConfig.qml
|
|||
BackgroundConfig 1.0 BackgroundConfig.qml
|
||||
BarConfig 1.0 BarConfig.qml
|
||||
DeviceConfig 1.0 DeviceConfig.qml
|
||||
DisplayConfig 1.0 DisplayConfig.qml
|
||||
DockConfig 1.0 DockConfig.qml
|
||||
GeneralConfig 1.0 GeneralConfig.qml
|
||||
IdleConfig 1.0 IdleConfig.qml
|
||||
|
|
@ -10,7 +11,10 @@ InterfaceConfig 1.0 InterfaceConfig.qml
|
|||
KeyboardConfig 1.0 KeyboardConfig.qml
|
||||
LockConfig 1.0 LockConfig.qml
|
||||
NavigationConfig 1.0 NavigationConfig.qml
|
||||
NetworkConfig 1.0 NetworkConfig.qml
|
||||
QuickConfig 1.0 QuickConfig.qml
|
||||
ServicesConfig 1.0 ServicesConfig.qml
|
||||
SettingsHome 1.0 SettingsHome.qml
|
||||
SpeechConfig 1.0 SpeechConfig.qml
|
||||
SoundConfig 1.0 SoundConfig.qml
|
||||
WallpaperConfig 1.0 WallpaperConfig.qml
|
||||
|
|
|
|||
|
|
@ -63,6 +63,25 @@ Scope {
|
|||
// Pokes the C splash to release DRM master. Fires once, on first frame.
|
||||
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
|
||||
// elapsed = frameClock.currentTime-based delta. We drive u_time off an
|
||||
// Animator-free FrameAnimation for a monotonic per-frame tick.
|
||||
|
|
|
|||
|
|
@ -206,10 +206,24 @@ PanelWindow {
|
|||
`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
|
||||
// (0/1/2). Higher stages imply the lower one, so Mission Control also
|
||||
// reveals the dock beneath it.
|
||||
function commitUp(stage: int): void {
|
||||
singleTapTimer.stop()
|
||||
lastTapAt = -1
|
||||
if (stage >= 1) {
|
||||
GlobalStates.dockSuppressed = false
|
||||
|
|
@ -260,6 +274,7 @@ PanelWindow {
|
|||
|
||||
// Downward: peel the nearest surface (keyboard, then dock).
|
||||
if (deltaY >= rail.revealAt) {
|
||||
singleTapTimer.stop()
|
||||
lastTapAt = -1
|
||||
if (GlobalStates.oskOpen) {
|
||||
GlobalStates.oskOpen = false
|
||||
|
|
@ -277,24 +292,34 @@ PanelWindow {
|
|||
// Neither committed: an upward attempt that fell short of REVEAL
|
||||
// counts toward the discovery nudge; anything else resets it.
|
||||
if (travel > tapSlop) {
|
||||
singleTapTimer.stop()
|
||||
lastTapAt = -1
|
||||
maybeNudgeDiscovery()
|
||||
return
|
||||
}
|
||||
if (Math.abs(deltaY) > tapSlop) {
|
||||
singleTapTimer.stop()
|
||||
lastTapAt = -1
|
||||
return
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
if (lastTapAt > 0 && now - lastTapAt <= doubleTapInterval) {
|
||||
singleTapTimer.stop()
|
||||
lastTapAt = -1
|
||||
toggleFullscreen()
|
||||
} else {
|
||||
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
|
||||
// the gesture has been discovered, pulse the handle once.
|
||||
function maybeNudgeDiscovery(): void {
|
||||
|
|
@ -308,6 +333,7 @@ PanelWindow {
|
|||
}
|
||||
|
||||
onCanceled: {
|
||||
singleTapTimer.stop()
|
||||
rail.dragging = false
|
||||
rail.dragTravel = 0
|
||||
lastTapAt = -1
|
||||
|
|
|
|||
|
|
@ -46,7 +46,16 @@ Scope {
|
|||
PanelLoader { extraCondition: Config.options.dock.enable; component: Dock {} }
|
||||
PanelLoader { component: Lock {} }
|
||||
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: OnScreenKeyboard {} }
|
||||
PanelLoader { component: Overlay {} }
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
singleton GlobalStates 1.0 GlobalStates.qml
|
||||
ReloadPopup 1.0 ReloadPopup.qml
|
||||
SettingsWindow 1.0 SettingsWindow.qml
|
||||
|
|
|
|||
|
|
@ -74,7 +74,31 @@ id="$(echo "$response" | jq -r '.data[0].id // empty')"
|
|||
[ -n "$id" ] || id="$(date +%s)"
|
||||
dest="$WALL_DIR/wallhaven_${id}.${ext}"
|
||||
|
||||
# Re-rolled the same image we already have: skip the fetch, just report it.
|
||||
[ -f "$dest" ] || curl -fsS "$link" -o "$dest"
|
||||
# Download beside the destination, validate the complete image, then publish it
|
||||
# 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"
|
||||
|
|
|
|||
|
|
@ -32,6 +32,12 @@ Singleton {
|
|||
property var messageIDs: []
|
||||
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.
|
||||
readonly property bool currentModelHasApiKey: true
|
||||
readonly property var apiKeysLoaded: true
|
||||
|
|
@ -67,6 +73,15 @@ Singleton {
|
|||
property var currentModelId: Souveraine.currentAgentId
|
||||
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 {
|
||||
target: Souveraine
|
||||
|
||||
|
|
@ -110,7 +125,12 @@ Singleton {
|
|||
}
|
||||
|
||||
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 (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));
|
||||
|
|
@ -199,10 +219,21 @@ Singleton {
|
|||
// ── Streaming message shaping ────────────────────────────────────────
|
||||
property AiMessageData streamingMessage
|
||||
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
|
||||
// the chat stream for a future inspector to subscribe to.
|
||||
property string subconsciousBuffer: ""
|
||||
|
||||
// ── Subconscious three-tier visibility ───────────────────────────────
|
||||
// See docs/tasks/subconscious-surfacing-threshold.md. The subconscious's
|
||||
// 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) {
|
||||
if (!root.streamingMessage) return;
|
||||
|
|
@ -210,8 +241,35 @@ Singleton {
|
|||
root.streamingMessage.content += text;
|
||||
}
|
||||
|
||||
function flushSubconscious() {
|
||||
root.subconsciousBuffer = "";
|
||||
// Push a line onto the Tier-1 live stream. kind ∈ token|tool_call|tool_result.
|
||||
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() {
|
||||
|
|
@ -273,17 +331,26 @@ Singleton {
|
|||
: `\n\n*${event.text}*\n\n`);
|
||||
break;
|
||||
case "souveraine_surfacing":
|
||||
// Aster is Annie's name for her subconscious; this surface may
|
||||
// be speaking with Souveraine, Vanguard, or another agent. One
|
||||
// canonical surfacing is shown instead of the raw N+1 token
|
||||
// stream plus a second, agent-specific label.
|
||||
root.addMessage(Translation.tr("**Subconscious surfaces** (%1, %2)\n\n%3").arg(event.source).arg(event.priority).arg(event.content), root.interfaceRole);
|
||||
// Tier 3: routine surfacings no longer pollute the chat field.
|
||||
// Routed to the Tier-2 event panel only. (Aster is Annie's name
|
||||
// for her subconscious; this surface may be Souveraine, Vanguard,
|
||||
// or another agent — one canonical surfacing is shown.)
|
||||
root.appendSubconsciousEvent(
|
||||
"surfacing",
|
||||
Translation.tr("Surfacing (%1)").arg(event.source ?? "?"),
|
||||
event.content,
|
||||
String(event.priority ?? "")
|
||||
);
|
||||
break;
|
||||
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;
|
||||
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;
|
||||
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);
|
||||
|
|
@ -292,10 +359,24 @@ Singleton {
|
|||
root.tokenCount.total = event.tokens;
|
||||
break;
|
||||
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;
|
||||
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;
|
||||
case "subconscious_halt":
|
||||
root.addMessage(Translation.tr("**Halt** (%1) — %2").arg(event.severity).arg(event.reason), root.interfaceRole);
|
||||
|
|
@ -362,7 +443,8 @@ Singleton {
|
|||
root.messageByID = ({});
|
||||
root.streamingMessage = null;
|
||||
root.inThinkBlock = false;
|
||||
root.subconsciousBuffer = "";
|
||||
root.subconsciousActive = false;
|
||||
root.subconsciousStream = [];
|
||||
root.tokenCount.input = -1;
|
||||
root.tokenCount.output = -1;
|
||||
root.tokenCount.total = -1;
|
||||
|
|
|
|||
|
|
@ -39,6 +39,11 @@ Singleton {
|
|||
function setState(next) {
|
||||
if (root.state === next) return;
|
||||
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);
|
||||
console.log("[idle-coordinator] state=" + next);
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,10 @@ import qs
|
|||
Singleton {
|
||||
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.
|
||||
property bool registered: false
|
||||
// sessiond held the session lock when we registered; we owe it a lock
|
||||
|
|
@ -29,12 +33,24 @@ Singleton {
|
|||
property bool ackSent: false
|
||||
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
|
||||
// means sessiond was holding and the session IS locked — the shell must
|
||||
// raise its own lock surface immediately.
|
||||
function shellReady(cb) {
|
||||
if (!root.authorityScope) {
|
||||
console.log("[sessiond-bridge] shell_ready refused outside authority scope")
|
||||
cb(false)
|
||||
return
|
||||
}
|
||||
if (!sock.connected) {
|
||||
cb(false);
|
||||
return;
|
||||
|
|
@ -78,14 +94,14 @@ Singleton {
|
|||
id: reconnect
|
||||
interval: 5000
|
||||
repeat: true
|
||||
running: !sock.connected
|
||||
running: root.authorityScope && !sock.connected
|
||||
onTriggered: sock.connected = true
|
||||
}
|
||||
|
||||
Socket {
|
||||
id: sock
|
||||
path: Quickshell.env("XDG_RUNTIME_DIR") + "/souveraine/sessiond.sock"
|
||||
connected: true
|
||||
connected: root.authorityScope
|
||||
|
||||
// `connectionStateChanged` is the real Signal on Quickshell.Io.Socket;
|
||||
// the `onSocketConnected`/`onSocketDisconnected` handler-slots aren't
|
||||
|
|
|
|||
|
|
@ -2,9 +2,8 @@ pragma Singleton
|
|||
|
||||
// Souveraine wallpaper download service. Owns fetching a random wallhaven
|
||||
// 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
|
||||
// through the `wallpapers apply` IPC so config persistence + matugen theming
|
||||
// run in the shell process that owns them — the same path the picker uses.
|
||||
// end: the script lives in this surface (scripts/wallpaper/), and applying
|
||||
// calls the shell-owned Wallpapers singleton directly.
|
||||
//
|
||||
// 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
|
||||
|
|
@ -97,11 +96,8 @@ Singleton {
|
|||
root.downloading = false;
|
||||
const path = proc.stdoutText.trim();
|
||||
if (exitCode === 0 && path.length > 0) {
|
||||
// Apply through the shell's IPC — same path the picker uses, so
|
||||
// theming + Config.options.background.wallpaperPath stay owned
|
||||
// by the shell rather than written from here.
|
||||
Quickshell.execDetached(["qs", "-c", "souveraine",
|
||||
"ipc", "call", "wallpapers", "apply", path]);
|
||||
// Same in-process path as the picker: no nested qs instance.
|
||||
Wallpapers.apply(path);
|
||||
Config.options.background.wallpaperPath = path;
|
||||
root.downloaded(path);
|
||||
} else {
|
||||
|
|
|
|||
7
surfaces/quickshell/settings-launch.sh
Normal file → Executable file
7
surfaces/quickshell/settings-launch.sh
Normal file → Executable file
|
|
@ -1,6 +1,7 @@
|
|||
#!/bin/sh
|
||||
# Souveraine Settings must be a direct graphical-session process. Some app
|
||||
# grids omit Wayland/Hyprland variables when activating .desktop entries.
|
||||
# Souveraine Settings is a normal app window owned by the already-running
|
||||
# authoritative shell. This launcher is only an IPC client: it must never
|
||||
# start another Quickshell configuration or session heartbeat.
|
||||
set -eu
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
|
|
|||
371
surfaces/quickshell/settings.qml
Normal file
371
surfaces/quickshell/settings.qml
Normal 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 : ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -36,8 +36,14 @@ ShellRoot {
|
|||
// master on its first frame; LockScreen clears GlobalStates.bootBloomActive.
|
||||
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: {
|
||||
MaterialThemeLoader.reapplyTheme()
|
||||
SessiondBridge.claimAuthority()
|
||||
SessiondBridge.load()
|
||||
Hyprsunset.load()
|
||||
FirstRunExperience.load()
|
||||
|
|
|
|||
|
|
@ -2,10 +2,9 @@
|
|||
Type=Application
|
||||
Name=Settings
|
||||
Comment=Souveraine phone settings
|
||||
# App grids can launch this with an empty WAYLAND_DISPLAY. The launcher imports
|
||||
# the live compositor values held by the user manager while remaining in the
|
||||
# graphical session; a transient user service cannot open this system's socket.
|
||||
Exec=/bin/sh /home/casey/souveraine-surfaces/quickshell/settings-launch.sh
|
||||
# --any-display addresses the already-running Souveraine instance directly;
|
||||
# this desktop entry never starts a second Quickshell configuration.
|
||||
Exec=/usr/bin/qs -c souveraine ipc --any-display call settings open
|
||||
Icon=settings-configure
|
||||
Terminal=false
|
||||
Categories=Settings;System;
|
||||
|
|
|
|||
Loading…
Reference in a new issue