Watch
1
0
Fork
You've already forked souveraine
0

quickshell: touch PIN lock screen for the phone

The OSK is layershell and can't rise above a session lock, so the lock
surface carries its own keypad. Lock state persists to states.json so a
qs crash while locked comes back locked; with lock.launchOnStartup the
lock screen is the greeter (greetd autologins straight into Hyprland).
Keypad gated behind lock.touchKeypad so desktop deploys keep the stock
surface.
This commit is contained in:
Fimeg 2026-07-13 15:19:59 -04:00
commit 72f5169458
6 changed files with 691 additions and 0 deletions

View file

@ -36,6 +36,10 @@ modules/ii/dock/DockButton.qml ii/modules/ii/dock/DockButton.qml
modules/ii/dock/DockSeparator.qml ii/modules/ii/dock/DockSeparator.qml
modules/ii/dock/DockStack.qml ii/modules/ii/dock/DockStack.qml
modules/ii/overview/Overview.qml ii/modules/ii/overview/Overview.qml
modules/common/Persistent.qml ii/modules/common/Persistent.qml
modules/common/panels/lock/LockScreen.qml ii/modules/common/panels/lock/LockScreen.qml
modules/ii/lock/Lock.qml ii/modules/ii/lock/Lock.qml
modules/ii/lock/TouchLockSurface.qml ii/modules/ii/lock/TouchLockSurface.qml
pill/shell.qml pill/shell.qml
"

View file

@ -385,6 +385,10 @@ Singleton {
property JsonObject lock: JsonObject {
property bool useHyprlock: false
property bool launchOnStartup: false
// Souveraine: PIN keypad surface for touch panels (phone).
// The OSK can't rise above a session lock, so the lock
// surface must carry its own input.
property bool touchKeypad: false
property JsonObject blur: JsonObject {
property bool enable: true
property real radius: 100

View file

@ -0,0 +1,174 @@
// Souveraine patch to ii's stock Persistent.qml.
//
// Adds states.lock.locked: written by LockScreen.qml whenever the session
// lock engages/releases, so a quickshell crash or restart while locked
// comes back locked instead of silently dropping the lock. Everything else
// is unchanged stock ii — diff against upstream before re-applying if ii
// updates.
pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Io
Singleton {
id: root
property alias states: persistentStatesJsonAdapter
property string fileDir: Directories.state
property string fileName: "states.json"
property string filePath: `${root.fileDir}/${root.fileName}`
property bool ready: false
property string previousHyprlandInstanceSignature: ""
property bool isNewHyprlandInstance: previousHyprlandInstanceSignature !== states.hyprlandInstanceSignature
onReadyChanged: {
root.previousHyprlandInstanceSignature = root.states.hyprlandInstanceSignature
root.states.hyprlandInstanceSignature = Quickshell.env("HYPRLAND_INSTANCE_SIGNATURE") || ""
}
Timer {
id: fileReloadTimer
interval: 100
repeat: false
onTriggered: {
persistentStatesFileView.reload()
}
}
Timer {
id: fileWriteTimer
interval: 100
repeat: false
onTriggered: {
persistentStatesFileView.writeAdapter()
}
}
FileView {
id: persistentStatesFileView
path: root.filePath
watchChanges: true
onFileChanged: fileReloadTimer.restart()
onAdapterUpdated: fileWriteTimer.restart()
onLoaded: root.ready = true
onLoadFailed: error => {
console.log("Failed to load persistent states file:", error);
if (error == FileViewError.FileNotFound) {
fileWriteTimer.restart();
}
}
adapter: JsonAdapter {
id: persistentStatesJsonAdapter
property string hyprlandInstanceSignature: ""
property JsonObject ai: JsonObject {
property string model: "gemini-2.5-flash"
property real temperature: 0.5
}
property JsonObject cheatsheet: JsonObject {
property int tabIndex: 0
}
property JsonObject sidebar: JsonObject {
property JsonObject bottomGroup: JsonObject {
property bool collapsed: false
property int tab: 0
}
}
property JsonObject booru: JsonObject {
property bool allowNsfw: false
property string provider: "yandere"
}
property JsonObject idle: JsonObject {
property bool inhibit: false
}
property JsonObject lock: JsonObject {
property bool locked: false
}
property JsonObject overlay: JsonObject {
property list<string> open: ["crosshair", "recorder", "volumeMixer", "resources"]
property JsonObject crosshair: JsonObject {
property bool pinned: false
property bool clickthrough: true
property real x: 827
property real y: 441
property real width: 250
property real height: 100
}
property JsonObject floatingImage: JsonObject {
property bool pinned: false
property bool clickthrough: false
property real x: 1650
property real y: 390
property real width: 0
property real height: 0
}
property JsonObject fpsLimiter: JsonObject {
property bool pinned: false
property bool clickthrough: false
property real x: 1570
property real y: 615
property real width: 280
property real height: 80
}
property JsonObject recorder: JsonObject {
property bool pinned: false
property bool clickthrough: false
property real x: 80
property real y: 80
property real width: 350
property real height: 130
}
property JsonObject resources: JsonObject {
property bool pinned: false
property bool clickthrough: true
property real x: 1500
property real y: 770
property real width: 350
property real height: 200
property int tabIndex: 0
}
property JsonObject volumeMixer: JsonObject {
property bool pinned: false
property bool clickthrough: false
property real x: 80
property real y: 280
property real width: 350
property real height: 600
property int tabIndex: 0
}
property JsonObject notes: JsonObject {
property bool pinned: false
property bool clickthrough: true
property real x: 1400
property real y: 42
property real width: 460
property real height: 330
}
}
property JsonObject timer: JsonObject {
property JsonObject pomodoro: JsonObject {
property bool running: false
property int start: 0
property bool isBreak: false
property int cycle: 0
}
property JsonObject stopwatch: JsonObject {
property bool running: false
property int start: 0
property list<var> laps: []
}
}
}
}
}

View file

@ -0,0 +1,164 @@
// Souveraine patch to ii's stock LockScreen.qml.
//
// Two changes, both for phone duty where the lock is the only gate on the
// device:
// 1. The lock state is mirrored into Persistent.states.lock.locked, so a
// quickshell crash/restart while locked comes back locked instead of
// dropping the session lock on the floor.
// 2. initIfReady() also locks when the persisted flag says we died locked,
// not only on a fresh Hyprland instance.
// Everything else is unchanged stock ii.
pragma ComponentBehavior: Bound
import qs
import qs.services
import qs.modules.common
import qs.modules.common.functions
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import Quickshell.Hyprland
Scope {
id: root
required property Component lockSurface
property alias context: lockContext
property Component sessionLockSurface: WlSessionLockSurface {
id: sessionLockSurface
color: "transparent"
Loader {
active: GlobalStates.screenLocked
anchors.fill: parent
opacity: active ? 1 : 0
Behavior on opacity {
animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this)
}
sourceComponent: root.lockSurface
}
}
Process {
id: unlockKeyringProc
onExited: (exitCode, exitStatus) => {
KeyringStorage.fetchKeyringData();
}
}
function unlockKeyring() {
unlockKeyringProc.exec({
environment: ({
"UNLOCK_PASSWORD": lockContext.currentText
}),
command: ["bash", "-c", Quickshell.shellPath("scripts/keyring/unlock.sh")]
})
}
// This stores all the information shared between the lock surfaces on each screen.
// https://github.com/quickshell-mirror/quickshell-examples/tree/master/lockscreen
LockContext {
id: lockContext
Connections {
target: GlobalStates
function onScreenLockedChanged() {
Persistent.states.lock.locked = GlobalStates.screenLocked;
if (GlobalStates.screenLocked) {
lockContext.reset();
lockContext.tryFingerUnlock();
}
}
}
onUnlocked: (targetAction) => {
// Perform the target action if it's not just unlocking
if (targetAction == LockContext.ActionEnum.Poweroff) {
Session.poweroff();
return;
} else if (targetAction == LockContext.ActionEnum.Reboot) {
Session.reboot();
return;
}
// Unlock the keyring if configured to do so
if (Config.options.lock.security.unlockKeyring) root.unlockKeyring(); // Async
// Unlock the screen before exiting, or the compositor will display a
// fallback lock you can't interact with.
GlobalStates.screenLocked = false;
// Reset
lockContext.reset();
// Post-unlock actions
if (lockContext.alsoInhibitIdle) {
lockContext.alsoInhibitIdle = false;
Idle.toggleInhibit(true);
}
}
}
WlSessionLock {
id: lock
locked: GlobalStates.screenLocked
surface: root.sessionLockSurface
}
function lock() {
if (Config.options.lock.useHyprlock) {
Quickshell.execDetached(["bash", "-c", "pidof hyprlock || hyprlock"]);
return;
}
GlobalStates.screenLocked = true;
}
IpcHandler {
target: "lock"
function activate(): void {
root.lock();
}
function focus(): void {
lockContext.shouldReFocus();
}
}
GlobalShortcut {
name: "lock"
description: "Locks the screen"
onPressed: {
root.lock()
}
}
GlobalShortcut {
name: "lockFocus"
description: "Re-focuses the lock screen. This is because Hyprland after waking up for whatever reason"
+ "decides to keyboard-unfocus the lock screen"
onPressed: {
lockContext.shouldReFocus();
}
}
function initIfReady() {
if (!Config.ready || !Persistent.ready) return;
if (Config.options.lock.launchOnStartup && (Persistent.isNewHyprlandInstance || Persistent.states.lock.locked)) {
root.lock();
} else {
KeyringStorage.fetchKeyringData();
}
}
Connections {
target: Config
function onReadyChanged() {
root.initIfReady();
}
}
Connections {
target: Persistent
function onReadyChanged() {
root.initIfReady();
}
}
}

View file

@ -0,0 +1,87 @@
// Souveraine patch to ii's stock Lock.qml.
//
// Selects the lock surface by config: lock.touchKeypad picks the
// TouchLockSurface (PIN pad for the phone's touch panel) instead of the
// stock keyboard-driven LockSurface. Everything else is unchanged stock ii.
pragma ComponentBehavior: Bound
import qs
import qs.services
import qs.modules.common
import qs.modules.common.functions
import qs.modules.common.panels.lock
import QtQuick
import Quickshell
import Quickshell.Hyprland
LockScreen {
id: root
// Monitor name -> workspace id to restore on unlock (set when locking)
property var savedWorkspaces: ({})
Timer {
id: restoreTimer
interval: 150
repeat: false
onTriggered: {
var batch = ""
for (var j = 0; j < Quickshell.screens.length; ++j) {
var monName = Quickshell.screens[j].name
var wsId = root.savedWorkspaces[monName]
if (wsId !== undefined) {
batch += `hyprctl dispatch 'hl.dsp.focus({monitor="${monName}"})'; hyprctl dispatch 'hl.dsp.focus({workspace=${wsId}})';`
}
}
if (batch.length > 0) {
Quickshell.execDetached(["bash", "-c", batch])
}
}
}
lockSurface: Config.options.lock.touchKeypad ? touchSurfaceComponent : desktopSurfaceComponent
property Component desktopSurfaceComponent: LockSurface {
context: root.context
}
property Component touchSurfaceComponent: TouchLockSurface {
context: root.context
}
// Single batch for lock and unlock so we don't race multiple hyprctl calls
Connections {
target: GlobalStates
function onScreenLockedChanged() {
if (GlobalStates.screenLocked) {
// Lock: save workspace per monitor and move all to temp workspace in one batch
var next = {}
var batch = "keyword animation workspaces,1,7,menu_decel,slidevert; "
for (var i = 0; i < Quickshell.screens.length; ++i) {
var mon = Quickshell.screens[i].name
var mData = HyprlandData.monitors.find(m => m.name === mon)
if (mData?.activeWorkspace == undefined) {
return;
}
var ws = (mData?.activeWorkspace?.id ?? 1)
next[mon] = ws
batch += `hyprctl dispatch 'hl.dsp.focus({monitor="${mon}"})'; hyprctl dispatch 'hl.dsp.focus({workspace=${2147483647 - ws}})';`
}
root.savedWorkspaces = next
Quickshell.execDetached(["bash", "-c", batch])
} else {
restoreTimer.start()
}
}
}
// Push everything down (visual only; workspace switch is in Connections above)
Variants {
model: Quickshell.screens
delegate: Scope {
required property ShellScreen modelData
property bool shouldPush: GlobalStates.screenLocked
property string targetMonitorName: modelData.name
property int verticalMovementDistance: modelData.height
property int horizontalSqueeze: modelData.width * 0.2
}
}
}

View file

@ -0,0 +1,258 @@
// 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. The big clock comes from Background.qml
// (Overlay layer while locked), so none is drawn here.
//
// 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 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()
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
}
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
}
}
}