Watch
1
0
Fork
You've already forked souveraine
0
souveraine/surfaces/quickshell/modules/ii/lock/TouchLockSurface.qml
Fimeg e31c3aaf62 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.
2026-07-14 20:00:57 -04:00

274 lines
9.7 KiB
QML

// Souveraine addition: touch-first lock surface for the phone.
//
// A PIN keypad instead of ii's keyboard-driven LockSurface — on the phone
// 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. 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
import QtQuick.Layouts
import qs
import qs.services
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
MouseArea {
id: root
required property LockContext context
readonly property bool requirePasswordToPower: Config.options.lock.security.requirePasswordToPower
readonly property int keySize: 96
readonly property int keySpacing: 18
function forceFieldFocus() {
root.forceActiveFocus();
}
Connections {
target: root.context
function onShouldReFocus() {
forceFieldFocus();
}
}
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;
}
// Hardware keyboard entry (USB-C keyboard); mirrors the desktop surface
focus: true
Keys.onPressed: event => {
root.context.resetClearTimer();
if (event.key === Qt.Key_Backspace) {
root.context.currentText = (event.modifiers & Qt.ControlModifier)
? "" : root.context.currentText.slice(0, -1);
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
if (root.context.currentText.length > 0) root.context.tryUnlock();
} else if (event.key === Qt.Key_Escape) {
root.context.currentText = "";
} else if (event.text.length === 1 && event.text >= " ") {
root.context.currentText += event.text;
}
}
ColumnLayout {
id: padColumn
anchors {
horizontalCenter: parent.horizontalCenter
bottom: parent.bottom
bottomMargin: 48
}
z: 2
spacing: 20
// Entered-PIN dots, with the empty-state hint behind them.
// In a ColumnLayout so ErrorShakeAnimation's Layout.leftMargin works.
Item {
id: dotsArea
Layout.alignment: Qt.AlignHCenter
implicitWidth: Math.max(dotsRow.width, hintText.width, 1)
implicitHeight: 36
opacity: root.context.unlockInProgress ? 0.5 : 1
Row {
id: dotsRow
anchors.centerIn: parent
spacing: 13
Repeater {
model: Math.min(root.context.currentText.length, 14)
Rectangle {
width: 13
height: 13
radius: 6.5
color: GlobalStates.screenUnlockFailed
? Appearance.colors.colError : Appearance.colors.colOnLayer1
}
}
}
StyledText {
id: hintText
anchors.centerIn: parent
visible: root.context.currentText.length === 0
text: GlobalStates.screenUnlockFailed
? Translation.tr("Incorrect PIN") : Translation.tr("Enter PIN")
color: GlobalStates.screenUnlockFailed
? Appearance.colors.colError : Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.normal
}
ErrorShakeAnimation {
id: wrongPinShakeAnim
target: dotsArea
}
Connections {
target: GlobalStates
function onScreenUnlockFailedChanged() {
if (GlobalStates.screenUnlockFailed) wrongPinShakeAnim.restart();
}
}
}
GridLayout {
Layout.alignment: Qt.AlignHCenter
columns: 3
columnSpacing: root.keySpacing
rowSpacing: root.keySpacing
Repeater {
model: ["1", "2", "3", "4", "5", "6", "7", "8", "9"]
KeypadButton {
id: digitKey
required property string modelData
onClicked: root.pressDigit(digitKey.modelData)
contentItem: StyledText {
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
text: digitKey.modelData
font.pixelSize: 30
color: Appearance.colors.colOnLayer1
}
}
}
KeypadButton {
enabled: root.context.currentText.length > 0 && !root.context.unlockInProgress
colBackground: "transparent"
onClicked: {
root.context.resetClearTimer();
root.context.currentText = root.context.currentText.slice(0, -1);
}
onPressAndHold: root.context.currentText = ""
contentItem: KeypadIcon {
text: "backspace"
color: Appearance.colors.colOnLayer1
}
}
KeypadButton {
onClicked: root.pressDigit("0")
contentItem: StyledText {
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
text: "0"
font.pixelSize: 30
color: Appearance.colors.colOnLayer1
}
}
KeypadButton {
id: confirmKey
enabled: root.context.currentText.length > 0 && !root.context.unlockInProgress
toggled: true
onClicked: root.context.tryUnlock()
contentItem: KeypadIcon {
text: "arrow_right_alt"
color: confirmKey.enabled ? Appearance.colors.colOnPrimary : Appearance.colors.colSubtext
}
}
}
// Utility row: power / battery / reboot, kept small and away from digits
RowLayout {
Layout.alignment: Qt.AlignHCenter
Layout.topMargin: 6
spacing: 36
UtilityButton {
iconName: "power_settings_new"
targetAction: LockContext.ActionEnum.Poweroff
}
RowLayout {
spacing: 5
visible: Battery.available
MaterialSymbol {
fill: 1
text: Battery.isCharging ? "bolt" : "battery_android_full"
iconSize: Appearance.font.pixelSize.huge
color: (Battery.isLow && !Battery.isCharging)
? Appearance.colors.colError : Appearance.colors.colOnSurfaceVariant
}
StyledText {
text: Math.round(Battery.percentage * 100) + "%"
color: Appearance.colors.colOnSurfaceVariant
}
}
UtilityButton {
iconName: "restart_alt"
targetAction: LockContext.ActionEnum.Reboot
}
}
}
component KeypadButton: RippleButton {
implicitWidth: root.keySize
implicitHeight: root.keySize
buttonRadius: root.keySize / 2
enabled: !root.context.unlockInProgress
colBackground: ColorUtils.transparentize(Appearance.colors.colLayer1, 0.4)
}
component KeypadIcon: MaterialSymbol {
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
iconSize: 30
}
// Same semantics as the desktop surface's password-guarded power buttons:
// with requirePasswordToPower the action arms and the PIN confirms it,
// otherwise it fires immediately.
component UtilityButton: RippleButton {
id: utilBtn
required property string iconName
required property var targetAction
implicitWidth: 56
implicitHeight: 56
buttonRadius: 28
toggled: root.context.targetAction === utilBtn.targetAction
colBackground: "transparent"
onClicked: {
if (!root.requirePasswordToPower) {
root.context.unlocked(utilBtn.targetAction);
return;
}
if (root.context.targetAction === utilBtn.targetAction) {
root.context.resetTargetAction();
} else {
root.context.targetAction = utilBtn.targetAction;
root.context.shouldReFocus();
}
}
contentItem: KeypadIcon {
text: utilBtn.iconName
color: utilBtn.toggled ? Appearance.colors.colOnPrimary : Appearance.colors.colOnSurfaceVariant
}
}
}