Merge quattro into plugin auth boundary
This commit is contained in:
commit
19985314c2
40 files changed
+1829
-46
No files matched your search
+6
-2
@@ -86,8 +86,12 @@ Only one `bar` plugin is active at a time. Missing or invalid selections fall
|
||||
back to the built-in `omarchy.bar`, so users always have a safe path home.
|
||||
Panels, overlays, and menus are loaded when summoned. Plugins that need
|
||||
to outlive a single summon can set `keepLoaded: true` (e.g. the image
|
||||
picker keeps its overlay window mounted between summons). First-party
|
||||
services are loaded at startup.
|
||||
picker keeps its overlay window mounted between summons). The same flag
|
||||
keeps a service mounted across plugin hot-reload, so tearing down a
|
||||
changed bar widget cannot destroy `omarchy.lock` while Hyprland still
|
||||
holds the session lock. The kept instance is not replaced, so code
|
||||
changes to a `keepLoaded` service itself only take effect on a shell
|
||||
restart. First-party services are loaded at startup.
|
||||
|
||||
Entry points may declare `omarchyPath`, `shell`, `manifest`, `pluginRegistry`, and `barWidgetRegistry` properties for host injection. Built-in plugins receive the trusted host objects. Third-party plugins receive capability-scoped facades: ordinary plugins can look up and control only their own service and lifecycle, menu plugins receive an application-library facade, and plugins can read detached scalar bar state. A full-bar plugin additionally receives detached bar configuration and widget-catalog snapshots, narrow proxies for the non-authentication services used by built-in bar widgets, and lifecycle control over configured non-authentication UI plugins. Authentication capabilities are stamped from trusted first-party manifests, authentication services are retained outside the host's public service map and QML object tree, and changing a third-party registry snapshot cannot mutate the host registry.
|
||||
|
||||
|
||||
@@ -83,6 +83,9 @@ separate PAM services: `omarchy-lock-password` for password auth and,
|
||||
only when fingerprints are enrolled, `omarchy-lock-fingerprint` for
|
||||
fingerprint auth. It mirrors the previous lock screen field dimensions,
|
||||
colors, blurred wallpaper, placeholder, and Hyprland-driven corners.
|
||||
The plugin sets `keepLoaded: true` so a plugin hot-reload (for example
|
||||
an installed bar widget changing on disk) does not destroy the lock
|
||||
client while Hyprland still holds the session lock.
|
||||
|
||||
## Polkit agent
|
||||
|
||||
|
||||
@@ -14,9 +14,25 @@ function wifiIconFor(strength) {
|
||||
return icons[index]
|
||||
}
|
||||
|
||||
function connectionIcon(kind, signalStrength) {
|
||||
if (kind === "wifi") return wifiIconFor(signalStrength)
|
||||
if (kind === "ethernet") return ""
|
||||
// A known plain-HTTP endpoint lets the network redirect the browser to its
|
||||
// login page. Never execute or automatically open an untrusted Location header.
|
||||
var captivePortalUrl = "http://ping.archlinux.org/nm-check.txt"
|
||||
|
||||
function connectivityState(kind, connectivity, states, checksEnabled) {
|
||||
if (kind === "disconnected") return "none"
|
||||
// Ignore stale cached results when the operator has disabled probing.
|
||||
if (!checksEnabled) return "unknown"
|
||||
if (connectivity === states.Portal) return "portal"
|
||||
if (connectivity === states.Limited) return "limited"
|
||||
if (connectivity === states.Full) return "full"
|
||||
if (connectivity === states.None) return "none"
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
function connectionIcon(kind, signalStrength, connectivity) {
|
||||
var restricted = connectivity === "portal" || connectivity === "limited"
|
||||
if (kind === "wifi") return restricted ? "" : wifiIconFor(signalStrength)
|
||||
if (kind === "ethernet") return restricted ? "" : ""
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -352,6 +368,8 @@ if (typeof module !== "undefined") {
|
||||
parseNetworkStatus: parseNetworkStatus,
|
||||
wifiIconFor: wifiIconFor,
|
||||
connectionIcon: connectionIcon,
|
||||
connectivityState: connectivityState,
|
||||
captivePortalUrl: captivePortalUrl,
|
||||
formatHeaderSpeed: formatHeaderSpeed,
|
||||
formatHeaderFreq: formatHeaderFreq,
|
||||
headerDetail: headerDetail,
|
||||
|
||||
@@ -119,9 +119,9 @@ Panel {
|
||||
property bool cursorActive: false
|
||||
|
||||
// Keyboard focus zone for the panel. j/k crosses row boundaries:
|
||||
// header actions ⇄ band ⇄ DNS row ⇄ Wi-Fi networks. h/l move
|
||||
// header actions ⇄ portal ⇄ band ⇄ DNS row ⇄ Wi-Fi networks. h/l move
|
||||
// within header actions, band pills, or DNS providers.
|
||||
property string focusSection: "dns" // "header" | "band" | "dns" | "wifi"
|
||||
property string focusSection: "dns" // "header" | "portal" | "band" | "dns" | "wifi"
|
||||
property int headerIndex: 0
|
||||
readonly property bool canDisconnect: !!connectedWifiNetwork
|
||||
readonly property bool headerHasDisconnect: false
|
||||
@@ -220,6 +220,8 @@ Panel {
|
||||
// network target; both cards are their own plugins now.
|
||||
function showQr() { root.summonWifiQr(true) }
|
||||
function speedTest() { root.summonSpeedTest() }
|
||||
function openCaptivePortal() { root.openCaptivePortal() }
|
||||
function checkConnectivity() { root.checkConnectivity() }
|
||||
}
|
||||
|
||||
function activateHeader() {
|
||||
@@ -322,11 +324,11 @@ Panel {
|
||||
refresh(true)
|
||||
selectedIndex = wifiNetworks.length > 0 ? 0 : -1
|
||||
wifiActionFocused = false
|
||||
focusSection = wifiNetworks.length > 0 ? "wifi" : "dns"
|
||||
focusSection = hasCaptivePortal ? "portal" : (wifiNetworks.length > 0 ? "wifi" : "dns")
|
||||
var idx = dnsProviders.indexOf(dnsProvider)
|
||||
dnsIndex = idx >= 0 ? idx : 0
|
||||
syncBandIndex()
|
||||
cursorActive = false
|
||||
cursorActive = hasCaptivePortal
|
||||
} else {
|
||||
// Drop a restart armed by this open: without it a close/reopen inside
|
||||
// the 100ms window reuses the running timer and re-enables the scanner
|
||||
@@ -450,7 +452,59 @@ Panel {
|
||||
Quickshell.execDetached(["bash", "-c", "printf %s " + Util.shellQuote(value) + " | wl-copy"])
|
||||
}
|
||||
|
||||
readonly property string icon: Model.connectionIcon(kind, signalStrength)
|
||||
// NetworkManager performs the HTTP probe (including unexpected page bodies,
|
||||
// not just redirects). Consume its native notifications rather than running
|
||||
// a second curl loop or mistaking an ordinary timeout for a captive portal.
|
||||
readonly property bool connectivityChecksEnabled: networkManagerAvailable
|
||||
&& Networking.canCheckConnectivity && Networking.connectivityCheckEnabled
|
||||
readonly property string connectivity: Model.connectivityState(kind, Networking.connectivity, {
|
||||
Portal: NetworkConnectivity.Portal, Limited: NetworkConnectivity.Limited,
|
||||
Full: NetworkConnectivity.Full, None: NetworkConnectivity.None
|
||||
}, connectivityChecksEnabled)
|
||||
readonly property bool hasCaptivePortal: connectivity === "portal"
|
||||
readonly property bool restricted: hasCaptivePortal || connectivity === "limited"
|
||||
readonly property string icon: Model.connectionIcon(kind, signalStrength, connectivity)
|
||||
readonly property string connectionKey: kind === "wifi" && wifiDevice && connectedWifiNetwork
|
||||
? kind + ":" + wifiDevice.name + ":" + connectedWifiNetwork.name
|
||||
: (kind === "ethernet" && wiredDevice ? kind + ":" + wiredDevice.name : "")
|
||||
|
||||
onConnectionKeyChanged: Qt.callLater(checkConnectivity)
|
||||
onConnectivityChecksEnabledChanged: Qt.callLater(checkConnectivity)
|
||||
onHasCaptivePortalChanged: {
|
||||
if (hasCaptivePortal && opened && passwordSsid === "") {
|
||||
focusSection = "portal"
|
||||
cursorActive = true
|
||||
} else if (!hasCaptivePortal && focusSection === "portal") {
|
||||
focusSection = headerActionCount > 0 ? "header" : "dns"
|
||||
headerIndex = 0
|
||||
}
|
||||
}
|
||||
onRestrictedChanged: {
|
||||
connectionPhraseSwap.stop()
|
||||
heroMeta.opacity = 1.0
|
||||
}
|
||||
|
||||
function checkConnectivity() {
|
||||
if (connectivityChecksEnabled && kind !== "disconnected") Networking.checkConnectivity()
|
||||
}
|
||||
|
||||
function openCaptivePortal() {
|
||||
if (!hasCaptivePortal) return
|
||||
// Explicit user action only. argv (not a shell string), and a fixed HTTP
|
||||
// URL: let the browser handle the redirect without trusting portal input.
|
||||
Quickshell.execDetached(["omarchy-launch-browser", Model.captivePortalUrl])
|
||||
close()
|
||||
}
|
||||
|
||||
// Keep checking while login is needed, even with the panel closed in favour
|
||||
// of the browser. Normal connected operation relies on NM's own schedule.
|
||||
Timer {
|
||||
id: connectivityPoll
|
||||
interval: 10000
|
||||
repeat: true
|
||||
running: root.restricted && root.connectivityChecksEnabled
|
||||
onTriggered: root.checkConnectivity()
|
||||
}
|
||||
|
||||
// The share card is its own panel plugin (omarchy.wifiqr) so a replacement
|
||||
// design can take it over; summon() routes to whichever implementation is
|
||||
@@ -469,6 +523,7 @@ Panel {
|
||||
}
|
||||
|
||||
function refresh(scanWifi) {
|
||||
checkConnectivity()
|
||||
if (scanWifi === undefined) scanWifi = false
|
||||
if (!detailsProc.running) detailsProc.running = true
|
||||
if (!dnsProc.running) {
|
||||
@@ -901,7 +956,7 @@ Panel {
|
||||
Timer {
|
||||
id: connectionPhraseTimer
|
||||
interval: 2800
|
||||
running: root.opened && (root.info.type === "ethernet" || (root.info.type === "wifi" && root.canDisconnect))
|
||||
running: root.opened && !root.restricted && (root.info.type === "ethernet" || (root.info.type === "wifi" && root.canDisconnect))
|
||||
repeat: true
|
||||
onTriggered: connectionPhraseSwap.restart()
|
||||
}
|
||||
@@ -958,6 +1013,9 @@ Panel {
|
||||
anchors.fill: parent
|
||||
bar: root.bar
|
||||
text: root.icon
|
||||
active: root.restricted
|
||||
tooltipText: root.hasCaptivePortal ? "Sign in to this network"
|
||||
: (root.restricted ? "Limited internet access" : "")
|
||||
|
||||
onPressed: function(b) {
|
||||
if (root.opened) root.close()
|
||||
@@ -1001,23 +1059,34 @@ Panel {
|
||||
if (dy >= 0) return
|
||||
}
|
||||
if (dy !== 0) {
|
||||
// Vertical order is header ⇄ band ⇄ DNS ⇄ wifi, with the band section
|
||||
// dropping out of the chain entirely when it isn't on screen.
|
||||
// Hidden sections drop out of the keyboard chain entirely.
|
||||
if (root.focusSection === "header") {
|
||||
if (dy > 0) {
|
||||
if (root.canSelectBand) {
|
||||
if (root.hasCaptivePortal) {
|
||||
root.focusSection = "portal"
|
||||
} else if (root.canSelectBand) {
|
||||
root.focusSection = "band"
|
||||
root.bandAutoFocused = true
|
||||
} else {
|
||||
root.focusSection = "dns"
|
||||
}
|
||||
}
|
||||
} else if (root.focusSection === "portal") {
|
||||
if (dy < 0 && root.headerActionCount > 0) {
|
||||
root.focusSection = "header"
|
||||
root.headerIndex = 0
|
||||
} else if (dy > 0) {
|
||||
root.focusSection = root.canSelectBand ? "band" : "dns"
|
||||
root.bandAutoFocused = true
|
||||
}
|
||||
} else if (root.focusSection === "band") {
|
||||
// Automatic on the header line, then the pills -- which collapse
|
||||
// away under Automatic, leaving a single row to walk.
|
||||
if (dy < 0) {
|
||||
if (!root.bandAutoFocused) {
|
||||
root.bandAutoFocused = true
|
||||
} else if (root.hasCaptivePortal) {
|
||||
root.focusSection = "portal"
|
||||
} else if (root.headerActionCount > 0) {
|
||||
root.focusSection = "header"
|
||||
root.headerIndex = 0
|
||||
@@ -1035,6 +1104,8 @@ Panel {
|
||||
if (root.canSelectBand) {
|
||||
root.focusSection = "band"
|
||||
root.bandAutoFocused = !root.bandPillsVisible
|
||||
} else if (root.hasCaptivePortal) {
|
||||
root.focusSection = "portal"
|
||||
} else if (root.headerActionCount > 0) {
|
||||
root.focusSection = "header"
|
||||
root.headerIndex = 0
|
||||
@@ -1063,6 +1134,7 @@ Panel {
|
||||
onActivateRequested: {
|
||||
if (root.cursorActive) {
|
||||
if (root.focusSection === "header") root.activateHeader()
|
||||
else if (root.focusSection === "portal") root.openCaptivePortal()
|
||||
else if (root.focusSection === "band") root.activateBand()
|
||||
else if (root.focusSection === "dns") root.activateDns()
|
||||
else root.activateSelected()
|
||||
@@ -1092,7 +1164,7 @@ Panel {
|
||||
id: heroIcon
|
||||
textFormat: Text.PlainText
|
||||
text: root.icon
|
||||
color: root.bar.foreground
|
||||
color: root.restricted ? root.bar.urgent : root.bar.foreground
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: Style.font.display
|
||||
opacity: root.networkManagerAvailable ? 1.0 : 0.5
|
||||
@@ -1175,6 +1247,9 @@ Panel {
|
||||
width: parent.width
|
||||
|
||||
readonly property string title: {
|
||||
// The HTTP restriction does not undo association. Show the live
|
||||
// SSID even before route/details polling has returned anything.
|
||||
if (root.kind === "wifi" && root.connectedWifiNetwork) return root.connectedWifiNetwork.name || "Wi-Fi"
|
||||
if (root.info.type === "wifi") return root.info.ssid || "Wi-Fi"
|
||||
if (root.info.type === "ethernet") return "Ethernet"
|
||||
return root.info.iface || (root.kind === "disconnected" ? "Disconnected" : "No connection")
|
||||
@@ -1194,6 +1269,8 @@ Panel {
|
||||
textFormat: Text.PlainText
|
||||
width: parent.width
|
||||
text: {
|
||||
if (root.hasCaptivePortal) return "SIGN-IN REQUIRED"
|
||||
if (root.restricted) return "LIMITED INTERNET ACCESS"
|
||||
if (root.info.type === "wifi") {
|
||||
if (root.canDisconnect) return root.connectionPhrase.toUpperCase()
|
||||
if (root.kind === "disconnected") return "NOT CONNECTED"
|
||||
@@ -1204,7 +1281,7 @@ Panel {
|
||||
return ""
|
||||
}
|
||||
visible: text !== ""
|
||||
color: Qt.darker(root.bar.foreground, 1.4)
|
||||
color: root.restricted ? root.bar.urgent : Qt.darker(root.bar.foreground, 1.4)
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
font.bold: true
|
||||
@@ -1215,6 +1292,43 @@ Panel {
|
||||
|
||||
}
|
||||
|
||||
Column {
|
||||
visible: root.hasCaptivePortal
|
||||
width: parent.width
|
||||
spacing: Style.space(6)
|
||||
|
||||
Button {
|
||||
id: portalAction
|
||||
width: parent.width
|
||||
text: "Open Captive Portal"
|
||||
iconText: ""
|
||||
foreground: root.bar.urgent
|
||||
accent: root.bar.urgent
|
||||
fontFamily: root.bar.fontFamily
|
||||
verticalPadding: Style.space(10)
|
||||
bordered: true
|
||||
active: true
|
||||
hasCursor: root.cursorActive && root.focusSection === "portal"
|
||||
onHovered: function(on) {
|
||||
if (!on) return
|
||||
root.cursorActive = true
|
||||
root.focusSection = "portal"
|
||||
}
|
||||
onClicked: root.openCaptivePortal()
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: "Sign in or accept this network’s terms to access the internet."
|
||||
textFormat: Text.PlainText
|
||||
wrapMode: Text.WordWrap
|
||||
color: root.bar.foreground
|
||||
opacity: 0.7
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
}
|
||||
}
|
||||
|
||||
// Connection details: transfer metrics first, then IP/Gateway.
|
||||
Column {
|
||||
visible: !!root.info.iface
|
||||
@@ -1654,6 +1768,7 @@ Panel {
|
||||
if (isBusy && root.actionKind === "disconnect") return "Disconnecting…"
|
||||
if (isBusy && root.actionKind === "forget") return "Forgetting…"
|
||||
if (isFailed) return root.failureReason || "Failed"
|
||||
if (isConnected && root.kind === "wifi" && root.hasCaptivePortal) return "Sign-in required"
|
||||
if (isConnected) return "Connected"
|
||||
return ""
|
||||
}
|
||||
@@ -1661,6 +1776,7 @@ Panel {
|
||||
readonly property color statusColor: {
|
||||
if (isFailed) return root.bar.urgent
|
||||
if (isBusy) return root.bar.foreground
|
||||
if (isConnected && root.kind === "wifi" && root.hasCaptivePortal) return root.bar.urgent
|
||||
if (isConnected) return root.bar.foreground
|
||||
return Qt.darker(root.bar.foreground, 1.5)
|
||||
}
|
||||
@@ -1715,7 +1831,8 @@ Panel {
|
||||
Text {
|
||||
id: networkIcon
|
||||
textFormat: Text.PlainText
|
||||
text: row.net ? root.wifiIconFor(row.net.signal) : ""
|
||||
text: row.net ? Model.connectionIcon("wifi", row.net.signal,
|
||||
row.isConnected && root.kind === "wifi" ? root.connectivity : "") : ""
|
||||
color: row.statusColor
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: Style.font.title
|
||||
|
||||
@@ -21,6 +21,11 @@ function ids() {
|
||||
return Object.keys(services)
|
||||
}
|
||||
|
||||
function updateManifest(id, manifest) {
|
||||
var service = services[String(id || "")]
|
||||
if (service && "manifest" in service) service.manifest = manifest
|
||||
}
|
||||
|
||||
function destroy(id) {
|
||||
var key = String(id || "")
|
||||
var service = services[key]
|
||||
|
||||
+60
-6
@@ -802,14 +802,44 @@ ShellRoot {
|
||||
if (!Array.isArray(m.kinds) || m.kinds.indexOf("service") === -1) continue
|
||||
if (!m.entryPoints || !m.entryPoints.service) continue
|
||||
if (!pluginRegistry.isEnabled(id)) continue
|
||||
if (_services[id] || (shell.isAuthenticationService(m) && AuthServiceStore.has(id))) continue
|
||||
var authenticationService = shell.isAuthenticationService(m)
|
||||
if (_services[id]) {
|
||||
if (authenticationService) {
|
||||
// A service that gains a trusted authentication capability must move
|
||||
// out of the host's public service map before it is recreated.
|
||||
var published = _services[id]
|
||||
if (published && typeof published.destroy === "function") published.destroy()
|
||||
var withoutPublished = ({})
|
||||
for (var publishedId in _services)
|
||||
if (publishedId !== id) withoutPublished[publishedId] = _services[publishedId]
|
||||
_services = withoutPublished
|
||||
} else {
|
||||
// A kept instance outlives the rescan; hand it the fresh manifest.
|
||||
var kept = _services[id]
|
||||
if (kept && "manifest" in kept) kept.manifest = shell.publicPluginManifest(m)
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (AuthServiceStore.has(id)) {
|
||||
if (authenticationService) {
|
||||
AuthServiceStore.updateManifest(id, shell.publicPluginManifest(m))
|
||||
continue
|
||||
}
|
||||
// A service that loses its trusted authentication capability can move
|
||||
// back to the ordinary service map only after the isolated copy dies.
|
||||
AuthServiceStore.destroy(id)
|
||||
}
|
||||
ensureService(id)
|
||||
}
|
||||
// Drop services for plugins that have been disabled or removed.
|
||||
// Drop services for plugins that have been disabled or removed, or that
|
||||
// no longer declare a service entry point.
|
||||
for (var existingId in _services) {
|
||||
var stillThere = plugins[existingId]
|
||||
var stillService = stillThere && Array.isArray(stillThere.kinds)
|
||||
&& stillThere.kinds.indexOf("service") !== -1
|
||||
&& stillThere.entryPoints && stillThere.entryPoints.service
|
||||
var stillEnabled = stillThere && pluginRegistry.isEnabled(existingId)
|
||||
if (stillThere && stillEnabled) continue
|
||||
if (stillService && stillEnabled) continue
|
||||
var inst = _services[existingId]
|
||||
if (inst && typeof inst.destroy === "function") inst.destroy()
|
||||
var next = ({})
|
||||
@@ -822,19 +852,43 @@ ShellRoot {
|
||||
for (var ai = 0; ai < authenticationIds.length; ai++) {
|
||||
var authenticationId = authenticationIds[ai]
|
||||
var authenticationManifest = plugins[authenticationId]
|
||||
if (authenticationManifest && pluginRegistry.isEnabled(authenticationId)
|
||||
var stillAuthenticationService = authenticationManifest
|
||||
&& Array.isArray(authenticationManifest.kinds)
|
||||
&& authenticationManifest.kinds.indexOf("service") !== -1
|
||||
&& authenticationManifest.entryPoints
|
||||
&& authenticationManifest.entryPoints.service
|
||||
if (stillAuthenticationService && pluginRegistry.isEnabled(authenticationId)
|
||||
&& shell.isAuthenticationService(authenticationManifest)) continue
|
||||
AuthServiceStore.destroy(authenticationId)
|
||||
}
|
||||
}
|
||||
|
||||
function serviceKeepLoaded(pluginId) {
|
||||
var plugins = pluginRegistry && pluginRegistry.installedPlugins
|
||||
var manifest = plugins ? plugins[pluginId] : null
|
||||
return !!(manifest && manifest.keepLoaded === true)
|
||||
}
|
||||
|
||||
// keepLoaded services (lock, idle, polkit) must survive plugin hot-reload.
|
||||
// Destroying omarchy.lock drops the ext-session-lock client while Hyprland
|
||||
// still holds the lock, which surfaces the crashed-lockscreen fallback.
|
||||
function unloadPluginServices() {
|
||||
var next = ({})
|
||||
for (var existingId in _services) {
|
||||
if (serviceKeepLoaded(existingId)) {
|
||||
next[existingId] = _services[existingId]
|
||||
continue
|
||||
}
|
||||
var inst = _services[existingId]
|
||||
if (inst && typeof inst.destroy === "function") inst.destroy()
|
||||
}
|
||||
_services = ({})
|
||||
AuthServiceStore.destroyAll()
|
||||
_services = next
|
||||
var authenticationIds = AuthServiceStore.ids()
|
||||
for (var ai = 0; ai < authenticationIds.length; ai++) {
|
||||
var authenticationId = authenticationIds[ai]
|
||||
if (!serviceKeepLoaded(authenticationId))
|
||||
AuthServiceStore.destroy(authenticationId)
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
|
||||
Reference in new issue
Block a user