quickshell: first-party lock/nav/session layer, retire the pill
Grows Souveraine's own surfaces on top of the borrowed ii shell and drops the separate pill shell in favor of one integrated navigation rail. Session arbiter (functions/Session.qml): probe logind's Can* methods over busctl instead of guessing from installed binaries -- the answer carries the polkit tier (yes/challenge/na), so a swapless phone reports hibernate as na and refuses honestly rather than firing a verb that no-ops. Verbs run through a Process that logs exit codes and tracks lastAction; refusals log too. The busctl output is parsed with awk, not a sed regex buried under four escaping layers -- the sed version returned nothing on the phone and left every capability stuck at "unknown" (invisible on the laptop, where timing masked it). Every structured result is JSON-over-string; quickshell maps a var return to void. Lock trust: screenLocked (the shell's lock request) is now distinct from screenLockSecure (WlSessionLock.secure, the compositor's acknowledgement, mirrored from LockScreen). Cards that disclose personal data gate on secure, not on a button press. LockContentPolicy centralizes the ambient/personal/ step-up tiers so no card grows its own private rule. New first-party namespace modules/souveraine/: LockMediaCard, LockSurfaceHost, SystemGestureRail -- owned surfaces, not ii patches. IdleCoordinator gives one staged idle vocabulary (dim/lock) gated behind nativeCoordinatorEnabled, off until the native Wayland idle-notify is verified on the Pixel compositor; hypridle stays the adapter. WallpaperAssets selects aspect-aware variants for phone-vs-laptop display shapes. Pill retired: pill/shell.qml and PillConfig gone, replaced by NavigationConfig and the gesture rail. Hyprland starts qs -c souveraine directly; no secondary shell, no qsConfig flip. Verified on the phone: session.* reports challenge/na correctly, hibernate and unlock refuse, inhibit round-trips with its reason.
This commit is contained in:
parent
cecde3bac2
commit
e31c3aaf62
26 changed files with 1374 additions and 251 deletions
|
|
@ -1,9 +1,9 @@
|
|||
// Souveraine patch to ii's stock GlobalStates.qml.
|
||||
//
|
||||
// Adds dockRevealed: the explicit, persistent dock state driven by the
|
||||
// gesture pill (pill/shell.qml). Everything else in this file is unchanged
|
||||
// stock ii — diff against upstream before re-applying this patch if ii
|
||||
// updates.
|
||||
// Souveraine's integrated navigation rail. Everything else in this file is
|
||||
// unchanged stock ii — diff against upstream before re-applying this patch if
|
||||
// ii updates.
|
||||
import qs.modules.common
|
||||
import qs.services
|
||||
import QtQuick
|
||||
|
|
@ -27,7 +27,13 @@ Singleton {
|
|||
property bool overviewOpen: false
|
||||
property bool regionSelectorOpen: false
|
||||
property bool searchOpen: false
|
||||
// `screenLocked` is the shell's lock *request*: it drives WlSessionLock
|
||||
// and hides ordinary surfaces immediately. It is deliberately separate
|
||||
// from `screenLockSecure`, which is WlSessionLock.secure and only becomes
|
||||
// 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
|
||||
property bool screenLockSecure: false
|
||||
property bool screenLockContainsCharacters: false
|
||||
property bool screenUnlockFailed: false
|
||||
property bool screenTranslatorOpen: false
|
||||
|
|
@ -40,8 +46,8 @@ Singleton {
|
|||
property bool wallpaperSelectorOpen: false
|
||||
property bool workspaceShowNumbers: false
|
||||
// In fullscreen this is the only way the dock becomes visible:
|
||||
// pill swipe up sets it; pill swipe down clears it. It deliberately has
|
||||
// no timer or secondary state.
|
||||
// the navigation rail swipe sets/clears it. It deliberately has no timer
|
||||
// or secondary state.
|
||||
property bool dockRevealed: false
|
||||
|
||||
// True while a dock icon drag is in flight. Set by DragManager, read by
|
||||
|
|
|
|||
|
|
@ -1,37 +1,36 @@
|
|||
# How the phone shell works
|
||||
|
||||
Three surfaces: **bar** (top), **dock** (bottom, above pill), **pill** (bottom edge).
|
||||
Three surfaces: **bar** (top), **dock** (bottom, above the navigation rail),
|
||||
and Souveraine's integrated **navigation rail** (bottom edge).
|
||||
Edit here → `./deploy.sh --phone` → restart. Deployed via symlink; live edits = git-tracked.
|
||||
|
||||
## Pill (`pill/shell.qml`) — the always-on gesture bar
|
||||
## Navigation rail (`modules/souveraine/navigation/SystemGestureRail.qml`)
|
||||
|
||||
- **Always visible.** `WlrLayer.Overlay` + `ExclusionMode.Ignore` + `margins.bottom:0`. Survives fullscreen, dock, OSK. Never lower the layer.
|
||||
- **Must sit ABOVE the dock in z-order.** Enforced by the `pill-above-dock`
|
||||
layerrule in hyprland.lua (`order = 1` on namespace `quickshell:pill`) —
|
||||
creation order alone buried the pill after an ii restart and killed its
|
||||
touch. If the pill ever stops taking touch, verify that rule loaded.
|
||||
- **Owned by Souveraine.** It is loaded by `SouveraineFamily` on phone mode;
|
||||
there is no second Quickshell configuration, external IPC hop, or Hyprland
|
||||
layer rule to keep in sync.
|
||||
- Gestures (MouseArea — pointer handlers don't get touch here):
|
||||
- **double-tap** → toggles the active app's named `fullscreen` mode (whole
|
||||
display, no border/gaps). Routes via `dock` IPC `fullscreen()`, which
|
||||
targets `Hyprland.activeToplevel.address` — NOT hyprctl on "active window"
|
||||
(the tap focuses the shell).
|
||||
- **swipe up** → `dock` IPC `swipeUp` (reveals the dock above the pill).
|
||||
- **swipe down** → `dock` IPC `swipeDown` (dismiss).
|
||||
- **No keyboard on the pill.** OSK = 3-finger hyprgrass swipe only.
|
||||
display, no border/gaps). Targets `Hyprland.activeToplevel.address` — not
|
||||
hyprctl's focus (the tap focuses the shell).
|
||||
- **swipe up** → reveals the dock.
|
||||
- **swipe down** → dismisses the dock.
|
||||
- **No keyboard on the rail.** OSK = 3-finger hyprgrass swipe only.
|
||||
|
||||
## Dock (`modules/ii/dock/Dock.qml`)
|
||||
|
||||
- **On `WlrLayer.Overlay`** so the pill can reveal it over fullscreen apps.
|
||||
- **On `WlrLayer.Overlay`** so the navigation rail can reveal it over fullscreen apps.
|
||||
- **Hidden = layer unmounted** (`visible:false`), not just tucked — else it paints over fullscreen.
|
||||
- **Visibility** (`computeDockState`, first match wins):
|
||||
1. fullscreen app on focused monitor → **Hidden** (unless pill-revealed)
|
||||
1. fullscreen app on focused monitor → **Hidden** (unless rail-revealed)
|
||||
2. pinned → **Pinned** (only state that reserves exclusive zone)
|
||||
3. preview-hover → **Shown**
|
||||
4. empty desktop / no focused app → **Shown**
|
||||
5. else (normal app focused) → **Hidden**
|
||||
- **Reserves a 32px pill strip** at the bottom visually *and in its
|
||||
layer-shell input mask*, so the dock cannot intercept pill touches.
|
||||
- **Bar height is content-driven**: the window sizes itself to the button row (64px buttons) + row margin + pill strip; `Config.options.dock.height` is only a floor. Don't tune the config height to "fix" icon clipping.
|
||||
- **Reserves the navigation rail height** at the bottom visually *and in its
|
||||
layer-shell input mask*, so the dock cannot intercept rail touches.
|
||||
- **Bar height is content-driven**: the window sizes itself to the button row (64px buttons) + row margin + rail height; `Config.options.dock.height` is only a floor. Don't tune the config height to "fix" icon clipping.
|
||||
- **App list width is capped** to the screen (`DockApps.maxWidth`); past that the icon row scrolls horizontally by touch (flick is only enabled when overflowing, so drag-to-combine keeps working when everything fits).
|
||||
- **DockStack renders with the same content block as DockAppButton** (icon + half-reserved dot strip, centered as one unit). Keep them structurally identical or they drift apart on the bar.
|
||||
- Depends on **`GlobalStates.dockRevealed`** — lives in the surface tree (`GlobalStates.qml`, stock ii + patch) and the deploy manifest. If ii updates its GlobalStates, re-diff and re-apply the patch.
|
||||
|
|
@ -52,5 +51,5 @@ the dock.
|
|||
## Fullscreen API (Hyprland 0.55, Lua dispatch)
|
||||
|
||||
- Dispatch: `hl.dsp.window.fullscreen({ window="address:0x…", mode="fullscreen", action="toggle" })`.
|
||||
- **`fullscreen`** = whole display, no borders or gaps. ← the pill uses this.
|
||||
- **`fullscreen`** = whole display, no borders or gaps. ← the navigation rail uses this.
|
||||
- **`maximized`** = keeps the normal workspace layout margins.
|
||||
|
|
|
|||
176
surfaces/quickshell/REFERENCE-EXTRACTION.md
Normal file
176
surfaces/quickshell/REFERENCE-EXTRACTION.md
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
# Lock, idle, and wallpaper reference extraction
|
||||
|
||||
This is a design extraction, not a dependency list. Souveraine should retain
|
||||
one session authority across desktop, laptop, and phone; form factor changes
|
||||
the chosen layout and cards, never the meaning of lock, idle, or auth.
|
||||
|
||||
Migration rule: ii is a temporary compatibility substrate, not a source of
|
||||
new architecture. New behavior belongs in `surfaces/quickshell` under a
|
||||
Souveraine-owned module or service. Borrowed code is replaced deliberately,
|
||||
with the replacement's contract documented before the borrowed file leaves.
|
||||
|
||||
## What the active shell has today
|
||||
|
||||
The phone lock has a credential keypad, fingerprint attempt, battery state,
|
||||
and guarded power actions. It does **not** yet have lockscreen media,
|
||||
notifications, timers, weather, calls, agent output policy, a staged idle
|
||||
coordinator, or a real sleep-inhibitor backend. `Idle.qml` currently controls
|
||||
hypridle because the tested invisible Wayland idle inhibitor is not honoured
|
||||
on the Pixel 3 compositor build.
|
||||
|
||||
That is a sound small base. It is not yet a phone-grade lock surface.
|
||||
|
||||
## Ideas to extract
|
||||
|
||||
| Source | Keep | Do not inherit |
|
||||
| --- | --- | --- |
|
||||
| Sailfish/Lipstick lockscreen | A lock screen as layered/pannable content: glance surface, credential surface, and event surface. Wake/display-off are separate from lock. | Its device-specific Lipstick and DeviceLock plumbing; source provenance must be audited before copying. |
|
||||
| Glacier Home | A compact MPRIS card with capability-gated previous/play/next controls; media is loaded only if a player exists. Clock, media, keypad, and notifications are separate components. | Its older Nemo/Amber APIs and visual code verbatim unless copyright notices travel with it. |
|
||||
| DankMaterialShell | Independent idle monitors for dim/DPMS/lock/suspend, explicit re-arming, AC-vs-battery thresholds, and a wake monitor after screen-off. | Treating its shell state as authority or blindly importing its settings model. |
|
||||
| ii/iNiR | MPRIS card mechanics, downloaded artwork, palette adaptation, and image "least busy region" placement. ii already calculates content placement against wallpaper pixels. | The current one-file wallpaper assumption and arbitrary widget dimensions. |
|
||||
| Ambxst | Screen capture at lock, short-lived credential handling, failure lockout feedback, and listening to logind Lock/PrepareForSleep events. | Its AGPL code, polling `axctl` layer, and its separate lockscreen authority. Concepts only. |
|
||||
|
||||
DankMaterialShell and iNiR are MIT; Glacier carries MIT/BSD notices. Ambxst is
|
||||
AGPL and Vast is GPL. Do not copy from the latter two into this repository
|
||||
without intentionally accepting their licence obligations. Sailfish files in
|
||||
the local research extraction need per-file provenance review before reuse.
|
||||
|
||||
## One lock surface, composable cards
|
||||
|
||||
The lock surface should be a host with slots rather than a new shell for each
|
||||
device:
|
||||
|
||||
```
|
||||
WlSessionLockSurface
|
||||
└─ LockSurfaceHost
|
||||
├─ glance slot clock · date · battery · weather
|
||||
├─ continuity slot timer · ongoing call · navigation · media transport
|
||||
├─ event slot notification summaries / previews
|
||||
├─ credential slot PIN/password/fingerprint PAM conversation
|
||||
└─ ambient-agent slot explicitly safe question/answer cards
|
||||
```
|
||||
|
||||
The host chooses a layout profile:
|
||||
|
||||
| Profile | Layout |
|
||||
| --- | --- |
|
||||
| phone portrait | One vertical glance surface; a swipe/press brings up credentials; continuity cards occupy the lower third. |
|
||||
| laptop | Center credentials with a side or lower card rail; multi-monitor cards remain local to their screen. |
|
||||
| desktop | Center credentials and a sparse, large-screen card rail; no phone gesture assumptions. |
|
||||
|
||||
Each card declares a tier and a lock-surface visibility policy. This prevents
|
||||
an attractive card from silently becoming a data leak.
|
||||
|
||||
| Tier | Suitable cards | Rule |
|
||||
| --- | --- | --- |
|
||||
| ambient | clock, battery, weather, timer, transport buttons, an ongoing-call *indicator* | no user data beyond the explicitly permitted setting |
|
||||
| personal | track title/art, notification body, calendar title, agent conversation, call identity | hidden until unlock unless the user opts that field into ambient |
|
||||
| step-up | send/reply/delete/purchase/unlock a physical thing | never performed by a card tap alone; needs a recent step-up grant |
|
||||
|
||||
An ambient agent is not a diluted personal agent. It has a separate tool and
|
||||
memory allowlist, cannot read conversation history or sensors, and its output
|
||||
is labelled ambient. If the lock starts while a personal response streams, the
|
||||
host immediately withdraws it.
|
||||
|
||||
## Idle is a transition graph, not a timer
|
||||
|
||||
The useful DMS extraction is the vocabulary and re-arm discipline. The actual
|
||||
authority remains Wayland idle protocols plus logind:
|
||||
|
||||
```
|
||||
active ─idle timeout→ dimmed ─timeout→ lock-requested
|
||||
│
|
||||
WlSessionLock.secure
|
||||
↓
|
||||
lock-secure ─timeout→ display-off
|
||||
│ │
|
||||
PrepareForSleep(true) ─────────────────────────────────────┘
|
||||
↓
|
||||
suspending → asleep → waking → active
|
||||
```
|
||||
|
||||
Input can cancel dimming and display-off before lock. It cannot cancel an
|
||||
already secure lock; credentials do that. Every transition has an event source
|
||||
and an observable result. `screenLocked` is the request; `screenLockSecure` is
|
||||
the compositor acknowledgement.
|
||||
|
||||
The implementation sequence is:
|
||||
|
||||
1. Add one `IdleCoordinator` that owns the staged state and form-factor
|
||||
thresholds. It consumes native `IdleMonitor` events when verified on the
|
||||
target; the Pixel can retain the hypridle adapter behind that interface.
|
||||
2. Add a logind event watcher for this session's `Lock` and the manager's
|
||||
`PrepareForSleep` signal.
|
||||
3. Hold a delay-mode sleep inhibitor while the shell is running. Release it
|
||||
only after the Wayland lock is secure; re-acquire it on wake. Measure the
|
||||
actual logind delay budget on the Pixel 3.
|
||||
4. Make `idle`, `sleep`, `logout`, and `user-switch` explicit inhibitor kinds.
|
||||
Only expose a kind after it owns a real backend. `idle` is the sole
|
||||
implemented kind today.
|
||||
|
||||
## Wallpaper should be an asset, not a path
|
||||
|
||||
The current background path gives every output the same image and asks crop to
|
||||
solve incompatible aspect ratios. A downloaded landscape image consequently
|
||||
loses its subject on the phone. Preserve originals, but track a portable asset
|
||||
record:
|
||||
|
||||
```
|
||||
id, originalPath, origin, licence, palette,
|
||||
focalPoint(x,y), safeRegions[],
|
||||
variants: { landscape, portrait, square },
|
||||
previewPath, generatedAt
|
||||
```
|
||||
|
||||
Selection order for a screen is:
|
||||
|
||||
1. Use an explicit per-device assignment when the user set one.
|
||||
2. Select a sufficiently close aspect-ratio variant.
|
||||
3. Crop a derived local variant around `focalPoint` while preserving a
|
||||
`safeRegion`; never overwrite the original.
|
||||
4. Fall back to a blurred/palette background with the image contained, rather
|
||||
than crop the only meaningful part away.
|
||||
|
||||
ii's existing least-busy-region analysis is useful twice: place a clock/card
|
||||
where it does not cover the subject, and propose an initial safe region.
|
||||
However, it must run against the **actual output crop** and card dimensions,
|
||||
not a fixed 300×300 desktop widget. A manual focal-point editor is the escape
|
||||
hatch; an analyser may propose, never silently decide.
|
||||
|
||||
For the lock surface, prefer a frozen screencopy or an already-derived local
|
||||
variant over downloading or decoding a remote wallpaper at lock time. Locking
|
||||
must remain instant and work offline.
|
||||
|
||||
## Proposed Souveraine map
|
||||
|
||||
```
|
||||
modules/common/functions/Session.qml lifecycle verbs + IPC projection
|
||||
services/SessionEvents.qml logind Lock / PrepareForSleep ingress
|
||||
services/IdleCoordinator.qml transition graph + target adapters
|
||||
services/LockContentPolicy.qml card tier / field visibility decisions
|
||||
modules/ii/lock/LockSurfaceHost.qml shared card host
|
||||
modules/ii/lock/cards/ Clock · Media · Notifications · Continuity · AmbientAgent
|
||||
services/StepUpAuth.qml independent PamContext + expiring grants
|
||||
services/WallpaperAssets.qml asset records, variants, safe regions
|
||||
```
|
||||
|
||||
None of these are separate session managers. They are consumers of the single
|
||||
`session` lifecycle and its state projection.
|
||||
|
||||
## Build order
|
||||
|
||||
1. **Truth before visuals:** logind event ingress, delay inhibitor, and
|
||||
`IdleCoordinator` with a visible transition log.
|
||||
2. **First lock cards:** clock/date, battery, transport-only MPRIS. Add the
|
||||
metadata privacy switch before title/artist/artwork.
|
||||
3. **Continuity:** timers, ongoing call, navigation, and notification summary
|
||||
cards with per-field redaction.
|
||||
4. **Trust:** ambient-agent allowlist, lock-time response redaction, then
|
||||
`StepUpAuth` and action-family grants.
|
||||
5. **Wallpaper assets:** per-device assignments, focal-point editor, derived
|
||||
portrait/landscape variants, and crop-aware card placement.
|
||||
|
||||
The first useful vertical slice is therefore not a huge lockscreen redesign:
|
||||
one shared `LockSurfaceHost`, a transport-only media card, a real staged idle
|
||||
state projection, and a wallpaper asset descriptor. It proves the architecture
|
||||
on both shapes without committing personal data to the lock surface.
|
||||
101
surfaces/quickshell/SESSION-TRUST-ARCHITECTURE.md
Normal file
101
surfaces/quickshell/SESSION-TRUST-ARCHITECTURE.md
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
# Session and interaction trust model
|
||||
|
||||
This is the contract for the Souveraine shell on laptop and phone. It records
|
||||
the boundary between orchestration we own and the Linux/Wayland authorities we
|
||||
must consume rather than replace.
|
||||
|
||||
## Authorities and projections
|
||||
|
||||
| Question | Authority | Souveraine's role |
|
||||
| --- | --- | --- |
|
||||
| Can this machine suspend, hibernate, power off, or reboot? | logind `Can*` methods | Query and expose the answer; execute the matching logind/systemd verb. |
|
||||
| Has the compositor secured the display? | `WlSessionLock.secure` | Mirror it as `screenLockSecure`; never infer it from a button press. |
|
||||
| Does the shell want its lock surface shown? | `GlobalStates.screenLocked` | Maintain this request state and persist it across a shell crash. |
|
||||
| Has the user authenticated? | PAM and the lock's `LockContext` | Start a PAM conversation; only it may release `WlSessionLock`. |
|
||||
| Why is automatic idle sleep suppressed? | The active idle mechanism | Keep a reason/cookie registry, but report only mechanisms that are actually applied. |
|
||||
|
||||
`screenLocked` and `screenLockSecure` must stay distinct. The first closes
|
||||
ordinary shell surfaces immediately. The second is the only proof suitable for
|
||||
personal-data disclosure. A failed or delayed Wayland lock is therefore
|
||||
visible as `lockRequested: true, locked: false`, not mistaken for success.
|
||||
|
||||
## Session IPC
|
||||
|
||||
All structured IPC results are JSON strings. Quickshell IPC only marshals
|
||||
primitive QML types, so `: var` silently becomes `void` and loses its payload.
|
||||
|
||||
`session.lock()` means “the Wayland lock was requested”; it does not claim
|
||||
the compositor is secure yet. `session.state()` exposes the subsequent
|
||||
`locked` acknowledgement and the most recent asynchronous power-action result.
|
||||
Power verbs return `status: "started"` when their command has started; a later
|
||||
exit is available in `lastAction`. Refusals always log and return a reason.
|
||||
|
||||
The current public inhibitor surface implements `idle` only. It is intentionally
|
||||
wrong to accept `sleep`, `shutdown`, `logout`, or `user-switch` until each owns
|
||||
a real mechanism. The eventual contract is:
|
||||
|
||||
| Kind | Backend |
|
||||
| --- | --- |
|
||||
| `idle` | Native Wayland idle inhibitor once verified on both targets; until then the tested hypridle control path. |
|
||||
| `sleep` | A live logind/systemd inhibitor FD or `systemd-inhibit` process, held for the cookie lifetime. |
|
||||
| `logout`, `user-switch` | Shell policy checks at those operations, not fake logind inhibitors. |
|
||||
|
||||
Every inhibitor must have a non-empty human reason, a cookie, and a visible
|
||||
holder in `state()`. Releasing an unknown cookie is a refusal.
|
||||
|
||||
## Lock and suspend sequencing
|
||||
|
||||
The next lifecycle change is not another cached state machine. It is a logind
|
||||
event ingress:
|
||||
|
||||
1. Subscribe to the current logind session's `Lock` signal and request the
|
||||
Wayland lock on receipt. This makes external `loginctl lock-session` calls
|
||||
meaningful without assuming logind implements a screen locker.
|
||||
2. Hold one delay-mode sleep inhibitor from shell startup.
|
||||
3. On logind `PrepareForSleep(true)`, request the Wayland lock and wait for
|
||||
`WlSessionLock.secure`; release the delay inhibitor only then.
|
||||
4. Reacquire it after `PrepareForSleep(false)`.
|
||||
|
||||
The delay budget is logind's `InhibitDelayMaxUSec` (commonly five seconds), so
|
||||
this must be timed on the Pixel 3 before it becomes a security claim. The
|
||||
failure policy is fail closed: if the lock is not secure before the deadline,
|
||||
leave a diagnostic event and do not pretend the session was locked.
|
||||
|
||||
## Interaction capability tiers
|
||||
|
||||
The lock screen, media controls, and agent are one policy system, not three.
|
||||
Each operation and each response is labelled with a minimum tier:
|
||||
|
||||
| Tier | Requirement | Examples |
|
||||
| --- | --- | --- |
|
||||
| `ambient` | None | clock, weather, timers, transport controls, non-personal answers |
|
||||
| `personal` | No lock is requested and the compositor is not secure (`!screenLocked && !screenLockSecure`) | messages, calendar, memories, fleet state, conversation history |
|
||||
| `stepUp` | Recent successful reauthentication | sending, pushes, deletion, payments, physical access |
|
||||
|
||||
Media metadata is `ambient` by default but must be configurable to `personal`.
|
||||
When the lock occurs, in-flight personal or step-up agent output is withheld or
|
||||
redacted; only ambient output remains visible.
|
||||
|
||||
Step-up authentication is a separate `PamContext`, using a dedicated PAM
|
||||
service such as `souveraine-stepup`. It never unlocks the session and it never
|
||||
accepts a boolean from an agent as proof. A successful result mints a
|
||||
short-lived, in-memory grant bound to the local action family. The grant must
|
||||
be cleared on lock, session end, PAM failure, and expiry. The exact freshness
|
||||
window is a deliberate policy setting, not an implementation accident.
|
||||
|
||||
Do not ship that PAM service from the user shell: it is root-owned system
|
||||
configuration and must be installed/audited separately. Before enabling it,
|
||||
verify that one polkit agent owns the session and that no legacy desktop power
|
||||
manager is competing for idle or sleep policy.
|
||||
|
||||
## Required verification
|
||||
|
||||
- `session.capabilities()` matches logind `Can*` on laptop and phone.
|
||||
- `session.lock()` reaches `locked: true` only after `WlSessionLock.secure`.
|
||||
- `loginctl lock-session` and suspend both take the same Wayland lock path.
|
||||
- Unsupported inhibitors refuse; each supported cookie visibly changes its
|
||||
mechanism and is released exactly once.
|
||||
- A lock during personal agent output hides it; a lock during an ambient timer
|
||||
leaves only the timer visible.
|
||||
- Step-up grants cannot survive lock, timeout, shell restart, or action-family
|
||||
changes.
|
||||
|
|
@ -32,6 +32,9 @@ services/Ai.qml souveraine/services/Ai.qml
|
|||
services/TaskbarApps.qml souveraine/services/TaskbarApps.qml
|
||||
services/GlobalFocusGrab.qml souveraine/services/GlobalFocusGrab.qml
|
||||
services/Idle.qml souveraine/services/Idle.qml
|
||||
services/IdleCoordinator.qml souveraine/services/IdleCoordinator.qml
|
||||
services/LockContentPolicy.qml souveraine/services/LockContentPolicy.qml
|
||||
services/WallpaperAssets.qml souveraine/services/WallpaperAssets.qml
|
||||
services/ConflictKiller.qml souveraine/services/ConflictKiller.qml
|
||||
modules/ii/sidebarLeft/SidebarLeft.qml souveraine/modules/ii/sidebarLeft/SidebarLeft.qml
|
||||
modules/ii/sidebarLeft/AiChat.qml souveraine/modules/ii/sidebarLeft/AiChat.qml
|
||||
|
|
@ -44,7 +47,7 @@ modules/common/widgets/StyledToolTip.qml souveraine/modules/common/widgets/Style
|
|||
modules/settings/DeviceConfig.qml souveraine/modules/settings/DeviceConfig.qml
|
||||
modules/settings/LockConfig.qml souveraine/modules/settings/LockConfig.qml
|
||||
modules/settings/DockConfig.qml souveraine/modules/settings/DockConfig.qml
|
||||
modules/settings/PillConfig.qml souveraine/modules/settings/PillConfig.qml
|
||||
modules/settings/NavigationConfig.qml souveraine/modules/settings/NavigationConfig.qml
|
||||
modules/settings/KeyboardConfig.qml souveraine/modules/settings/KeyboardConfig.qml
|
||||
modules/ii/polkit/Polkit.qml souveraine/modules/ii/polkit/Polkit.qml
|
||||
modules/ii/dock/Dock.qml souveraine/modules/ii/dock/Dock.qml
|
||||
|
|
@ -62,7 +65,12 @@ modules/common/Persistent.qml souveraine/modules/common/Persistent.q
|
|||
modules/common/panels/lock/LockScreen.qml souveraine/modules/common/panels/lock/LockScreen.qml
|
||||
modules/ii/lock/Lock.qml souveraine/modules/ii/lock/Lock.qml
|
||||
modules/ii/lock/TouchLockSurface.qml souveraine/modules/ii/lock/TouchLockSurface.qml
|
||||
pill/shell.qml pill/shell.qml
|
||||
modules/souveraine/lock/LockMediaCard.qml souveraine/modules/souveraine/lock/LockMediaCard.qml
|
||||
modules/souveraine/lock/LockSurfaceHost.qml souveraine/modules/souveraine/lock/LockSurfaceHost.qml
|
||||
modules/souveraine/lock/qmldir souveraine/modules/souveraine/lock/qmldir
|
||||
modules/souveraine/navigation/SystemGestureRail.qml souveraine/modules/souveraine/navigation/SystemGestureRail.qml
|
||||
modules/souveraine/navigation/qmldir souveraine/modules/souveraine/navigation/qmldir
|
||||
modules/ii/sessionScreen/SessionScreen.qml souveraine/modules/ii/sessionScreen/SessionScreen.qml
|
||||
"
|
||||
|
||||
PHONE_USB=casey@172.16.42.1
|
||||
|
|
@ -82,8 +90,7 @@ 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"
|
||||
echo "Deployed to phone. If qsConfig is still 'ii' there, flip it:"
|
||||
echo " ssh: sed -i 's/hl.env(\"qsConfig\", \"ii\")/hl.env(\"qsConfig\", \"souveraine\")/' ~/.config/hypr/hyprland/variables.lua"
|
||||
echo "Deployed to phone. Hyprland starts qs -c souveraine; no secondary shell is required."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
|
|
|||
|
|
@ -249,6 +249,13 @@ Singleton {
|
|||
}
|
||||
property string wallpaperPath: ""
|
||||
property string thumbnailPath: ""
|
||||
// Optional local derivatives for unlike display shapes. The
|
||||
// original wallpaper remains canonical; variants are never a
|
||||
// destructive replacement for it.
|
||||
property string portraitVariantPath: ""
|
||||
property string landscapeVariantPath: ""
|
||||
property real wallpaperFocalX: 0.5
|
||||
property real wallpaperFocalY: 0.5
|
||||
property bool hideWhenFullscreen: true
|
||||
property JsonObject parallax: JsonObject {
|
||||
property bool vertical: false
|
||||
|
|
@ -391,10 +398,11 @@ Singleton {
|
|||
// Feel knobs (single source — components read these, never
|
||||
// hardcode): dragDwellMs = how long a drag hovers an icon
|
||||
// or stack before it reads as combine-intent (GNOME's
|
||||
// rule); pillStripHeight = bottom strip the dock reserves
|
||||
// for the gesture pill, visually and in its input mask.
|
||||
// rule); gestureRailHeight = bottom strip the dock reserves
|
||||
// for Souveraine's integrated navigation rail, visually and
|
||||
// in its input mask.
|
||||
property int dragDwellMs: 500
|
||||
property real pillStripHeight: 32
|
||||
property real gestureRailHeight: 32
|
||||
}
|
||||
|
||||
property JsonObject interactions: JsonObject {
|
||||
|
|
@ -448,6 +456,20 @@ Singleton {
|
|||
}
|
||||
property bool centerClock: true
|
||||
property bool showLockedText: true
|
||||
// Souveraine-owned lock cards. Transport controls are
|
||||
// ambient; media metadata remains personal by default.
|
||||
property JsonObject content: JsonObject {
|
||||
property bool showMediaControls: true
|
||||
property bool mediaMetadataAmbient: false
|
||||
property bool showBattery: true
|
||||
}
|
||||
property JsonObject idle: JsonObject {
|
||||
// Native idle-notify remains opt-in until verified on the
|
||||
// Pixel compositor; hypridle is the current adapter.
|
||||
property bool nativeCoordinatorEnabled: false
|
||||
property int dimAfterSeconds: 45
|
||||
property int lockAfterSeconds: 90
|
||||
}
|
||||
property JsonObject security: JsonObject {
|
||||
property bool unlockKeyring: true
|
||||
property bool requirePasswordToPower: false
|
||||
|
|
|
|||
|
|
@ -12,8 +12,9 @@
|
|||
// the session menus are unchanged) and adds the parts a real session arbiter
|
||||
// needs:
|
||||
//
|
||||
// 1. Capability detection. We probe loginctl/systemctl/hibernate ONCE at
|
||||
// startup instead of assuming `systemctl X || loginctl X` will work.
|
||||
// 1. Capability detection. We query logind's Can* methods once at startup
|
||||
// instead of treating a command being installed, or /sys/power/state
|
||||
// advertising "disk", as proof that an action is usable.
|
||||
// caps() reports what this machine can actually do, so a caller can ask
|
||||
// before it acts and the session menu can grey out what is unavailable.
|
||||
//
|
||||
|
|
@ -27,9 +28,9 @@
|
|||
// takes a reason, returns a cookie, and state() lists every holder. "Why
|
||||
// is the phone not sleeping" becomes a question with an answer.
|
||||
//
|
||||
// 4. State that is re-derived, not cached. locked reads GlobalStates (which
|
||||
// is bound to WlSessionLock) at call time; we never keep our own "I
|
||||
// locked it" bool that could drift from what the compositor actually did.
|
||||
// 4. State that is re-derived, not cached. `secure` is WlSessionLock's
|
||||
// compositor acknowledgement; it is distinct from `lockRequested`, the
|
||||
// shell input that asks WlSessionLock to lock.
|
||||
//
|
||||
// The trust boundary here is deliberately trivial and stated so it stays that
|
||||
// way: this surface is local, single-user, reachable only over quickshell's
|
||||
|
|
@ -55,19 +56,26 @@ Singleton {
|
|||
property bool probed: false
|
||||
property bool hasLoginctl: false
|
||||
property bool hasSystemctl: false
|
||||
property bool canHibernate: false
|
||||
property string suspendCapability: "unknown"
|
||||
property string hibernateCapability: "unknown"
|
||||
property string poweroffCapability: "unknown"
|
||||
property string rebootCapability: "unknown"
|
||||
|
||||
// logind is the preferred backend when present: it is the thing that
|
||||
// actually owns the session, and it works under elogind as well as
|
||||
// systemd. systemctl is the fallback for the poweroff/reboot verbs.
|
||||
readonly property bool canSuspend: root.hasLoginctl || root.hasSystemctl
|
||||
readonly property bool canPoweroff: root.hasLoginctl || root.hasSystemctl
|
||||
readonly property bool canReboot: root.hasLoginctl || root.hasSystemctl
|
||||
// "challenge" means logind can do it after polkit authentication. It is
|
||||
// available to a normal desktop session with a functioning polkit agent,
|
||||
// but callers still learn that a prompt may be required through caps().
|
||||
readonly property bool canSuspend: ["yes", "challenge"].includes(root.suspendCapability)
|
||||
readonly property bool canHibernate: ["yes", "challenge"].includes(root.hibernateCapability)
|
||||
readonly property bool canPoweroff: ["yes", "challenge"].includes(root.poweroffCapability)
|
||||
readonly property bool canReboot: ["yes", "challenge"].includes(root.rebootCapability)
|
||||
|
||||
// Live lock state. Read through, never stored: GlobalStates.screenLocked is
|
||||
// what WlSessionLock.locked is bound to, so this reports what the
|
||||
// compositor is actually doing rather than what we last asked it to do.
|
||||
readonly property bool locked: GlobalStates.screenLocked
|
||||
// Live compositor acknowledgement, mirrored from WlSessionLock.secure by
|
||||
// LockScreen.qml. `screenLocked` remains the requested state that drives
|
||||
// the lock surface; do not treat it as proof that the session is secure.
|
||||
readonly property bool locked: GlobalStates.screenLockSecure
|
||||
|
||||
signal actionFailed(string action, int exitCode)
|
||||
|
||||
|
|
@ -76,14 +84,27 @@ Singleton {
|
|||
// Runs at construction: the probe must land before anything asks
|
||||
// caps(), and every capability reads false until it does.
|
||||
running: true
|
||||
// One shell, one round trip. Prints three lines: loginctl, systemctl,
|
||||
// hibernate. /sys/power/state carries "disk" only when hibernation is
|
||||
// actually available on this kernel, which is the honest test — the
|
||||
// phone has no swap and cannot hibernate, and we must not offer it.
|
||||
// One shell, one round trip. logind's Can* methods incorporate the
|
||||
// policy and configuration that /sys/power/state cannot see (notably
|
||||
// swap/resume setup for hibernation). Possible values include yes,
|
||||
// no, challenge, and na; retain the value rather than flattening it.
|
||||
// busctl prints `s "challenge"`; awk pulls the second field verbatim
|
||||
// and the quotes come off in JS below. An earlier version parsed it
|
||||
// with sed inside single quotes, where sh does not process the \" and
|
||||
// sed ended up matching a literal backslash-quote that busctl never
|
||||
// emits — so on the phone the probe returned nothing and every
|
||||
// capability stuck at "unknown". Keep the shell here quote-free; do
|
||||
// the string work in QML where there is no second escaping layer.
|
||||
command: ["sh", "-c",
|
||||
"command -v loginctl >/dev/null && echo loginctl; " +
|
||||
"command -v systemctl >/dev/null && echo systemctl; " +
|
||||
"grep -qw disk /sys/power/state 2>/dev/null && echo hibernate; " +
|
||||
"if command -v busctl >/dev/null; then " +
|
||||
"for cap in CanSuspend CanHibernate CanPowerOff CanReboot; do " +
|
||||
"value=$(busctl --system call org.freedesktop.login1 /org/freedesktop/login1 " +
|
||||
"org.freedesktop.login1.Manager $cap 2>/dev/null | awk '{print $2}'); " +
|
||||
"[ -n \"$value\" ] && echo $cap=$value; " +
|
||||
"done; " +
|
||||
"fi; " +
|
||||
"true"]
|
||||
|
||||
stdout: StdioCollector {
|
||||
|
|
@ -91,12 +112,24 @@ Singleton {
|
|||
const lines = text.split("\n").map(l => l.trim());
|
||||
root.hasLoginctl = lines.includes("loginctl");
|
||||
root.hasSystemctl = lines.includes("systemctl");
|
||||
root.canHibernate = lines.includes("hibernate");
|
||||
const capability = (name) => {
|
||||
const prefix = name + "=";
|
||||
const line = lines.find(l => l.startsWith(prefix));
|
||||
// Value arrives quoted from busctl (e.g. "challenge").
|
||||
return line ? line.slice(prefix.length).replace(/"/g, "") : "unknown";
|
||||
};
|
||||
root.suspendCapability = capability("CanSuspend");
|
||||
root.hibernateCapability = capability("CanHibernate");
|
||||
root.poweroffCapability = capability("CanPowerOff");
|
||||
root.rebootCapability = capability("CanReboot");
|
||||
root.probed = true;
|
||||
console.log("[session] capabilities:",
|
||||
"loginctl=" + root.hasLoginctl,
|
||||
"systemctl=" + root.hasSystemctl,
|
||||
"hibernate=" + root.canHibernate);
|
||||
"suspend=" + root.suspendCapability,
|
||||
"hibernate=" + root.hibernateCapability,
|
||||
"poweroff=" + root.poweroffCapability,
|
||||
"reboot=" + root.rebootCapability);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -108,6 +141,11 @@ Singleton {
|
|||
id: verbProc
|
||||
property string verb: ""
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
root.lastAction = {
|
||||
action: verbProc.verb,
|
||||
status: exitCode === 0 ? "succeeded" : "failed",
|
||||
exitCode: exitCode
|
||||
};
|
||||
if (exitCode !== 0) {
|
||||
console.log(`[session] ${verbProc.verb} failed (exit ${exitCode})`);
|
||||
root.actionFailed(verbProc.verb, exitCode);
|
||||
|
|
@ -116,11 +154,22 @@ Singleton {
|
|||
}
|
||||
|
||||
function runVerb(verb, argv) {
|
||||
// Process has one command slot. Overwriting it while a prior action
|
||||
// is still running makes the eventual exit code belong to the wrong
|
||||
// action, which is another form of silent failure.
|
||||
if (verbProc.running) return false;
|
||||
verbProc.verb = verb;
|
||||
verbProc.command = argv;
|
||||
root.lastAction = { action: verb, status: "running", exitCode: null };
|
||||
verbProc.running = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
// IPC returns when an action is accepted, not when the kernel has already
|
||||
// suspended or powered off. This records the later Process outcome so a
|
||||
// caller can distinguish "started" from "succeeded".
|
||||
property var lastAction: ({ action: "", status: "idle", exitCode: null })
|
||||
|
||||
// Prefer logind (owns the session, works under elogind) and fall back to
|
||||
// systemctl. Returns [] when neither exists, which callers treat as a
|
||||
// refusal rather than firing a command that cannot work.
|
||||
|
|
@ -140,14 +189,22 @@ Singleton {
|
|||
readonly property bool inhibited: Object.keys(root.inhibitors).length > 0
|
||||
|
||||
function inhibit(what, reason) {
|
||||
if (!reason) return root.refuse("inhibit", "an inhibit must carry a reason");
|
||||
const kind = String(what || "idle").trim().toLowerCase();
|
||||
const why = String(reason || "").trim();
|
||||
if (!why) return root.refuse("inhibit", "an inhibit must carry a reason");
|
||||
// Only idle is wired today. Recording a sleep/logout/user-switch
|
||||
// inhibitor without applying its mechanism would create a dangerous
|
||||
// success-shaped no-op, so reject those until their real backends
|
||||
// (systemd-inhibit or session policy) land.
|
||||
if (kind !== "idle")
|
||||
return root.refuse("inhibit", `unsupported inhibit kind ${kind}; only idle is implemented`);
|
||||
const cookie = String(root.nextCookie++);
|
||||
// Reassign rather than mutate: QML only notifies on assignment, so an
|
||||
// in-place insert would leave `inhibited` and any binding on it stale.
|
||||
const next = Object.assign({}, root.inhibitors);
|
||||
next[cookie] = { what: what || "idle", reason: reason };
|
||||
next[cookie] = { what: kind, reason: why };
|
||||
root.inhibitors = next;
|
||||
console.log(`[session] inhibit ${cookie}: ${what || "idle"} — ${reason}`);
|
||||
console.log(`[session] inhibit ${cookie}: ${kind} — ${why}`);
|
||||
root.applyIdleInhibit();
|
||||
return { ok: true, cookie: cookie };
|
||||
}
|
||||
|
|
@ -182,8 +239,14 @@ Singleton {
|
|||
}));
|
||||
return {
|
||||
locked: root.locked,
|
||||
lockRequested: GlobalStates.screenLocked,
|
||||
idle: {
|
||||
stage: IdleCoordinator.state,
|
||||
nativeCoordinatorEnabled: IdleCoordinator.nativeEnabled
|
||||
},
|
||||
idleInhibited: Idle.inhibit,
|
||||
inhibitors: holders,
|
||||
lastAction: root.lastAction,
|
||||
capabilities: root.caps()
|
||||
};
|
||||
}
|
||||
|
|
@ -197,9 +260,14 @@ Singleton {
|
|||
return {
|
||||
probed: root.probed,
|
||||
suspend: root.canSuspend,
|
||||
suspendStatus: root.suspendCapability,
|
||||
hibernate: root.canHibernate,
|
||||
hibernateStatus: root.hibernateCapability,
|
||||
poweroff: root.canPoweroff,
|
||||
reboot: root.canReboot
|
||||
poweroffStatus: root.poweroffCapability,
|
||||
reboot: root.canReboot,
|
||||
rebootStatus: root.rebootCapability,
|
||||
inhibitors: ["idle"]
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -234,17 +302,18 @@ Singleton {
|
|||
}
|
||||
|
||||
function lock() {
|
||||
// loginctl lock-session is the right door: it tells logind, which
|
||||
// signals the session, which raises our WlSessionLock. Setting
|
||||
// GlobalStates directly would lock the surface without logind ever
|
||||
// knowing the session was locked.
|
||||
if (root.hasLoginctl) {
|
||||
root.runVerb("lock", ["loginctl", "lock-session"]);
|
||||
return { ok: true };
|
||||
}
|
||||
// No logind: fall back to raising the lock ourselves. Still a real
|
||||
// WlSessionLock, just without logind's knowledge of it.
|
||||
// Raise our Wayland lock ourselves: logind's Lock signal is a request
|
||||
// for session software to lock, not a Wayland lock implementation.
|
||||
// We also notify logind when it is available so other consumers see
|
||||
// the standard session event. The safe lock does not depend on that
|
||||
// asynchronous notification returning successfully.
|
||||
GlobalStates.screenLocked = true;
|
||||
if (root.hasLoginctl) {
|
||||
const notified = root.runVerb("lock", ["loginctl", "lock-session"]);
|
||||
return notified
|
||||
? { ok: true, status: "requested" }
|
||||
: { ok: true, status: "requested", degraded: "logind notification skipped; another action is running" };
|
||||
}
|
||||
return { ok: true, degraded: "no loginctl; locked without logind" };
|
||||
}
|
||||
|
||||
|
|
@ -260,43 +329,48 @@ Singleton {
|
|||
if (!root.probed) return root.refuse("suspend", "capabilities not probed yet");
|
||||
if (!root.canSuspend) return root.refuse("suspend", "no loginctl or systemctl on this machine");
|
||||
pauseAllPlayers();
|
||||
root.runVerb("suspend", root.powerCommand("suspend"));
|
||||
return { ok: true };
|
||||
if (!root.runVerb("suspend", root.powerCommand("suspend")))
|
||||
return root.refuse("suspend", "another session action is still running");
|
||||
return { ok: true, status: "started" };
|
||||
}
|
||||
|
||||
function hibernate() {
|
||||
if (!root.probed) return root.refuse("hibernate", "capabilities not probed yet");
|
||||
// The phone has no swap: /sys/power/state carries no "disk", so this
|
||||
// refuses instead of firing a hibernate that would quietly do nothing.
|
||||
// logind validates swap/resume configuration as well as kernel support,
|
||||
// so a phone without hibernation refuses instead of firing a no-op.
|
||||
if (!root.canHibernate)
|
||||
return root.refuse("hibernate", "no hibernate support (no disk in /sys/power/state)");
|
||||
return root.refuse("hibernate", "hibernate unavailable (logind: " + root.hibernateCapability + ")");
|
||||
pauseAllPlayers();
|
||||
root.runVerb("hibernate", root.powerCommand("hibernate"));
|
||||
return { ok: true };
|
||||
if (!root.runVerb("hibernate", root.powerCommand("hibernate")))
|
||||
return root.refuse("hibernate", "another session action is still running");
|
||||
return { ok: true, status: "started" };
|
||||
}
|
||||
|
||||
function poweroff() {
|
||||
if (!root.probed) return root.refuse("poweroff", "capabilities not probed yet");
|
||||
if (!root.canPoweroff) return root.refuse("poweroff", "no loginctl or systemctl on this machine");
|
||||
closeAllWindows();
|
||||
root.runVerb("poweroff", root.powerCommand("poweroff"));
|
||||
return { ok: true };
|
||||
if (!root.runVerb("poweroff", root.powerCommand("poweroff")))
|
||||
return root.refuse("poweroff", "another session action is still running");
|
||||
return { ok: true, status: "started" };
|
||||
}
|
||||
|
||||
function reboot() {
|
||||
if (!root.probed) return root.refuse("reboot", "capabilities not probed yet");
|
||||
if (!root.canReboot) return root.refuse("reboot", "no loginctl or systemctl on this machine");
|
||||
closeAllWindows();
|
||||
root.runVerb("reboot", root.powerCommand("reboot"));
|
||||
return { ok: true };
|
||||
if (!root.runVerb("reboot", root.powerCommand("reboot")))
|
||||
return root.refuse("reboot", "another session action is still running");
|
||||
return { ok: true, status: "started" };
|
||||
}
|
||||
|
||||
function rebootToFirmware() {
|
||||
if (!root.hasSystemctl)
|
||||
return root.refuse("rebootToFirmware", "firmware-setup reboot needs systemctl");
|
||||
closeAllWindows();
|
||||
root.runVerb("rebootToFirmware", ["systemctl", "reboot", "--firmware-setup"]);
|
||||
return { ok: true };
|
||||
if (!root.runVerb("rebootToFirmware", ["systemctl", "reboot", "--firmware-setup"]))
|
||||
return root.refuse("rebootToFirmware", "another session action is still running");
|
||||
return { ok: true, status: "started" };
|
||||
}
|
||||
|
||||
function logout() {
|
||||
|
|
@ -305,11 +379,13 @@ Singleton {
|
|||
// down the scope and the seat); pkill Hyprland just kills the
|
||||
// compositor and leaves logind believing the session is alive.
|
||||
if (root.hasLoginctl) {
|
||||
root.runVerb("logout", ["loginctl", "terminate-session", ""]);
|
||||
return { ok: true };
|
||||
if (!root.runVerb("logout", ["loginctl", "terminate-session", ""]))
|
||||
return root.refuse("logout", "another session action is still running");
|
||||
return { ok: true, status: "started" };
|
||||
}
|
||||
root.runVerb("logout", ["pkill", "-i", "Hyprland"]);
|
||||
return { ok: true, degraded: "no loginctl; killed the compositor" };
|
||||
if (!root.runVerb("logout", ["pkill", "-i", "Hyprland"]))
|
||||
return root.refuse("logout", "another session action is still running");
|
||||
return { ok: true, status: "started", degraded: "no loginctl; killed the compositor" };
|
||||
}
|
||||
|
||||
function changePassword() {
|
||||
|
|
|
|||
|
|
@ -112,6 +112,15 @@ Scope {
|
|||
id: lock
|
||||
locked: GlobalStates.screenLocked
|
||||
surface: root.sessionLockSurface
|
||||
|
||||
// This is the compositor acknowledgement, not our requested bool.
|
||||
// Keep it separate so an IPC caller cannot mistake a queued lock for
|
||||
// a secure surface when it is deciding whether personal content may
|
||||
// be exposed.
|
||||
onSecureChanged: {
|
||||
GlobalStates.screenLockSecure = secure;
|
||||
console.log("[lock] session lock secure=" + secure);
|
||||
}
|
||||
}
|
||||
|
||||
function lock() {
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@
|
|||
// keyboard-side math account for wherever the dock happens to be.
|
||||
//
|
||||
// GlobalStates.oskOpen suppresses both reveal-when-idle and the pinned
|
||||
// exclusive zone. In fullscreen the pill explicitly reveals or hides the
|
||||
// dock; it never times out or opens another surface.
|
||||
// exclusive zone. In fullscreen Souveraine's navigation rail explicitly
|
||||
// reveals or hides the dock; it never times out or opens another surface.
|
||||
import qs
|
||||
import qs.services
|
||||
import qs.modules.common
|
||||
|
|
@ -41,7 +41,7 @@ Scope { // Scope
|
|||
|
||||
// The normal dock pin is suppressed while the OSK is open. Fullscreen
|
||||
// always takes precedence, so fullscreen remains genuinely edge-to-edge
|
||||
// until the pill explicitly reveals the dock.
|
||||
// until the navigation rail explicitly reveals the dock.
|
||||
property bool effectivePinned: root.pinned && !GlobalStates.oskOpen
|
||||
|
||||
// The dock's state projection + guarded mutation surface for external
|
||||
|
|
@ -79,7 +79,7 @@ Scope { // Scope
|
|||
|
||||
property int dockState: computeDockState()
|
||||
|
||||
// The pill's dock contract is intentionally just two operations:
|
||||
// The navigation rail's dock contract is intentionally just two operations:
|
||||
// swipe up reveals the dock; swipe down hides it. No timer, no overview.
|
||||
// The manifest + guarded pin/stack methods below extend the same target
|
||||
// so the agent (via Souveraine's harness, not a new integration) reaches
|
||||
|
|
@ -100,10 +100,10 @@ Scope { // Scope
|
|||
GlobalStates.dockRevealed = true;
|
||||
}
|
||||
|
||||
// Toggle app-mode fullscreen on the REAL active window. The pill
|
||||
// can't use a bare `hyprctl dispatch fullscreen` because tapping the
|
||||
// pill makes the shell (org.quickshell) the focused surface, so
|
||||
// hyprctl would fullscreen the pill, not the app. The mechanism is
|
||||
// Toggle app-mode fullscreen on the REAL active window. The rail
|
||||
// can't use a bare `hyprctl dispatch fullscreen` because tapping it
|
||||
// makes the shell (org.quickshell) the focused surface, so hyprctl
|
||||
// would fullscreen the rail, not the app. The mechanism is
|
||||
// the one in the code below: Hyprland.activeToplevel stays the real
|
||||
// app window across a layer-shell tap, and we dispatch AT its
|
||||
// address. (An earlier draft went through ToplevelManager +
|
||||
|
|
@ -114,7 +114,7 @@ Scope { // Scope
|
|||
// Clear a revealed dock before either direction of the toggle.
|
||||
GlobalStates.dockRevealed = false;
|
||||
// Hyprland.activeToplevel is Hyprland's real active APP window and
|
||||
// its .address is a stable window handle — a layer-shell pill tap
|
||||
// its .address is a stable window handle — a layer-shell rail tap
|
||||
// never becomes a Hyprland toplevel, so this stays the app even
|
||||
// after the tap focuses the shell. Dispatch AT that address so we
|
||||
// fullscreen the app, not whatever hyprctl thinks is focused.
|
||||
|
|
@ -228,10 +228,10 @@ Scope { // Scope
|
|||
// keeps the strip alive for desktop pointer use.
|
||||
property bool reveal: root.dockState !== Dock.DockState.Hidden
|
||||
|| (Config.options?.dock.hoverToReveal && dockMouseArea.containsMouse)
|
||||
// This space belongs to the always-on pill. It is visually empty
|
||||
// and must also be absent from the dock's *input* region; otherwise
|
||||
// the dock receives touches before the pill can see them.
|
||||
readonly property int pillStripHeight: Config.options?.dock.pillStripHeight ?? 32
|
||||
// This space belongs to the always-on navigation rail. It is
|
||||
// visually empty and must also be absent from the dock's *input*
|
||||
// region; otherwise the dock receives touches before the rail.
|
||||
readonly property int gestureRailHeight: Config.options?.dock.gestureRailHeight ?? 32
|
||||
visible: !GlobalStates.screenLocked && reveal
|
||||
|
||||
anchors {
|
||||
|
|
@ -251,7 +251,7 @@ Scope { // Scope
|
|||
color: "transparent"
|
||||
|
||||
// Content-driven: tall enough for one 64px dock button + the
|
||||
// row's 8px bottom margin + the 32px pill strip, with Config
|
||||
// row's 8px bottom margin + the navigation rail, with Config
|
||||
// dock.height as a floor. A fixed config height (72) left the
|
||||
// visible bar ~36px for 64px buttons — icons poked out the
|
||||
// bottom and count dots landed under the bar. Size off the
|
||||
|
|
@ -260,7 +260,7 @@ Scope { // Scope
|
|||
// made the bar overshoot (content then top-aligned with a dead
|
||||
// band underneath).
|
||||
implicitHeight: Math.max(Config.options?.dock.height ?? 70,
|
||||
overviewButton.implicitHeight + 8 + pillStripHeight)
|
||||
overviewButton.implicitHeight + 8 + gestureRailHeight)
|
||||
+ Appearance.sizes.elevationMargin + Appearance.sizes.hyprlandGapsOut
|
||||
|
||||
mask: Region {
|
||||
|
|
@ -275,7 +275,7 @@ Scope { // Scope
|
|||
anchors.top: parent.top
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
width: dockMouseArea.width
|
||||
height: Math.max(0, dockMouseArea.height - dockRoot.pillStripHeight)
|
||||
height: Math.max(0, dockMouseArea.height - dockRoot.gestureRailHeight)
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
|
|
@ -302,16 +302,15 @@ Scope { // Scope
|
|||
id: dockBackground
|
||||
anchors {
|
||||
top: parent.top
|
||||
// Reserve the pill strip: bottom-anchor short of
|
||||
// the window's bottom by thePillZone so the dock
|
||||
// floats above the pill and never covers it.
|
||||
// Reserve the rail: bottom-anchor short of the
|
||||
// window's bottom so the dock never covers it.
|
||||
bottom: parent.bottom
|
||||
bottomMargin: dockRoot.pillStripHeight
|
||||
bottomMargin: dockRoot.gestureRailHeight
|
||||
horizontalCenter: parent.horizontalCenter
|
||||
}
|
||||
|
||||
implicitWidth: dockRow.implicitWidth + 5 * 2
|
||||
height: parent.height - Appearance.sizes.elevationMargin - Appearance.sizes.hyprlandGapsOut - dockRoot.pillStripHeight
|
||||
height: parent.height - Appearance.sizes.elevationMargin - Appearance.sizes.hyprlandGapsOut - dockRoot.gestureRailHeight
|
||||
|
||||
StyledRectangularShadow {
|
||||
target: dockVisualBackground
|
||||
|
|
|
|||
|
|
@ -31,12 +31,11 @@ LockScreen {
|
|||
// shell.* and apps.*. `lock` (target: "lock") stays as it was: it is the
|
||||
// surface's own activate/focus pair, not the session lifecycle.
|
||||
//
|
||||
// Named "sessionctl", not "session": ii's SessionScreen already owns the
|
||||
// "session" target (its toggle/open/close for the power menu). Quickshell
|
||||
// does not merge duplicate IPC targets — the second one is silently
|
||||
// dropped, with no error — so a collision here would have quietly produced
|
||||
// a surface that simply is not there. The menu is "session"; the session
|
||||
// lifecycle is "sessionctl".
|
||||
// `session` is the one lifecycle authority across phone, laptop, and
|
||||
// desktop. The visual power-menu toggles live at `sessionMenu`; a menu is
|
||||
// a presentation concern, whereas this target is the system contract.
|
||||
// The ii screen is vendored with that one target rename so Quickshell
|
||||
// never has to silently choose between duplicate `session` handlers.
|
||||
// Every method returns `string`, not `var`, and the payload is JSON.
|
||||
// This is not a style choice — quickshell marshals exactly five types over
|
||||
// IPC (string, int, bool, double, color; see src/io/ipc.cpp ipcType()) and
|
||||
|
|
@ -45,7 +44,7 @@ LockScreen {
|
|||
// `(): void`, and silently returns nothing to the caller. JSON-over-string
|
||||
// is the only way a structured {ok, reason} result actually crosses.
|
||||
IpcHandler {
|
||||
target: "sessionctl"
|
||||
target: "session"
|
||||
|
||||
// Read-only projection: lock state (read through from the compositor,
|
||||
// never a cached bool), idle inhibitors with their reasons, and what
|
||||
|
|
|
|||
|
|
@ -4,8 +4,9 @@
|
|||
// the OSK is a layershell surface and can never appear above the session
|
||||
// lock, so the lock surface must carry its own input. Auth goes through the
|
||||
// same LockContext/PAM machinery as the desktop surface; hardware keyboards
|
||||
// still work via the Keys handlers. The big clock comes from Background.qml
|
||||
// (Overlay layer while locked), so none is drawn here.
|
||||
// still work via the Keys handlers. Ambient glance content is supplied by the
|
||||
// Souveraine-owned LockSurfaceHost; ii Background remains only a temporary
|
||||
// compatibility layer while the shell is progressively brought in-tree.
|
||||
//
|
||||
// Sized for 540x1080 logical (1080x2160 @ scale 2).
|
||||
import QtQuick
|
||||
|
|
@ -16,6 +17,7 @@ import qs.modules.common
|
|||
import qs.modules.common.widgets
|
||||
import qs.modules.common.functions
|
||||
import qs.modules.common.panels.lock
|
||||
import qs.modules.souveraine.lock
|
||||
import Quickshell
|
||||
import Quickshell.Services.UPower
|
||||
|
||||
|
|
@ -39,6 +41,19 @@ MouseArea {
|
|||
Component.onCompleted: forceFieldFocus()
|
||||
onPressed: forceFieldFocus()
|
||||
|
||||
// Souveraine-owned ambient content. Credentials stay below this host, so
|
||||
// phone, laptop, and desktop can rearrange the same cards later without
|
||||
// inventing separate lock/session behavior.
|
||||
LockSurfaceHost {
|
||||
anchors {
|
||||
top: parent.top
|
||||
topMargin: 56
|
||||
horizontalCenter: parent.horizontalCenter
|
||||
}
|
||||
width: Math.max(0, parent.width - 40)
|
||||
z: 1
|
||||
}
|
||||
|
||||
function pressDigit(d) {
|
||||
root.context.resetClearTimer();
|
||||
root.context.currentText += d;
|
||||
|
|
@ -67,6 +82,7 @@ MouseArea {
|
|||
bottom: parent.bottom
|
||||
bottomMargin: 48
|
||||
}
|
||||
z: 2
|
||||
spacing: 20
|
||||
|
||||
// Entered-PIN dots, with the empty-state hint behind them.
|
||||
|
|
|
|||
338
surfaces/quickshell/modules/ii/sessionScreen/SessionScreen.qml
Normal file
338
surfaces/quickshell/modules/ii/sessionScreen/SessionScreen.qml
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
import qs
|
||||
import qs.services
|
||||
import qs.modules.common
|
||||
import qs.modules.common.widgets
|
||||
import qs.modules.common.functions
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Wayland
|
||||
import Quickshell.Hyprland
|
||||
|
||||
Scope {
|
||||
id: root
|
||||
property var focusedScreen: Quickshell.screens.find(s => s.name === Hyprland.focusedMonitor?.name)
|
||||
|
||||
Loader {
|
||||
id: sessionLoader
|
||||
active: GlobalStates.sessionOpen
|
||||
onActiveChanged: {
|
||||
if (sessionLoader.active)
|
||||
SessionWarnings.refresh();
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: GlobalStates
|
||||
function onScreenLockedChanged() {
|
||||
if (GlobalStates.screenLocked) {
|
||||
GlobalStates.sessionOpen = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sourceComponent: PanelWindow { // Session menu
|
||||
id: sessionRoot
|
||||
visible: sessionLoader.active
|
||||
property string subtitle
|
||||
|
||||
function hide() {
|
||||
GlobalStates.sessionOpen = false;
|
||||
}
|
||||
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
WlrLayershell.namespace: "quickshell:session"
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
|
||||
color: ColorUtils.transparentize(Appearance.m3colors.m3background, Appearance.m3colors.darkmode ? 0.05 : 0.12)
|
||||
|
||||
anchors {
|
||||
top: true
|
||||
left: true
|
||||
right: true
|
||||
}
|
||||
|
||||
implicitWidth: root.focusedScreen?.width ?? 0
|
||||
implicitHeight: root.focusedScreen?.height ?? 0
|
||||
|
||||
MouseArea {
|
||||
id: sessionMouseArea
|
||||
anchors.fill: parent
|
||||
onClicked: {
|
||||
sessionRoot.hide();
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout { // Content column
|
||||
id: contentColumn
|
||||
anchors.centerIn: parent
|
||||
spacing: 15
|
||||
|
||||
Keys.onPressed: event => {
|
||||
if (event.key === Qt.Key_Escape) {
|
||||
sessionRoot.hide();
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
spacing: 0
|
||||
StyledText {
|
||||
// Title
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
font {
|
||||
family: Appearance.font.family.title
|
||||
pixelSize: Appearance.font.pixelSize.title
|
||||
variableAxes: Appearance.font.variableAxes.title
|
||||
}
|
||||
text: Translation.tr("Session")
|
||||
}
|
||||
|
||||
StyledText {
|
||||
// Small instruction
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
font.pixelSize: Appearance.font.pixelSize.normal
|
||||
text: Translation.tr("Arrow keys to navigate, Enter to select\nEsc or click anywhere to cancel")
|
||||
}
|
||||
}
|
||||
|
||||
GridLayout {
|
||||
columns: 4
|
||||
columnSpacing: 15
|
||||
rowSpacing: 15
|
||||
|
||||
SessionActionButton {
|
||||
id: sessionLock
|
||||
focus: sessionRoot.visible
|
||||
buttonIcon: "lock"
|
||||
buttonText: Translation.tr("Lock")
|
||||
onClicked: {
|
||||
Session.lock();
|
||||
sessionRoot.hide();
|
||||
}
|
||||
onFocusChanged: {
|
||||
if (focus)
|
||||
sessionRoot.subtitle = buttonText;
|
||||
}
|
||||
KeyNavigation.right: sessionSleep
|
||||
KeyNavigation.down: sessionHibernate
|
||||
}
|
||||
SessionActionButton {
|
||||
id: sessionSleep
|
||||
buttonIcon: "dark_mode"
|
||||
buttonText: Translation.tr("Sleep")
|
||||
onClicked: {
|
||||
Session.suspend();
|
||||
sessionRoot.hide();
|
||||
}
|
||||
onFocusChanged: {
|
||||
if (focus)
|
||||
sessionRoot.subtitle = buttonText;
|
||||
}
|
||||
KeyNavigation.left: sessionLock
|
||||
KeyNavigation.right: sessionLogout
|
||||
KeyNavigation.down: sessionShutdown
|
||||
}
|
||||
SessionActionButton {
|
||||
id: sessionLogout
|
||||
buttonIcon: "logout"
|
||||
buttonText: Translation.tr("Logout")
|
||||
onClicked: {
|
||||
Session.logout();
|
||||
sessionRoot.hide();
|
||||
}
|
||||
onFocusChanged: {
|
||||
if (focus)
|
||||
sessionRoot.subtitle = buttonText;
|
||||
}
|
||||
KeyNavigation.left: sessionSleep
|
||||
KeyNavigation.right: sessionTaskManager
|
||||
KeyNavigation.down: sessionReboot
|
||||
}
|
||||
SessionActionButton {
|
||||
id: sessionTaskManager
|
||||
buttonIcon: "browse_activity"
|
||||
buttonText: Translation.tr("Task Manager")
|
||||
onClicked: {
|
||||
Session.launchTaskManager();
|
||||
sessionRoot.hide();
|
||||
}
|
||||
onFocusChanged: {
|
||||
if (focus)
|
||||
sessionRoot.subtitle = buttonText;
|
||||
}
|
||||
KeyNavigation.left: sessionLogout
|
||||
KeyNavigation.down: sessionFirmwareReboot
|
||||
}
|
||||
|
||||
SessionActionButton {
|
||||
id: sessionHibernate
|
||||
buttonIcon: "downloading"
|
||||
buttonText: Translation.tr("Hibernate")
|
||||
onClicked: {
|
||||
Session.hibernate();
|
||||
sessionRoot.hide();
|
||||
}
|
||||
onFocusChanged: {
|
||||
if (focus)
|
||||
sessionRoot.subtitle = buttonText;
|
||||
}
|
||||
KeyNavigation.up: sessionLock
|
||||
KeyNavigation.right: sessionShutdown
|
||||
}
|
||||
SessionActionButton {
|
||||
id: sessionShutdown
|
||||
buttonIcon: "power_settings_new"
|
||||
buttonText: Translation.tr("Shutdown")
|
||||
onClicked: {
|
||||
Session.poweroff();
|
||||
sessionRoot.hide();
|
||||
}
|
||||
onFocusChanged: {
|
||||
if (focus)
|
||||
sessionRoot.subtitle = buttonText;
|
||||
}
|
||||
KeyNavigation.left: sessionHibernate
|
||||
KeyNavigation.right: sessionReboot
|
||||
KeyNavigation.up: sessionSleep
|
||||
}
|
||||
SessionActionButton {
|
||||
id: sessionReboot
|
||||
buttonIcon: "restart_alt"
|
||||
buttonText: Translation.tr("Reboot")
|
||||
onClicked: {
|
||||
Session.reboot();
|
||||
sessionRoot.hide();
|
||||
}
|
||||
onFocusChanged: {
|
||||
if (focus)
|
||||
sessionRoot.subtitle = buttonText;
|
||||
}
|
||||
KeyNavigation.left: sessionShutdown
|
||||
KeyNavigation.right: sessionFirmwareReboot
|
||||
KeyNavigation.up: sessionLogout
|
||||
}
|
||||
SessionActionButton {
|
||||
id: sessionFirmwareReboot
|
||||
buttonIcon: "settings_applications"
|
||||
buttonText: Translation.tr("Reboot to firmware settings")
|
||||
onClicked: {
|
||||
Session.rebootToFirmware();
|
||||
sessionRoot.hide();
|
||||
}
|
||||
onFocusChanged: {
|
||||
if (focus)
|
||||
sessionRoot.subtitle = buttonText;
|
||||
}
|
||||
KeyNavigation.up: sessionTaskManager
|
||||
KeyNavigation.left: sessionReboot
|
||||
}
|
||||
}
|
||||
|
||||
DescriptionLabel {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
text: sessionRoot.subtitle
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
anchors {
|
||||
top: contentColumn.bottom
|
||||
topMargin: 10
|
||||
horizontalCenter: contentColumn.horizontalCenter
|
||||
}
|
||||
spacing: 10
|
||||
|
||||
Loader {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
active: SessionWarnings.downloadRunning
|
||||
visible: active
|
||||
sourceComponent: DescriptionLabel {
|
||||
text: Translation.tr("There might be a download in progress. Check your Downloads folder.")
|
||||
textColor: Appearance.m3colors.m3onErrorContainer
|
||||
color: Appearance.m3colors.m3errorContainer
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
active: SessionWarnings.packageManagerRunning
|
||||
visible: active
|
||||
sourceComponent: DescriptionLabel {
|
||||
text: Translation.tr("Your package manager is running")
|
||||
textColor: Appearance.m3colors.m3onErrorContainer
|
||||
color: Appearance.m3colors.m3errorContainer
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component DescriptionLabel: Rectangle {
|
||||
id: descriptionLabel
|
||||
property string text
|
||||
property color textColor: Appearance.colors.colOnTooltip
|
||||
color: Appearance.colors.colTooltip
|
||||
clip: true
|
||||
radius: Appearance.rounding.normal
|
||||
implicitHeight: descriptionLabelText.implicitHeight + 10 * 2
|
||||
implicitWidth: descriptionLabelText.implicitWidth + 15 * 2
|
||||
|
||||
Behavior on implicitWidth {
|
||||
animation: Appearance.animation.elementMove.numberAnimation.createObject(this)
|
||||
}
|
||||
|
||||
StyledText {
|
||||
id: descriptionLabelText
|
||||
anchors.centerIn: parent
|
||||
color: descriptionLabel.textColor
|
||||
text: descriptionLabel.text
|
||||
}
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "sessionMenu"
|
||||
|
||||
function toggle(): void {
|
||||
GlobalStates.sessionOpen = !GlobalStates.sessionOpen;
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
GlobalStates.sessionOpen = false;
|
||||
}
|
||||
|
||||
function open(): void {
|
||||
GlobalStates.sessionOpen = true;
|
||||
}
|
||||
}
|
||||
|
||||
GlobalShortcut {
|
||||
name: "sessionToggle"
|
||||
description: "Toggles session screen on press"
|
||||
|
||||
onPressed: {
|
||||
GlobalStates.sessionOpen = !GlobalStates.sessionOpen;
|
||||
}
|
||||
}
|
||||
|
||||
GlobalShortcut {
|
||||
name: "sessionOpen"
|
||||
description: "Opens session screen on press"
|
||||
|
||||
onPressed: {
|
||||
GlobalStates.sessionOpen = true;
|
||||
}
|
||||
}
|
||||
|
||||
GlobalShortcut {
|
||||
name: "sessionClose"
|
||||
description: "Closes session screen on press"
|
||||
|
||||
onPressed: {
|
||||
GlobalStates.sessionOpen = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -104,4 +104,37 @@ ContentPage {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
ContentSection {
|
||||
icon: "visibility"
|
||||
title: Translation.tr("Lock screen content")
|
||||
|
||||
ConfigSwitch {
|
||||
buttonIcon: "music_note"
|
||||
text: Translation.tr("Show media controls")
|
||||
checked: Config.options.lock.content.showMediaControls
|
||||
onCheckedChanged: Config.options.lock.content.showMediaControls = checked
|
||||
StyledToolTip {
|
||||
text: Translation.tr("Shows previous, play/pause, and next. Track details remain private unless enabled below.")
|
||||
}
|
||||
}
|
||||
|
||||
ConfigSwitch {
|
||||
buttonIcon: "visibility"
|
||||
text: Translation.tr("Show media title and artist")
|
||||
checked: Config.options.lock.content.mediaMetadataAmbient
|
||||
enabled: Config.options.lock.content.showMediaControls
|
||||
onCheckedChanged: Config.options.lock.content.mediaMetadataAmbient = checked
|
||||
StyledToolTip {
|
||||
text: Translation.tr("Treats current media metadata as ambient. Leave off to keep it hidden until unlock.")
|
||||
}
|
||||
}
|
||||
|
||||
ConfigSwitch {
|
||||
buttonIcon: "battery_android_full"
|
||||
text: Translation.tr("Show battery on lock screen")
|
||||
checked: Config.options.lock.content.showBattery
|
||||
onCheckedChanged: Config.options.lock.content.showBattery = checked
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
42
surfaces/quickshell/modules/settings/NavigationConfig.qml
Normal file
42
surfaces/quickshell/modules/settings/NavigationConfig.qml
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import qs.services
|
||||
import qs.modules.common
|
||||
import qs.modules.common.widgets
|
||||
|
||||
// Souveraine's integrated phone navigation surface.
|
||||
ContentPage {
|
||||
forceWidth: true
|
||||
|
||||
ContentSection {
|
||||
icon: "gesture"
|
||||
title: Translation.tr("Layout")
|
||||
|
||||
ConfigSpinBox {
|
||||
icon: "swap_vert"
|
||||
text: Translation.tr("Navigation rail height (px)")
|
||||
value: Config.options.dock.gestureRailHeight
|
||||
from: 0
|
||||
to: 96
|
||||
stepSize: 2
|
||||
onValueChanged: Config.options.dock.gestureRailHeight = value
|
||||
|
||||
StyledToolTip {
|
||||
text: Translation.tr("Bottom strip reserved for Souveraine navigation — visually and in the dock's input mask. The navigation rail must always win touch here.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ContentSection {
|
||||
icon: "swipe"
|
||||
title: Translation.tr("Gestures")
|
||||
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
text: Translation.tr("Double-tap toggles app fullscreen; swipe up reveals the dock; swipe down hides it. Timing and threshold controls will appear here once their defaults prove out.")
|
||||
color: Appearance.colors.colSubtext
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import qs.services
|
||||
import qs.modules.common
|
||||
import qs.modules.common.widgets
|
||||
|
||||
// Pill — the always-on gesture bar. Its reserved strip is stored under
|
||||
// Config.options.dock (the dock subtracts it from its own input mask), but
|
||||
// it is the pill's knob, so it lives on the pill's page. Gesture tuning
|
||||
// (dwell, swipe thresholds, double-tap timing) lands here as the fixed
|
||||
// values in pill/shell.qml graduate into config.
|
||||
|
||||
ContentPage {
|
||||
forceWidth: true
|
||||
|
||||
ContentSection {
|
||||
icon: "gesture"
|
||||
title: Translation.tr("Layout")
|
||||
|
||||
ConfigSpinBox {
|
||||
icon: "swap_vert"
|
||||
text: Translation.tr("Pill strip height (px)")
|
||||
value: Config.options.dock.pillStripHeight
|
||||
from: 0
|
||||
to: 96
|
||||
stepSize: 2
|
||||
onValueChanged: {
|
||||
Config.options.dock.pillStripHeight = value;
|
||||
}
|
||||
StyledToolTip {
|
||||
text: Translation.tr("Bottom strip reserved for the gesture pill — visually and in the dock's input mask. The pill must always win touch here.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ContentSection {
|
||||
icon: "swipe"
|
||||
title: Translation.tr("Gestures")
|
||||
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
text: Translation.tr("Current gestures are fixed: double-tap toggles app fullscreen, swipe up reveals the dock. Timing and threshold knobs graduate here from pill/shell.qml as they prove out.")
|
||||
color: Appearance.colors.colSubtext
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
}
|
||||
121
surfaces/quickshell/modules/souveraine/lock/LockMediaCard.qml
Normal file
121
surfaces/quickshell/modules/souveraine/lock/LockMediaCard.qml
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
// Souveraine-owned ambient MPRIS card. Transport is safe while locked; title,
|
||||
// artist, and artwork are personal by default and require an explicit opt-in.
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell.Services.Mpris
|
||||
import qs.services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
function candidates() {
|
||||
return Mpris.players.values.filter(player =>
|
||||
player && (player.canTogglePlaying || player.canGoPrevious || player.canGoNext));
|
||||
}
|
||||
|
||||
readonly property var player: {
|
||||
const players = root.candidates();
|
||||
return players.find(player => player.isPlaying) || players[0] || null;
|
||||
}
|
||||
readonly property bool hasPlayer: root.player !== null
|
||||
readonly property bool revealMetadata: LockContentPolicy.mediaMetadataVisible
|
||||
|
||||
visible: LockContentPolicy.mediaControlsVisible && root.hasPlayer
|
||||
implicitHeight: visible ? card.implicitHeight : 0
|
||||
|
||||
Rectangle {
|
||||
id: card
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
width: parent.width
|
||||
implicitHeight: content.implicitHeight + 24
|
||||
radius: 20
|
||||
color: "#b3181b20"
|
||||
border.width: 1
|
||||
border.color: "#55ffffff"
|
||||
|
||||
ColumnLayout {
|
||||
id: content
|
||||
anchors.fill: parent
|
||||
anchors.margins: 12
|
||||
spacing: 8
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: root.revealMetadata
|
||||
text: root.player?.trackTitle || "Media"
|
||||
color: "white"
|
||||
font.pixelSize: 17
|
||||
font.bold: true
|
||||
elide: Text.ElideRight
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: root.revealMetadata && (root.player?.trackArtist || "").length > 0
|
||||
text: root.player?.trackArtist || ""
|
||||
color: "#d9ffffff"
|
||||
font.pixelSize: 13
|
||||
elide: Text.ElideRight
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: !root.revealMetadata
|
||||
text: "Media controls"
|
||||
color: "#d9ffffff"
|
||||
font.pixelSize: 13
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
spacing: 18
|
||||
|
||||
TransportButton {
|
||||
glyph: "‹‹"
|
||||
available: root.player?.canGoPrevious ?? false
|
||||
onClicked: root.player.previous()
|
||||
}
|
||||
TransportButton {
|
||||
glyph: root.player?.isPlaying ? "Ⅱ" : "▶"
|
||||
available: root.player?.canTogglePlaying ?? false
|
||||
primary: true
|
||||
onClicked: root.player.togglePlaying()
|
||||
}
|
||||
TransportButton {
|
||||
glyph: "››"
|
||||
available: root.player?.canGoNext ?? false
|
||||
onClicked: root.player.next()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component TransportButton: Rectangle {
|
||||
required property string glyph
|
||||
required property bool available
|
||||
property bool primary: false
|
||||
signal clicked()
|
||||
|
||||
implicitWidth: primary ? 50 : 40
|
||||
implicitHeight: primary ? 50 : 40
|
||||
radius: implicitWidth / 2
|
||||
color: primary ? "#e6ffffff" : "#26ffffff"
|
||||
opacity: available ? 1 : 0.35
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: parent.glyph
|
||||
color: parent.primary ? "#1c1b20" : "white"
|
||||
font.pixelSize: parent.primary ? 19 : 16
|
||||
font.bold: true
|
||||
}
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
enabled: parent.available
|
||||
onClicked: parent.clicked()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
// Shared lock-surface glance host. Credentials are supplied by the parent
|
||||
// lock surface; this component owns only ambient cards so every form factor
|
||||
// shares the same policy and data model.
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell.Services.UPower
|
||||
import qs.services
|
||||
import "."
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
readonly property bool compact: width < 700
|
||||
implicitHeight: content.implicitHeight
|
||||
|
||||
Timer {
|
||||
interval: 1000
|
||||
running: true
|
||||
repeat: true
|
||||
triggeredOnStart: true
|
||||
onTriggered: clock.now = new Date()
|
||||
}
|
||||
|
||||
QtObject {
|
||||
id: clock
|
||||
property date now: new Date()
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
id: content
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
width: Math.min(parent.width, root.compact ? 460 : 620)
|
||||
spacing: root.compact ? 10 : 16
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: Qt.formatTime(clock.now, "hh:mm")
|
||||
color: "white"
|
||||
font.pixelSize: root.compact ? 60 : 76
|
||||
font.weight: Font.Light
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: Qt.formatDate(clock.now, "dddd, MMMM d")
|
||||
color: "#d9ffffff"
|
||||
font.pixelSize: root.compact ? 17 : 21
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
visible: LockContentPolicy.batteryVisible && UPower.displayDevice?.isPresent
|
||||
text: {
|
||||
const pct = Math.round(UPower.displayDevice?.percentage ?? 0);
|
||||
return (UPower.onBattery ? "Battery " : "Charging ") + pct + "%";
|
||||
}
|
||||
color: "#d9ffffff"
|
||||
font.pixelSize: 14
|
||||
}
|
||||
|
||||
LockMediaCard {
|
||||
Layout.topMargin: root.compact ? 8 : 14
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
}
|
||||
3
surfaces/quickshell/modules/souveraine/lock/qmldir
Normal file
3
surfaces/quickshell/modules/souveraine/lock/qmldir
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module qs.modules.souveraine.lock
|
||||
LockMediaCard 1.0 LockMediaCard.qml
|
||||
LockSurfaceHost 1.0 LockSurfaceHost.qml
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
// Souveraine's phone navigation rail.
|
||||
//
|
||||
// This is deliberately part of the primary Souveraine shell, rather than a
|
||||
// second `qs -c …` configuration. It owns the small bottom input region that
|
||||
// stays available over fullscreen applications: swipe up/down controls the
|
||||
// dock and a double tap toggles fullscreen for the real active toplevel.
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Hyprland
|
||||
import Quickshell.Wayland
|
||||
import qs
|
||||
import qs.modules.common
|
||||
|
||||
PanelWindow {
|
||||
id: rail
|
||||
|
||||
anchors.bottom: true
|
||||
implicitWidth: 200
|
||||
implicitHeight: Config.options?.dock.gestureRailHeight ?? 32
|
||||
margins.bottom: 0
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
color: "transparent"
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
WlrLayershell.namespace: "souveraine:navigation-rail"
|
||||
|
||||
Rectangle {
|
||||
id: handle
|
||||
anchors.centerIn: parent
|
||||
width: 150
|
||||
height: 7
|
||||
radius: height / 2
|
||||
color: "#e6ffffff"
|
||||
|
||||
Behavior on width {
|
||||
NumberAnimation { duration: 120 }
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
property real startY: 0
|
||||
property real lastTapAt: -1
|
||||
readonly property int tapSlop: 24
|
||||
readonly property int swipeDistance: 48
|
||||
readonly property int doubleTapInterval: 350
|
||||
|
||||
function toggleFullscreen(): void {
|
||||
GlobalStates.dockRevealed = false
|
||||
const raw = Hyprland.activeToplevel?.address
|
||||
if (!raw)
|
||||
return
|
||||
const address = raw.startsWith("0x") ? raw : "0x" + raw
|
||||
Quickshell.execDetached(["hyprctl", "dispatch",
|
||||
`hl.dsp.window.fullscreen({ window = "address:${address}", mode = "fullscreen", action = "toggle" })`])
|
||||
}
|
||||
|
||||
onPressed: mouse => {
|
||||
startY = mouse.y
|
||||
handle.width = 170
|
||||
}
|
||||
|
||||
onReleased: mouse => {
|
||||
handle.width = 150
|
||||
const deltaY = mouse.y - startY
|
||||
|
||||
if (deltaY <= -swipeDistance) {
|
||||
lastTapAt = -1
|
||||
GlobalStates.dockRevealed = true
|
||||
return
|
||||
}
|
||||
if (deltaY >= swipeDistance) {
|
||||
lastTapAt = -1
|
||||
GlobalStates.dockRevealed = false
|
||||
return
|
||||
}
|
||||
if (Math.abs(deltaY) > tapSlop) {
|
||||
lastTapAt = -1
|
||||
return
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
if (lastTapAt > 0 && now - lastTapAt <= doubleTapInterval) {
|
||||
lastTapAt = -1
|
||||
toggleFullscreen()
|
||||
} else {
|
||||
lastTapAt = now
|
||||
}
|
||||
}
|
||||
|
||||
onCanceled: {
|
||||
handle.width = 150
|
||||
lastTapAt = -1
|
||||
}
|
||||
}
|
||||
}
|
||||
2
surfaces/quickshell/modules/souveraine/navigation/qmldir
Normal file
2
surfaces/quickshell/modules/souveraine/navigation/qmldir
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
module qs.modules.souveraine.navigation
|
||||
SystemGestureRail 1.0 SystemGestureRail.qml
|
||||
|
|
@ -25,6 +25,7 @@ import qs.modules.ii.sidebarRight
|
|||
import qs.modules.ii.overlay
|
||||
import qs.modules.ii.verticalBar
|
||||
import qs.modules.ii.wallpaperSelector
|
||||
import qs.modules.souveraine.navigation
|
||||
|
||||
// The Souveraine panel family — one family for both modes.
|
||||
// Starts at exact panel parity with IllogicalImpulseFamily (our overridden
|
||||
|
|
@ -57,6 +58,10 @@ Scope {
|
|||
PanelLoader { component: SessionScreen {} }
|
||||
PanelLoader { component: SidebarLeft {} }
|
||||
PanelLoader { component: SidebarRight {} }
|
||||
PanelLoader {
|
||||
extraCondition: Config.options.souveraine.phone
|
||||
component: SystemGestureRail {}
|
||||
}
|
||||
PanelLoader { extraCondition: Config.options.bar.vertical; component: VerticalBar {} }
|
||||
PanelLoader { component: WallpaperSelector {} }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,91 +0,0 @@
|
|||
// pill — Phosh-style gesture bar for the Pixel 3.
|
||||
// Gesture map (2026-07-12):
|
||||
// double-tap : toggle true app fullscreen (whole display) of active win
|
||||
// swipe up : reveal the dock above the pill
|
||||
// swipe down : hide the revealed dock
|
||||
// Keyboard is NOT on the pill. The OSK lives on the 3-finger-swipe-up
|
||||
// hyprgrass bind alone until the grip sensor arrives.
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import QtQuick
|
||||
|
||||
ShellRoot {
|
||||
PanelWindow {
|
||||
id: pillWin
|
||||
anchors.bottom: true
|
||||
implicitWidth: 200
|
||||
implicitHeight: 26
|
||||
margins.bottom: 0
|
||||
// stick to the physical screen edge: ignore other surfaces'
|
||||
// exclusive zones (dock, OSK) instead of being pushed up by them
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
color: "transparent"
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
// Stable namespace so a hyprland layerrule can pin the pill above the
|
||||
// dock (both live on Overlay; without a rule, z-order is creation
|
||||
// order and an ii restart buries the pill under the dock, killing its
|
||||
// touch). See the `order` layerrule in hyprland.lua.
|
||||
WlrLayershell.namespace: "quickshell:pill"
|
||||
|
||||
Rectangle {
|
||||
id: pill
|
||||
anchors.centerIn: parent
|
||||
width: 150
|
||||
height: 7
|
||||
radius: 3.5
|
||||
color: "#e6ffffff"
|
||||
Behavior on width { NumberAnimation { duration: 120 } }
|
||||
}
|
||||
|
||||
// Keep gesture arbitration in one place. Qt's onDoubleClicked is too
|
||||
// eager to treat the small drift in a touchscreen double-tap as a
|
||||
// drag, so a double tap is recognized from two released tap events.
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
property real startY: 0
|
||||
property real lastTapAt: -1
|
||||
readonly property int tapSlop: 24
|
||||
readonly property int swipeDistance: 48
|
||||
readonly property int doubleTapInterval: 350
|
||||
|
||||
onPressed: (mouse) => {
|
||||
startY = mouse.y
|
||||
pill.width = 170
|
||||
}
|
||||
onReleased: (mouse) => {
|
||||
pill.width = 150
|
||||
const deltaY = mouse.y - startY
|
||||
|
||||
if (deltaY <= -swipeDistance) {
|
||||
lastTapAt = -1
|
||||
Quickshell.execDetached(["qs", "-c", "ii", "ipc", "call", "dock", "swipeUp"])
|
||||
return
|
||||
}
|
||||
if (deltaY >= swipeDistance) {
|
||||
lastTapAt = -1
|
||||
Quickshell.execDetached(["qs", "-c", "ii", "ipc", "call", "dock", "swipeDown"])
|
||||
return
|
||||
}
|
||||
|
||||
// A small move is still a tap; intermediate moves are neither
|
||||
// a tap nor a swipe, which prevents accidental actions.
|
||||
if (Math.abs(deltaY) > tapSlop) {
|
||||
lastTapAt = -1
|
||||
return
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
if (lastTapAt > 0 && now - lastTapAt <= doubleTapInterval) {
|
||||
lastTapAt = -1
|
||||
Quickshell.execDetached(["qs", "-c", "ii", "ipc", "call", "dock", "fullscreen"])
|
||||
} else {
|
||||
lastTapAt = now
|
||||
}
|
||||
}
|
||||
onCanceled: {
|
||||
pill.width = 150
|
||||
lastTapAt = -1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
85
surfaces/quickshell/services/IdleCoordinator.qml
Normal file
85
surfaces/quickshell/services/IdleCoordinator.qml
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
// Souveraine's staged idle projection.
|
||||
//
|
||||
// It does not replace logind or hypridle. It gives all surfaces one state
|
||||
// vocabulary while target-specific adapters evolve. Native idle-notify stays
|
||||
// opt-in until it is verified on the Pixel compositor.
|
||||
pragma Singleton
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import qs
|
||||
import qs.modules.common
|
||||
import qs.modules.common.functions
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
enum State { Active, Dimmed, LockRequested, LockSecure }
|
||||
|
||||
property int state: IdleCoordinator.Active
|
||||
readonly property bool nativeEnabled: Config.options.lock.idle.nativeCoordinatorEnabled
|
||||
readonly property bool lockSecure: GlobalStates.screenLockSecure
|
||||
readonly property bool lockRequested: GlobalStates.screenLocked
|
||||
|
||||
signal dimRequested()
|
||||
signal activeRequested()
|
||||
signal stateTransitioned(int state)
|
||||
|
||||
function setState(next) {
|
||||
if (root.state === next) return;
|
||||
root.state = next;
|
||||
root.stateTransitioned(next);
|
||||
console.log("[idle-coordinator] state=" + next);
|
||||
}
|
||||
|
||||
function returnActive() {
|
||||
if (root.lockRequested || root.lockSecure) return;
|
||||
root.setState(IdleCoordinator.Active);
|
||||
root.activeRequested();
|
||||
}
|
||||
|
||||
IdleMonitor {
|
||||
id: dimMonitor
|
||||
enabled: root.nativeEnabled
|
||||
timeout: Math.max(1, Config.options.lock.idle.dimAfterSeconds) * 1000
|
||||
respectInhibitors: true
|
||||
onIsIdleChanged: {
|
||||
if (isIdle && !root.lockRequested) {
|
||||
root.setState(IdleCoordinator.Dimmed);
|
||||
root.dimRequested();
|
||||
} else if (!isIdle) {
|
||||
root.returnActive();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IdleMonitor {
|
||||
id: lockMonitor
|
||||
enabled: root.nativeEnabled
|
||||
timeout: Math.max(1, Config.options.lock.idle.lockAfterSeconds) * 1000
|
||||
respectInhibitors: true
|
||||
onIsIdleChanged: {
|
||||
if (isIdle && !root.lockRequested) {
|
||||
root.setState(IdleCoordinator.LockRequested);
|
||||
Session.lock();
|
||||
} else if (!isIdle) {
|
||||
root.returnActive();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: GlobalStates
|
||||
function onScreenLockedChanged() {
|
||||
if (GlobalStates.screenLocked)
|
||||
root.setState(IdleCoordinator.LockRequested);
|
||||
else
|
||||
root.returnActive();
|
||||
}
|
||||
function onScreenLockSecureChanged() {
|
||||
if (GlobalStates.screenLockSecure)
|
||||
root.setState(IdleCoordinator.LockSecure);
|
||||
}
|
||||
}
|
||||
}
|
||||
27
surfaces/quickshell/services/LockContentPolicy.qml
Normal file
27
surfaces/quickshell/services/LockContentPolicy.qml
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
// Lock-surface information policy. Every card asks this singleton instead of
|
||||
// growing an accidental privacy rule of its own.
|
||||
pragma Singleton
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.modules.common
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property string ambient: "ambient"
|
||||
readonly property string personal: "personal"
|
||||
readonly property string stepUp: "step-up"
|
||||
|
||||
function allowsOnLock(tier, promotedAmbient = false) {
|
||||
if (tier === root.ambient) return true;
|
||||
// Promotion is intentionally field-specific (for example media title)
|
||||
// and never applies to credentials, memories, agent output, or actions.
|
||||
return tier === root.personal && promotedAmbient;
|
||||
}
|
||||
|
||||
readonly property bool mediaControlsVisible: Config.options.lock.content.showMediaControls
|
||||
readonly property bool mediaMetadataVisible: root.allowsOnLock(
|
||||
root.personal, Config.options.lock.content.mediaMetadataAmbient)
|
||||
readonly property bool batteryVisible: Config.options.lock.content.showBattery
|
||||
}
|
||||
32
surfaces/quickshell/services/WallpaperAssets.qml
Normal file
32
surfaces/quickshell/services/WallpaperAssets.qml
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// Aspect-aware wallpaper selection for unlike display shapes. This retains the
|
||||
// original as canonical and selects only explicit local derivatives.
|
||||
pragma Singleton
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.modules.common
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
function aspectFor(screen) {
|
||||
if (!screen || !screen.height) return 1;
|
||||
return screen.width / screen.height;
|
||||
}
|
||||
|
||||
function pathFor(screen) {
|
||||
const aspect = root.aspectFor(screen);
|
||||
const portrait = Config.options.background.portraitVariantPath;
|
||||
const landscape = Config.options.background.landscapeVariantPath;
|
||||
if (aspect < 0.9 && portrait) return portrait;
|
||||
if (aspect > 1.1 && landscape) return landscape;
|
||||
return Config.options.background.wallpaperPath;
|
||||
}
|
||||
|
||||
function focalPoint() {
|
||||
return {
|
||||
x: Math.max(0, Math.min(1, Config.options.background.wallpaperFocalX)),
|
||||
y: Math.max(0, Math.min(1, Config.options.background.wallpaperFocalY))
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -49,9 +49,9 @@ ApplicationWindow {
|
|||
component: "modules/settings/DockConfig.qml"
|
||||
},
|
||||
{
|
||||
name: Translation.tr("Pill"),
|
||||
name: Translation.tr("Navigation"),
|
||||
icon: "gesture",
|
||||
component: "modules/settings/PillConfig.qml"
|
||||
component: "modules/settings/NavigationConfig.qml"
|
||||
},
|
||||
{
|
||||
name: Translation.tr("Keyboard"),
|
||||
|
|
|
|||
Loading…
Reference in a new issue