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.
274 lines
9.7 KiB
QML
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
|
|
}
|
|
}
|
|
}
|