Sync config from arch

- hypr/apps.lua
- hypr/autostart.lua
- hypr/envs.lua
- hypr/hyprland.lua
- hypr/hyprsunset.conf
- hypr/input.lua
- hypr/looknfeel.lua
- hypr/omasettings.lua
- hypr/xdph.conf
- omarchy/branding/about.txt
- omarchy/branding/screensaver.txt
- omarchy/extensions/omarchy-menu.jsonc
- omarchy/hooks/battery-low.d/play-warning-sound.sample
- omarchy/hooks/font-set.d/show-font-notification.sample
- omarchy/hooks/post-boot.d/weather.sample
- omarchy/hooks/post-update.d/install-voxtype.hook
- omarchy/hooks/post-update.d/setup-agent.hook
- omarchy/hooks/post-update.d/setup-fingerprint.hook
- omarchy/hooks/post-update.d/show-update-notification.sample
- omarchy/hooks/pre-refresh-pacman.d/add-custom-repo.sample
- omarchy/hooks/theme-set.d/show-theme-notification.sample
- omarchy/shell.json
- omarchy/shell.toml
- omarchy/theme.name
- omarchy/themes/azure-glow/README.md
- omarchy/themes/azure-glow/alacritty.toml
- omarchy/themes/azure-glow/btop.theme
- omarchy/themes/azure-glow/hyprland.conf
- omarchy/themes/azure-glow/hyprlock.conf
- omarchy/themes/azure-glow/icons.theme
- … 269 more
This commit is contained in:
asepharyana committed 2026-09-23 15:19:12 +07:00
commit 1cdb82a76f
300 files changed
+78143

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
# Required security review
Before committing or pushing this repository, read and follow
`skills/omaplug-security-review/SKILL.md`. It uses the local, gitignored
`omarchy-plugin-verify-security-review-public.md` as the review checklist.
Run the appropriate pre-commit or pre-push review before the operation. If the
checklist is missing, report the missing prerequisite and do not silently skip
the review. This is an agent workflow requirement, not an installed Git hook.
+106
View File
@@ -0,0 +1,106 @@
import QtQuick
import Quickshell.Io
import qs.Commons
import qs.Ui
BarWidget {
id: root
moduleName: "omaplug"
// The bar's findPanelWidget (Bar.qml) requires open/close/opened on the
// bar-widget root, and the popout coordinator compares against
// slot.activeItem — so the widget, not the nested panel, is the identity.
readonly property bool opened: panelItem ? panelItem.opened === true : false
readonly property int pendingUpdateCount: panelItem ? (panelItem.pendingUpdateCount || 0) : 0
function open() { if (panelItem) panelItem.open() }
function close() { if (panelItem) panelItem.close() }
function togglePanel() { if (panelItem) panelItem.toggle() }
// Forwarded so this widget can stand in for the panel as the bar's popout
// identity: Bar.requestPopout prefers closeForPopoutSwitch over close, and
// KeyboardPanel reads popoutSwitchClosing back off its owner.
readonly property bool popoutSwitchClosing: panelItem ? panelItem.popoutSwitchClosing === true : false
function closeForPopoutSwitch() { if (panelItem) panelItem.closeForPopoutSwitch() }
property var panelItem: null
function injectPanel() {
var target = panelLoader.item
if (!target) return
panelItem = target
if ("bar" in target) target.bar = root.bar
if ("settings" in target) target.settings = root.settings
if ("anchorItem" in target) target.anchorItem = button
if ("hostWidget" in target) target.hostWidget = root
}
implicitWidth: button.implicitWidth
implicitHeight: button.implicitHeight
onBarChanged: injectPanel()
onSettingsChanged: injectPanel()
Loader {
id: panelLoader
active: true
source: Qt.resolvedUrl("Panel.qml")
visible: false
onLoaded: {
root.injectPanel()
Qt.callLater(root.injectPanel)
}
}
IpcHandler {
target: "omaplug"
function refresh(): void { root.broadcast("refresh") }
function open(): void { root.open() }
function close(): void { root.close() }
function show(): void { root.open() }
function hide(): void { root.close() }
function toggle(): void { root.togglePanel() }
}
BarIconButton {
id: button
anchors.fill: parent
bar: root.bar
text: root.opened ? "\udb85\udcd3" : "\udb85\udcd9"
tooltipText: root.opened ? "Close Plugin Manager" : "Plugin Manager"
onPressed: function(b) {
if (b === Qt.LeftButton) root.togglePanel()
}
}
// Small pending-update indicator, independent of the manager's own
// open/close state so it stays visible whether or not the panel is open.
Rectangle {
id: updateBadge
visible: root.pendingUpdateCount > 0
anchors.top: button.top
anchors.right: button.right
z: 10
readonly property string countText: root.pendingUpdateCount > 9 ? "9+" : String(root.pendingUpdateCount)
implicitWidth: Math.max(Style.space(14), badgeLabel.implicitWidth + Style.space(6))
implicitHeight: Style.space(14)
radius: height / 2
color: Color.accent
border.width: 1
border.color: root.bar ? root.bar.background : "transparent"
Text {
id: badgeLabel
anchors.centerIn: parent
text: updateBadge.countText
color: "white"
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.font.caption * 0.85
font.bold: true
}
}
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Fross
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
File diff suppressed because it is too large. Load diff
+93
View File
@@ -0,0 +1,93 @@
# 🧩 Omaplug
**A small tool for managing your Omarchy plugins.**
[![Marketplace](https://img.shields.io/badge/Omarchy_Marketplace-listed-blue)](https://plugins.omarchy.org/plugin.html?id=omaplug) [![Verified](https://img.shields.io/badge/Automated_Security_Baseline-verified-brightgreen)](https://github.com/omacom/omarchy-plugin-marketplace/blob/main/SECURITY.md#automated-security-baseline) [![hearts](https://stats.ussego.com/api/badges/hearts/omaplug.svg)](https://stats.ussego.com/plugins/omaplug) [![views](https://stats.ussego.com/api/badges/views/omaplug.sLine truncated
Access it right from the Omarchy bar — it gives you a centralized place to view and organize the plugins you have installed. Here are a few things you can use it for:
- Easily turn individual plugins on or off as needed.
- Check for available updates and update them individually — or all at once.
- Remove plugins individually, or several in one go.
- Jump straight to each plugin's repo, or browse the [Omarchy marketplace](https://plugins.omarchy.org).
Omaplug is listed on the marketplace: [plugins.omarchy.org/plugin.html?id=omaplug](https://plugins.omarchy.org/plugin.html?id=omaplug)
## Screenshots
![Omaplug preview](preview.png)
<table>
<tr>
<td align="center"><img src="preview_main.png" alt="Main plugin list" width="280"/></td>
<td align="center"><img src="preview_check-update.png" alt="Checking for updates" width="280"/></td>
<td align="center"><img src="preview_add.png" alt="Installing a plugin" width="280"/></td>
</tr>
<tr>
<td align="center">Plugin list</td>
<td align="center">Checking for updates</td>
<td align="center">Installing a plugin</td>
</tr>
<tr>
<td align="center"><img src="preview_filter.png" alt="Scope filter" width="280"/></td>
<td align="center"><img src="preview_filter_2.png" alt="Type filter" width="280"/></td>
<td align="center"><img src="preview_action.png" alt="Row action menu" width="280"/></td>
</tr>
<tr>
<td align="center">Scope filter</td>
<td align="center">Type filter</td>
<td align="center">Row action menu</td>
</tr>
</table>
## What it can do
- **🔌 Enable / disable** — every discovered plugin (Omarchy's own and third-party) gets a simple toggle. Flipping it goes through the same registry the `omarchy plugin enable/disable` command uses, so what you see here is always what's really running.
- **🔄 Check for updates** — scans every installed third-party plugin and distinguishes clean updates from local plugins, symlinked development plugins, local changes, and genuine fetch errors.
- **⬆️ Update (or update everything)** — apply one update, or finish every proven-safe pending update from a single click, even while Omarchy reloads changed plugins.
- **➕ Install** — paste a git repo URL and add a plugin in one step. It'll warn you first that plugins run as unsandboxed code, because honesty is the default here.
- **🗑️ Remove** — third-party plugins only. Trash one, or enter Select mode to check several and remove them all at once (with a confirmation, no accidents).
- **🔗 Source link** — every git-managed plugin gets a `SOURCE` button that jumps straight to its repo page.
- **🔍 Search & filter** — narrow the list to Omarchy plugins, third-party plugins, or search by name, description, ID, author, or kind.
- **♻️ Restart shell** — if a plugin ever acts up from stale compiled code, one button clears the QML cache and restarts the shell so everything reloads fresh.
## Install
```bash
omarchy plugin add https://github.com/fross100/omaplug --enable
```
## Remove
```bash
omarchy plugin remove omaplug
```
## Remove manually
No terminal? No problem — or maybe you just like doing things the hands-on way. Here's how to remove it by hand:
1. Delete the plugin folder:
```bash
rm -rf ~/.config/omarchy/plugins/omaplug
```
2. Remove the `"id": "omaplug"` entry from the bar layout in `~/.config/omarchy/shell.json`.
3. Restart the shell to apply:
```bash
omarchy-restart-shell
```
## Requirements
- Omarchy 4.x
- Quickshell
- `git`, `jq`, and the `omarchy` CLI
- Standard coreutils (`setsid`, `nohup`, `timeout`, `sed`)
## License
[MIT](LICENSE) © 2026 Fross
+15
View File
@@ -0,0 +1,15 @@
# Omaplug 1.5.1
## Fixed
- Secured runtime state, cache, lock, installer, and keep-open writes against symlink and replacement races.
- Replaced PID-directory locking with exclusive OS-managed locks.
- Added bounded, supervised helper processes and consistent XDG configuration paths.
- Fixed nested bar-widget removal so host layouts retain remaining widgets and failures stop the disable operation.
- Kept installer completion status separate from untrusted command output.
## Tests
- Added behavioral nested-widget regression coverage.
- Added isolated filesystem safety regressions.
- Full shell and Quickshell test suite passes locally.
+23
View File
@@ -0,0 +1,23 @@
#!/bin/bash
# Coordinates plugin-state.sh across the bar's per-monitor instances. The
# omaplug widget exists once per monitor, each running its own independent
# Panel.qml and its own background auto-check Timer, so an unattended tick
# firing at (roughly) the same moment on every monitor would otherwise run
# N fully redundant git-fetch passes over every installed plugin. This wraps
# plugin-state.sh with a lock + shared cache so only one instance's tick
# does the real work; the others reuse its output.
#
# The manual "Check for updates" button in the panel bypasses this entirely
# and always calls plugin-state.sh directly - only the unattended Timer path
# (Panel.qml's autoUpdateCheckTimer) uses this wrapper.
#
# Usage: auto-check-coordinator.sh <plugins-dir>
# Output: identical CHECK/state line format to plugin-state.sh.
set -euo pipefail
PLUGINS_DIR="${1:?usage: auto-check-coordinator.sh <plugins-dir>}"
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
exec python3 "$SCRIPT_DIR/runtime-state.py" check "$PLUGINS_DIR"
+21
View File
@@ -0,0 +1,21 @@
{
"schemaVersion": 1,
"id": "omaplug",
"name": "Plugin Manager",
"version": "1.5.1",
"author": "Fross",
"license": "MIT",
"description": "Manage installed Omarchy plugins: enable/disable, update, install from git, and remove third-party plugins",
"kinds": [
"bar-widget"
],
"entryPoints": {
"barWidget": "BarWidget.qml"
},
"barWidget": {
"displayName": "Plugin Manager",
"description": "Enable/disable, update, install, and remove plugins",
"category": "System",
"allowMultiple": false
}
}
+34
View File
@@ -0,0 +1,34 @@
#!/bin/bash
set -euo pipefail
CATALOG_URL=${1:-https://plugins.omarchy.org/catalog.json}
curl -fsSL --max-time 30 --max-filesize 16777216 "$CATALOG_URL" |
jq -ce '
if (.plugins | type) != "array" then
error("catalog.plugins is not an array")
else
{
plugins: [
.plugins[]
| select((.id | type) == "string" and (.id | length) > 0)
| {
id,
verificationStatus,
verificationCommit,
verificationSnapshotStatus,
verificationCoverage,
upstreamObservedCommit,
repositoryRelease: (
if (.repositoryRelease | type) == "object" then
{url: .repositoryRelease.url}
else
null
end
)
}
]
}
end
'
+30
View File
@@ -0,0 +1,30 @@
#!/bin/bash
set -euo pipefail
id=${1:?plugin id required}
action=${2:-disable}
[[ $id =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ && $id != *..* ]] || exit 2
[[ $action == disable || $action == enable ]] || exit 2
config=$(omarchy-shell shell listShellConfig)
if [[ $action == disable ]]; then
changes=$(jq -er --arg id "$id" '
if type != "object" then error("invalid config") else . end
| (.bar.layout // {})
| if type != "object" then error("invalid layout") else . end
| [to_entries[] | .value
| if type != "array" then error("invalid section") else .[] end
| select(type == "object")
| select((.widgets | type) == "array" and (.widgets | index($id)) != null)
| [.id, (.widgets | map(select(. != $id)) | tojson)] | @tsv]
| join("\n")
' <<< "$config")
while IFS=$'\t' read -r host widgets; do
[[ -n $host ]] || continue
omarchy bar set "$host" widgets "$widgets" --json
done <<< "$changes"
exec omarchy plugin disable "$id"
fi
exec omarchy plugin "$action" "$id"
+70
View File
@@ -0,0 +1,70 @@
.pragma library
function marketplaceUrl(pluginId) {
return "https://plugins.omarchy.org/plugin.html?id=" + encodeURIComponent(String(pluginId))
}
function listingChecksUrl(pluginId) {
return marketplaceUrl(pluginId) + "#verification"
}
function shortSha(sha) {
var value = String(sha || "")
return value.length > 7 ? value.substring(0, 7) : value
}
function normalizedGitHubUrl(repoUrl) {
var url = String(repoUrl || "")
if (!/^https:\/\/github\.com\//.test(url)) return ""
return url.replace(/\.git\/?$/, "").replace(/\/+$/, "")
}
function commitUrl(repoUrl, sha) {
var url = normalizedGitHubUrl(repoUrl)
var commit = String(sha || "")
return url !== "" && commit !== "" ? url + "/commit/" + commit : ""
}
function authorUrl(repoUrl) {
var match = normalizedGitHubUrl(repoUrl).match(/^https:\/\/github\.com\/([^\/]+)/)
return match ? "https://github.com/" + match[1] : ""
}
function compareUrl(repoUrl, fromSha, toSha) {
var url = normalizedGitHubUrl(repoUrl)
var from = String(fromSha || "")
var to = String(toSha || "")
if (url === "" || from === "" || to === "" || from === to) return ""
return url + "/compare/" + from + "..." + to
}
function kindLabel(kinds, knownKinds) {
if (!kinds) return ""
var parts = String(kinds).split(",")
var labels = []
for (var i = 0; i < parts.length; i++) {
var kind = parts[i].trim()
if (kind === "") continue
var label = kind
for (var j = 0; j < knownKinds.length; j++) {
if (knownKinds[j].value === kind) {
label = knownKinds[j].label
break
}
}
labels.push(label)
}
return labels.join(", ").toUpperCase()
}
function iconColor(name) {
var value = String(name || "")
var hash = 0
for (var i = 0; i < value.length; i++) hash = (hash * 31 + value.charCodeAt(i)) | 0
var palette = [
"#c0392b", "#2980b9", "#27ae60", "#d35400", "#8e44ad",
"#16a085", "#e67e22", "#2c3e50", "#c0272f", "#21618c",
"#1e8449", "#b9770e", "#7d3c98", "#117a65", "#ca6f1e"
]
return palette[Math.abs(hash) % palette.length]
}
+115
View File
@@ -0,0 +1,115 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Commons
import qs.Ui
Rectangle {
id: dialog
required property bool open
required property string title
required property string message
required property string confirmText
required property color foreground
required property string fontFamily
required property color panelBackground
property bool dismissEnabled: true
property real maximumWidth: Style.space(360)
property color borderColor: Style.selectedStateColor(foreground, Color.accent)
property color confirmForeground: foreground
property color confirmAccent: Color.accent
property bool confirmBordered: false
property int titleWrapMode: Text.NoWrap
signal cancelRequested
signal confirmRequested
visible: open
color: Util.alpha(panelBackground, 0.7)
focus: true
Keys.priority: Keys.BeforeItem
Keys.onEscapePressed: {
if (dialog.dismissEnabled) dialog.cancelRequested()
}
MouseArea {
anchors.fill: parent
onClicked: {
if (dialog.dismissEnabled) dialog.cancelRequested()
}
}
Rectangle {
id: card
anchors.centerIn: parent
width: Math.min(parent.width - Style.space(32), dialog.maximumWidth)
height: content.implicitHeight + Style.space(36)
color: dialog.panelBackground
radius: Style.cornerRadius
border.color: dialog.borderColor
border.width: 1
ColumnLayout {
id: content
anchors.fill: parent
anchors.margins: Style.space(18)
spacing: Style.space(12)
Text {
text: dialog.title
textFormat: Text.PlainText
color: dialog.foreground
font.family: dialog.fontFamily
font.pixelSize: Style.font.title
font.bold: true
Layout.fillWidth: true
wrapMode: dialog.titleWrapMode
}
Text {
text: dialog.message
textFormat: Text.PlainText
color: Qt.darker(dialog.foreground, 1.6)
font.family: dialog.fontFamily
font.pixelSize: Style.font.bodySmall
Layout.fillWidth: true
wrapMode: Text.WordWrap
}
RowLayout {
Layout.fillWidth: true
Item { Layout.fillWidth: true }
Button {
text: "Cancel"
foreground: dialog.foreground
accent: Color.accent
fontFamily: dialog.fontFamily
fontSize: Style.font.bodySmall
horizontalPadding: Style.space(12)
verticalPadding: Style.space(6)
onClicked: dialog.cancelRequested()
}
Button {
text: dialog.confirmText
bordered: dialog.confirmBordered
foreground: dialog.confirmForeground
accent: dialog.confirmAccent
fontFamily: dialog.fontFamily
fontSize: Style.font.bodySmall
horizontalPadding: Style.space(12)
verticalPadding: Style.space(6)
onClicked: dialog.confirmRequested()
}
}
}
}
}
+138
View File
@@ -0,0 +1,138 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Commons
import qs.Ui
Rectangle {
id: dialog
required property bool open
required property bool running
required property bool failed
required property string result
required property color foreground
required property string fontFamily
required property color panelBackground
signal closeRequested
signal installRequested(string rawUrl)
visible: open
color: Util.alpha(panelBackground, 0.7)
focus: true
onOpenChanged: {
if (open) Qt.callLater(function() { urlField.forceActiveFocus() })
}
Keys.priority: Keys.BeforeItem
Keys.onEscapePressed: {
if (!dialog.running) dialog.closeRequested()
}
MouseArea {
anchors.fill: parent
onClicked: {
if (!dialog.running) dialog.closeRequested()
}
}
Rectangle {
id: card
anchors.centerIn: parent
width: Math.min(parent.width - Style.space(32), Style.space(360))
height: content.implicitHeight + Style.space(36)
color: dialog.panelBackground
radius: Style.cornerRadius
border.color: Style.selectedStateColor(dialog.foreground, Color.accent)
border.width: 1
ColumnLayout {
id: content
anchors.fill: parent
anchors.margins: Style.space(18)
spacing: Style.space(12)
Text {
text: "Install a plugin from a git repo"
textFormat: Text.PlainText
color: dialog.foreground
font.family: dialog.fontFamily
font.pixelSize: Style.font.title
font.bold: true
Layout.fillWidth: true
wrapMode: Text.WordWrap
}
Text {
text: "Plugins run as arbitrary, unsandboxed code inside your omarchy-shell process. Only add repos you trust — review the code before you enable the plugin."
textFormat: Text.PlainText
color: Qt.darker(dialog.foreground, 1.6)
font.family: dialog.fontFamily
font.pixelSize: Style.font.bodySmall
Layout.fillWidth: true
wrapMode: Text.WordWrap
}
TextField {
id: urlField
placeholderText: "https://github.com/acme/omarchy-weather.git"
foreground: dialog.foreground
accent: Color.accent
font.family: dialog.fontFamily
Layout.fillWidth: true
onAccepted: {
if (urlField.text.trim() !== "" && !dialog.running)
dialog.installRequested(urlField.text)
}
}
Text {
visible: dialog.result !== ""
text: dialog.result
textFormat: Text.PlainText
color: dialog.running ? dialog.foreground
: (dialog.failed ? Color.urgent
: Style.selectedStateColor(dialog.foreground, Color.accent))
font.family: dialog.fontFamily
font.pixelSize: Style.font.caption
Layout.fillWidth: true
wrapMode: Text.WordWrap
}
RowLayout {
Layout.fillWidth: true
Item { Layout.fillWidth: true }
Button {
text: dialog.result !== "" ? "Close" : "Cancel"
enabled: !dialog.running
foreground: dialog.foreground
accent: Color.accent
fontFamily: dialog.fontFamily
fontSize: Style.font.bodySmall
horizontalPadding: Style.space(12)
verticalPadding: Style.space(6)
onClicked: dialog.closeRequested()
}
Button {
text: dialog.running ? "Installing…" : "Install"
enabled: !dialog.running
foreground: dialog.foreground
accent: Color.accent
fontFamily: dialog.fontFamily
fontSize: Style.font.bodySmall
horizontalPadding: Style.space(12)
verticalPadding: Style.space(6)
onClicked: dialog.installRequested(urlField.text)
}
}
}
}
}
+430
View File
@@ -0,0 +1,430 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Commons
import qs.Ui
// Bar-layout board: the three bar sections side by side, each showing its
// widgets in live order. Drag a chip within a column to reorder, or across
// columns to move sections. Every drop calls back with (id, section, index)
// and the owner applies it with `omarchy bar move`, so the bar follows.
//
// Interaction notes:
// - The dragged chip stays in place as a dimmed placeholder; a thin accent
// line marks the exact insertion point. Neither changes delegate heights,
// so the list never shifts under the cursor mid-drag.
// - Plain hover does nothing — only an active drag shows indicators.
Rectangle {
id: board
required property bool open
required property var sections
required property color foreground
required property string fontFamily
required property color panelBackground
signal closeRequested
signal dropRequested(string pluginId, string section, int index)
visible: open
color: panelBackground
property bool _stayLoaded: false
onOpenChanged: {
if (open) {
_stayLoaded = true
// Never inherit a stale drag/selection look from a previous visit:
// the board always opens in a clean, unselected state.
cancelDrag()
}
}
focus: true
Keys.onEscapePressed: {
if (board.dragId !== "") board.cancelDrag()
}
// Active drag state, shared across the three columns.
property string dragId: ""
property string dragName: ""
property string dragFromSection: ""
property int dragFromIndex: -1
property string dropSection: ""
property int dropIndex: -1
property real ghostX: 0
property real ghostY: 0
function startDrag(id, name, section, index, x, y) {
board.dragId = id
board.dragName = name
board.dragFromSection = section
board.dragFromIndex = index
board.dropSection = section
board.dropIndex = index
board.ghostX = x
board.ghostY = y
}
function moveDrag(section, index, x, y) {
if (board.dragId === "") return
board.dropSection = section
board.dropIndex = index
board.ghostX = x
board.ghostY = y
}
function endDrag() {
if (board.dragId === "") return
var id = board.dragId
var section = board.dropSection
var index = board.dropIndex
var fromSection = board.dragFromSection
var fromIndex = board.dragFromIndex
board.dragId = ""
board.dragName = ""
board.dragFromSection = ""
board.dragFromIndex = -1
board.dropSection = ""
board.dropIndex = -1
// No-op when dropped back where it started.
if (section === fromSection && (index === fromIndex || index === fromIndex + 1)) return
// Account for the removal shift when moving down within one column.
var target = (section === fromSection && index > fromIndex) ? index - 1 : index
board.dropRequested(id, section, target)
}
function cancelDrag() {
board.dragId = ""
board.dragName = ""
board.dragFromSection = ""
board.dragFromIndex = -1
board.dropSection = ""
board.dropIndex = -1
}
ColumnLayout {
anchors.fill: parent
anchors.margins: Style.space(16)
spacing: Style.space(10)
RowLayout {
Layout.fillWidth: true
spacing: Style.space(8)
Label {
text: "Bar Layout"
color: board.foreground
font.family: board.fontFamily
font.pixelSize: Style.font.body
font.bold: true
Layout.fillWidth: true
}
Button {
text: "Back"
tooltipText: "Back to plugin list"
bordered: true
foreground: board.foreground
accent: Color.accent
fontFamily: board.fontFamily
fontSize: Style.font.bodySmall
horizontalPadding: Style.space(10)
verticalPadding: Style.space(5)
onClicked: board.closeRequested()
}
}
Label {
text: "Drag a widget to reorder — within its section or across sections."
textFormat: Text.PlainText
color: Qt.darker(board.foreground, 1.5)
font.family: board.fontFamily
font.pixelSize: Style.font.bodySmall
Layout.fillWidth: true
}
RowLayout {
Layout.fillWidth: true
Layout.fillHeight: true
spacing: Style.space(8)
Repeater {
model: board.sections
ColumnLayout {
id: column
required property var modelData
readonly property string section: modelData.section
readonly property var entries: modelData.entries
readonly property bool isDropColumn: board.dragId !== "" && board.dropSection === column.section
Layout.fillWidth: true
Layout.fillHeight: true
spacing: Style.space(6)
Label {
text: column.section.toUpperCase()
color: board.foreground
font.family: board.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
horizontalAlignment: Text.AlignHCenter
Layout.fillWidth: true
}
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
radius: Style.cornerRadius > 0 ? Style.cornerRadius : 4
color: column.isDropColumn
? Util.alpha(Color.accent, 0.08)
: Util.alpha(board.foreground, 0.04)
border.color: column.isDropColumn
? Util.alpha(Color.accent, 0.5)
: Util.alpha(board.foreground, 0.12)
border.width: 1
ListView {
id: sectionList
anchors.fill: parent
anchors.margins: Style.space(6)
clip: true
spacing: Style.space(4)
model: column.entries
// The board manages its own drop markers; never show the
// view's own current-item highlight or keep a selection.
currentIndex: -1
highlight: null
keyNavigationEnabled: false
Component.onCompleted: board.registerList(column.section, sectionList)
delegate: Item {
id: chip
required property var modelData
required property int index
readonly property string widgetId: modelData.id
readonly property string widgetName: modelData.name
readonly property bool isDragged: board.dragId === widgetId
// Insertion line sits above this chip.
readonly property bool showLineAbove: board.dragId !== ""
&& board.dropSection === column.section
&& board.dropIndex === index
// Insertion line sits below the last chip.
readonly property bool showLineBelow: board.dragId !== ""
&& board.dropSection === column.section
&& board.dropIndex === index + 1
&& index === sectionList.count - 1
width: sectionList.width
// Fixed height: the drop line is an overlay and never
// changes delegate geometry, so the list stays still.
height: Style.space(34)
Rectangle {
id: chipCard
anchors.fill: parent
radius: height / 2
color: chip.isDragged
? Util.alpha(Color.accent, 0.25)
: Style.normalFillFor(board.foreground, Color.accent)
border.color: chip.isDragged
? Color.accent
: Util.alpha(board.foreground, 0.15)
border.width: 1
opacity: chip.isDragged ? 0.55 : 1.0
RowLayout {
anchors.fill: parent
anchors.leftMargin: Style.space(8)
anchors.rightMargin: Style.space(8)
spacing: Style.space(6)
Text {
text: "⋮⋮"
color: Qt.darker(board.foreground, 1.4)
font.family: board.fontFamily
font.pixelSize: Style.font.caption
Layout.alignment: Qt.AlignVCenter
}
Text {
text: chip.widgetName
textFormat: Text.PlainText
elide: Text.ElideRight
maximumLineCount: 1
color: board.foreground
font.family: board.fontFamily
font.pixelSize: Style.font.bodySmall
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
}
}
MouseArea {
id: chipMouse
anchors.fill: parent
hoverEnabled: true
// The enclosing ListView steals press-and-move for
// scrolling otherwise; keep the grab for dragging.
preventStealing: true
// Arrow at rest so chips read as a plain list; the hand
// only appears while a drag is actually in flight.
cursorShape: chipMouse.dragging ? Qt.ClosedHandCursor : Qt.ArrowCursor
acceptedButtons: Qt.LeftButton
property real pressX: 0
property real pressY: 0
property bool dragging: false
onPressed: function(mouse) {
pressX = mouse.x
pressY = mouse.y
dragging = false
}
onPositionChanged: function(mouse) {
// Ignore pure hover: a drag starts only while the left
// button is held. Without this gate, the first hover
// motion trips the threshold (pressX/pressY start at 0)
// and a phantom drag follows the cursor uninvited.
if (!(mouse.buttons & Qt.LeftButton)) return
if (!dragging
&& (Math.abs(mouse.x - pressX) > 6 || Math.abs(mouse.y - pressY) > 6)) {
dragging = true
// NOTE: bare `index` — the Repeater context property.
// `chip.index` does not resolve and breaks drop math.
var start = board.mapFromItem(chip, mouse.x, mouse.y)
board.startDrag(chip.widgetId, chip.widgetName, column.section, index, start.x, start.y)
}
if (dragging) {
var p = board.mapFromItem(chip, mouse.x, mouse.y)
board.updateDropTarget(p.x, p.y)
}
}
onReleased: {
if (dragging) board.endDrag()
dragging = false
}
}
}
// Insertion marker: a thin accent line overlaid at the top
// edge (or bottom edge for the end-of-list slot). Overlay —
// never affects layout, never shifts siblings.
Rectangle {
visible: chip.showLineAbove
anchors.top: parent.top
anchors.topMargin: -Style.space(3)
anchors.left: parent.left
anchors.right: parent.right
height: Style.space(3)
radius: height / 2
color: Color.accent
}
Rectangle {
visible: chip.showLineBelow
anchors.bottom: parent.bottom
anchors.bottomMargin: -Style.space(3)
anchors.left: parent.left
anchors.right: parent.right
height: Style.space(3)
radius: height / 2
color: Color.accent
}
}
// Empty column still accepts drops: any motion over it targets
// index 0, release commits the move.
MouseArea {
anchors.fill: parent
visible: sectionList.count === 0
onPositionChanged: function(mouse) {
if (board.dragId === "") return
var p = board.mapFromItem(sectionList, mouse.x, mouse.y)
board.moveDrag(column.section, 0, p.x, p.y)
}
onReleased: {
if (board.dragId !== "") board.endDrag()
}
}
}
}
}
}
}
}
// Ghost chip following the cursor during a drag.
Rectangle {
id: ghost
visible: board.dragId !== ""
width: Math.min(Style.space(200), board.width / 3 - Style.space(16))
height: Style.space(34)
radius: height / 2
color: Util.alpha(Color.accent, 0.85)
border.color: Color.accent
border.width: 1
x: board.ghostX - width / 2
y: board.ghostY - height / 2
z: 100
opacity: 0.9
Text {
anchors.centerIn: parent
width: parent.width - Style.space(16)
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
maximumLineCount: 1
text: board.dragName
textFormat: Text.PlainText
color: Color.background
font.family: board.fontFamily
font.pixelSize: Style.font.bodySmall
font.bold: true
}
}
function updateDropTarget(x, y) {
if (board.dragId === "") return
var names = ["left", "center", "right"]
for (var i = 0; i < names.length; i++) {
var list = board._lists[names[i]]
if (!list) continue
var p = board.mapToItem(list, x, y)
if (p.x < -Style.space(20) || p.y < -Style.space(40) || p.x > list.width + Style.space(20)
|| p.y > list.height + Style.space(40)) continue
var idx = list.indexAt(list.width / 2, Math.max(0, Math.min(list.height - 1, p.y)))
board.moveDrag(names[i], idx === -1 ? list.count : idx, x, y)
return
}
}
property var _lists: ({})
function registerList(section, list) {
var next = {}
for (var k in board._lists) next[k] = board._lists[k]
next[section] = list
board._lists = next
}
// Position tracker covering gaps between chips and column padding.
// NoButton: tracks motion without stealing press/release from chips.
// Release always lands on the chip holding the mouse grab, so its own
// onReleased commits the drop no matter where the cursor ends up.
MouseArea {
anchors.fill: parent
enabled: board.dragId !== ""
acceptedButtons: Qt.NoButton
hoverEnabled: true
z: 50
onPositionChanged: function(mouse) {
board.updateDropTarget(mouse.x, mouse.y)
}
}
}
+140
View File
@@ -0,0 +1,140 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Commons
import qs.Ui
ColumnLayout {
id: actions
required property var plugin
required property bool pluginEnabled
required property bool repoKnown
required property string updateState
required property bool updateRunning
required property string updatingId
required property color foreground
required property string fontFamily
signal enabledChangeRequested(bool enabled)
signal sourceRequested(string sourceKey)
signal updateRequested(string sourceKey)
signal menuRequested(var sourceItem, real x, real y)
readonly property bool showSourceRow: plugin.updatable && repoKnown
readonly property int menuWidth: toggle.trackHeight
readonly property int sourceWidth: toggle.implicitWidth + Style.space(6) + actions.menuWidth
Layout.alignment: Qt.AlignRight | Qt.AlignVCenter
spacing: Style.space(4)
RowLayout {
Layout.alignment: Qt.AlignRight | Qt.AlignVCenter
spacing: Style.space(6)
// Active bar options cannot be disabled directly. Their reduced opacity
// mirrors the native-toggle guard while still allowing disabled plugins
// to be enabled from this row.
Item {
id: toggle
readonly property bool checked: actions.pluginEnabled
readonly property bool canToggle: actions.plugin.canDisable || !checked
readonly property int trackHeight: Math.max(22, Math.round(Style.spacing.controlHeight * 0.55))
readonly property int trackWidth: Math.round(trackHeight * 1.9)
readonly property int knobSize: Math.max(6, Math.round(trackHeight * 0.72))
readonly property int inset: Math.max(1, Math.round((trackHeight - knobSize) / 2))
implicitWidth: trackWidth
implicitHeight: trackHeight
opacity: canToggle ? 1 : 0.4
Layout.alignment: Qt.AlignVCenter
Rectangle {
width: toggle.trackWidth
height: toggle.trackHeight
radius: Style.cornerRadius > 0 ? height / 2 : 0
color: toggle.checked
? Color.accent
: Style.normalFillFor(actions.foreground, Color.accent)
Behavior on color { ColorAnimation { duration: 120 } }
Rectangle {
width: toggle.knobSize
height: toggle.knobSize
radius: Style.cornerRadius > 0 ? height / 2 : 0
x: toggle.checked ? toggle.trackWidth - width - toggle.inset : toggle.inset
anchors.verticalCenter: parent.verticalCenter
color: toggle.checked ? Color.background : Qt.darker(actions.foreground, 1.25)
Behavior on x { NumberAnimation { duration: 120; easing.type: Easing.OutCubic } }
Behavior on color { ColorAnimation { duration: 120 } }
}
}
MouseArea {
anchors.fill: parent
cursorShape: toggle.canToggle ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: {
if (!toggle.canToggle) return
Qt.callLater(function() { actions.enabledChangeRequested(!toggle.checked) })
}
}
}
Button {
id: menuButton
iconText: "\uf142"
tooltipText: "More actions"
visible: !actions.plugin.firstParty
bordered: true
borderSpec: menuButton.hot ? Border.none()
: Border.controlSpec("normal", menuButton.foreground, Color.accent)
foreground: actions.foreground
accent: Color.accent
fontFamily: actions.fontFamily
fontSize: Style.font.bodySmall
horizontalPadding: 0
verticalPadding: Style.space(3)
Layout.preferredWidth: actions.menuWidth
Layout.minimumWidth: actions.menuWidth
Layout.maximumWidth: actions.menuWidth
Layout.preferredHeight: actions.menuWidth
Layout.minimumHeight: actions.menuWidth
Layout.maximumHeight: actions.menuWidth
Layout.alignment: Qt.AlignVCenter
onClicked: actions.menuRequested(menuButton, 0, menuButton.height)
}
}
RowLayout {
visible: actions.showSourceRow
Layout.preferredWidth: actions.sourceWidth
Layout.minimumWidth: actions.sourceWidth
Layout.maximumWidth: actions.sourceWidth
Layout.alignment: Qt.AlignRight
spacing: Style.space(6)
Button {
id: sourceButton
visible: actions.plugin.updatable && actions.repoKnown
tooltipText: "Open plugin repository"
text: "SOURCE \uDB85\uDD94"
bordered: true
borderSpec: sourceButton.hot ? Border.none()
: Border.controlSpec("normal", sourceButton.foreground, Color.accent)
foreground: actions.foreground
accent: Color.accent
fontFamily: actions.fontFamily
fontSize: Style.font.caption
iconSize: Style.font.caption
horizontalPadding: 0
verticalPadding: Style.space(3)
Layout.fillWidth: true
onClicked: actions.sourceRequested(actions.plugin.sourceKey)
}
}
}
@@ -0,0 +1,166 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Commons
import qs.Ui
Rectangle {
id: menu
required property bool open
required property var plugin
required property bool pluginEnabled
required property bool repoKnown
required property string updateState
required property bool updateRunning
required property point requestedPosition
required property color foreground
required property string fontFamily
required property color panelBackground
property bool canMove: false
property string currentSection: ""
signal closeRequested
signal enabledChangeRequested(string pluginId, bool enabled)
signal sourceRequested(string sourceKey)
signal updateRequested(string sourceKey)
signal removalRequested(string pluginId)
signal moveRequested(string pluginId, string section)
visible: open
color: "transparent"
focus: true
Keys.priority: Keys.BeforeItem
Keys.onEscapePressed: menu.closeRequested()
MouseArea {
anchors.fill: parent
onClicked: menu.closeRequested()
}
Rectangle {
id: card
x: Math.min(menu.requestedPosition.x, parent.width - width - Style.space(4))
y: Math.min(menu.requestedPosition.y, parent.height - height - Style.space(4))
width: actions.implicitWidth + Style.space(8)
height: actions.implicitHeight + Style.space(8)
color: menu.panelBackground
radius: Style.cornerRadius
border.color: Util.alpha(menu.foreground, 0.2)
border.width: 1
ColumnLayout {
id: actions
anchors.fill: parent
anchors.margins: Style.space(4)
spacing: Style.space(2)
implicitWidth: Style.space(180)
Button {
text: menu.pluginEnabled
? (menu.plugin && menu.plugin.canDisable ? "Disable" : "Active bar")
: "Enable"
enabled: menu.plugin && (menu.plugin.canDisable || !menu.pluginEnabled)
foreground: menu.foreground
accent: Color.accent
fontFamily: menu.fontFamily
fontSize: Style.font.bodySmall
horizontalPadding: Style.space(8)
verticalPadding: Style.space(5)
Layout.fillWidth: true
Layout.alignment: Qt.AlignLeft
onClicked: {
menu.enabledChangeRequested(menu.plugin.id, !menu.pluginEnabled)
menu.closeRequested()
}
}
Button {
visible: menu.plugin && menu.plugin.sourceKey !== "" && menu.repoKnown
text: "Source"
foreground: menu.foreground
accent: Color.accent
fontFamily: menu.fontFamily
fontSize: Style.font.bodySmall
horizontalPadding: Style.space(8)
verticalPadding: Style.space(5)
Layout.fillWidth: true
Layout.alignment: Qt.AlignLeft
onClicked: {
menu.sourceRequested(menu.plugin.sourceKey)
menu.closeRequested()
}
}
Button {
visible: menu.plugin && menu.plugin.updatable && menu.updateState === "UPDATE"
text: menu.updateRunning ? "Updating…" : "Update"
enabled: !menu.updateRunning
foreground: menu.foreground
accent: Color.accent
fontFamily: menu.fontFamily
fontSize: Style.font.bodySmall
horizontalPadding: Style.space(8)
verticalPadding: Style.space(5)
Layout.fillWidth: true
Layout.alignment: Qt.AlignLeft
onClicked: {
menu.updateRequested(menu.plugin.sourceKey)
menu.closeRequested()
}
}
// Bar-widget placement. Only visible for widgets currently on the bar;
// the widget's own section is marked and disabled so the menu shows
// where the widget lives now.
Repeater {
model: menu.canMove ? ["left", "center", "right"] : []
Button {
required property string modelData
readonly property bool isCurrent: menu.currentSection === modelData
visible: menu.canMove
text: (isCurrent ? "● " : "") + "Move to " + modelData
enabled: !isCurrent
opacity: isCurrent ? 0.55 : 1.0
foreground: menu.foreground
accent: Color.accent
fontFamily: menu.fontFamily
fontSize: Style.font.bodySmall
horizontalPadding: Style.space(8)
verticalPadding: Style.space(5)
Layout.fillWidth: true
Layout.alignment: Qt.AlignLeft
onClicked: {
menu.moveRequested(menu.plugin.id, modelData)
menu.closeRequested()
}
}
}
Button {
visible: menu.plugin && !menu.plugin.firstParty
text: "Remove"
foreground: Color.urgent
accent: Color.urgent
fontFamily: menu.fontFamily
fontSize: Style.font.bodySmall
horizontalPadding: Style.space(8)
verticalPadding: Style.space(5)
Layout.fillWidth: true
Layout.alignment: Qt.AlignLeft
onClicked: {
var pluginId = menu.plugin.id
menu.closeRequested()
menu.removalRequested(pluginId)
}
}
}
}
}
@@ -0,0 +1,238 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Commons
import "../Presentation.js" as Presentation
RowLayout {
id: links
required property string pluginId
required property var entry
required property string localCommit
required property string repoUrl
required property color foreground
required property string fontFamily
signal openUrlRequested(string url)
readonly property string snapshotCommit: entry && typeof entry.snapshotCommit === "string"
? entry.snapshotCommit : ""
readonly property bool commitKnown: snapshotCommit !== "" && localCommit !== ""
readonly property bool commitMatches: commitKnown && snapshotCommit === localCommit
readonly property string compareUrl: Presentation.compareUrl(repoUrl, snapshotCommit, localCommit)
readonly property string snapshotUrl: Presentation.commitUrl(repoUrl, snapshotCommit)
readonly property string localUrl: Presentation.commitUrl(repoUrl, localCommit)
readonly property string marketplaceUrl: Presentation.marketplaceUrl(pluginId)
readonly property string checksUrl: Presentation.listingChecksUrl(pluginId)
spacing: Style.space(6)
Layout.fillWidth: true
// Every link can shrink or elide so this row never displaces the plugin
// actions at the right edge.
Item {
id: marketplaceLink
readonly property int gap: 2
Layout.preferredWidth: marketplaceIcon.width + marketplaceArrow.implicitWidth + gap
Layout.preferredHeight: Math.max(marketplaceIcon.height, marketplaceArrow.implicitHeight)
Layout.alignment: Qt.AlignVCenter
// Omarchy Plugins favicon, based on Lucide's ISC-licensed cable icon.
Image {
id: marketplaceIcon
anchors.verticalCenter: parent.verticalCenter
source: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAABmJLR0QA/wD/AP+gvaeTAAADb0lEQVRYhe2WTWhcVRTHf+fNNAmh1tFQsISUErMSwUicSbOpLTSLiEUTECaTEl2JKEVU6MKvjboRFAu6cCNB7CNS0SKxWbQqtJCYSYsR6spa1Cgu2spYQup85P1d3DfT92YyHcTpqv3D4557vt+599x74TZudVg9Q08Npih1PA922vylbwGUG94H2kNH6YjNrBQabfZ2UVo/gPGAY/AD5cScHVu81iqBZAOn1Pki6DVQAbgrDPE5kKLUmQBejwWfSqcprc8A96GIYMvGqnLpg+Yvn/5vCYiesC4p5dL7Q26qJouq5tL9yL4Btm7iuw9sXrnhQfOXfmqWgBdzOD20E9PUdY6ddF91qoPKpvsi8o/C4AJ7AzruppzocRVEQDfow2bBobLine truncated
width: 14
height: 14
fillMode: Image.PreserveAspectFit
cache: false
}
Text {
id: marketplaceArrow
x: marketplaceIcon.width + marketplaceLink.gap
anchors.verticalCenter: parent.verticalCenter
text: "↗"
textFormat: Text.PlainText
color: Color.accent
font.family: links.fontFamily
font.pixelSize: Style.font.caption
}
HoverHandler {
id: marketplaceHover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: links.openUrlRequested(links.marketplaceUrl)
}
ToolTip.text: links.marketplaceUrl
ToolTip.visible: marketplaceHover.hovered
ToolTip.delay: 400
}
Text {
text: "|"
textFormat: Text.PlainText
color: Qt.darker(links.foreground, 2.4)
font.family: links.fontFamily
font.pixelSize: Style.font.caption
}
Text {
visible: links.snapshotCommit !== ""
text: "\uDB81\uDF91 " + Presentation.shortSha(links.snapshotCommit)
textFormat: Text.PlainText
color: links.commitMatches ? Color.accent : Qt.darker(links.foreground, 1.6)
font.family: links.fontFamily
font.pixelSize: Style.font.caption
font.underline: snapshotHover.hovered && links.snapshotUrl !== ""
ToolTip.text: links.snapshotUrl !== "" ? links.snapshotUrl : links.snapshotCommit
ToolTip.visible: snapshotHover.hovered
ToolTip.delay: 400
HoverHandler {
id: snapshotHover
cursorShape: links.snapshotUrl !== "" ? Qt.PointingHandCursor : Qt.ArrowCursor
}
MouseArea {
anchors.fill: parent
enabled: links.snapshotUrl !== ""
cursorShape: Qt.PointingHandCursor
onClicked: links.openUrlRequested(links.snapshotUrl)
}
}
Text {
visible: links.snapshotCommit === "" && links.localCommit !== ""
text: "\uDB81\uDF91 " + Presentation.shortSha(links.localCommit) + (links.localUrl !== "" ? " ↗" : "")
textFormat: Text.PlainText
color: Qt.darker(links.foreground, 1.6)
font.family: links.fontFamily
font.pixelSize: Style.font.caption
font.underline: localOnlyHover.hovered && links.localUrl !== ""
HoverHandler {
id: localOnlyHover
cursorShape: links.localUrl !== "" ? Qt.PointingHandCursor : Qt.ArrowCursor
}
MouseArea {
anchors.fill: parent
enabled: links.localUrl !== ""
cursorShape: Qt.PointingHandCursor
onClicked: links.openUrlRequested(links.localUrl)
}
}
Text {
visible: links.snapshotCommit !== "" && links.localCommit !== "" && !links.commitMatches
text: "→"
textFormat: Text.PlainText
color: Qt.darker(links.foreground, 2.0)
font.family: links.fontFamily
font.pixelSize: Style.font.caption
}
Text {
visible: links.snapshotCommit !== "" && links.localCommit !== "" && !links.commitMatches
text: Presentation.shortSha(links.localCommit) + (links.localUrl !== "" ? " ↗" : "")
textFormat: Text.PlainText
color: Color.accent
font.family: links.fontFamily
font.pixelSize: Style.font.caption
font.underline: localHover.hovered && links.localUrl !== ""
ToolTip.text: links.localUrl !== "" ? links.localUrl : links.localCommit
ToolTip.visible: localHover.hovered
ToolTip.delay: 400
HoverHandler {
id: localHover
cursorShape: links.localUrl !== "" ? Qt.PointingHandCursor : Qt.ArrowCursor
}
MouseArea {
anchors.fill: parent
enabled: links.localUrl !== ""
cursorShape: Qt.PointingHandCursor
onClicked: links.openUrlRequested(links.localUrl)
}
}
Text {
visible: links.compareUrl !== ""
text: "|"
textFormat: Text.PlainText
color: Qt.darker(links.foreground, 2.4)
font.family: links.fontFamily
font.pixelSize: Style.font.caption
}
Text {
visible: links.compareUrl !== ""
text: "view changes ↗"
textFormat: Text.PlainText
color: Color.accent
font.family: links.fontFamily
font.pixelSize: Style.font.caption
font.underline: compareHover.hovered
elide: Text.ElideRight
ToolTip.text: links.compareUrl
ToolTip.visible: compareHover.hovered
ToolTip.delay: 400
HoverHandler {
id: compareHover
cursorShape: Qt.PointingHandCursor
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: links.openUrlRequested(links.compareUrl)
}
}
Text {
text: "|"
textFormat: Text.PlainText
color: Qt.darker(links.foreground, 2.4)
font.family: links.fontFamily
font.pixelSize: Style.font.caption
}
Text {
text: "Listing checks ↗"
textFormat: Text.PlainText
color: Color.accent
font.family: links.fontFamily
font.pixelSize: Style.font.caption
font.underline: checksHover.hovered
elide: Text.ElideRight
Layout.fillWidth: true
Layout.minimumWidth: 0
ToolTip.text: links.checksUrl
ToolTip.visible: checksHover.hovered
ToolTip.delay: 400
HoverHandler {
id: checksHover
cursorShape: Qt.PointingHandCursor
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: links.openUrlRequested(links.checksUrl)
}
}
}
+382
View File
@@ -0,0 +1,382 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Commons
import qs.Ui
import "../Presentation.js" as Presentation
Item {
id: pluginRow
required property var modelData
required property int index
required property int rowCount
required property var marketplaceEntry
required property string localCommit
required property string repoUrl
required property bool repoKnown
required property string updateState
required property bool removeSelectMode
required property bool selectedForRemoval
required property bool removingPlugin
required property bool pluginEnabled
required property bool updateRunning
required property string updatingId
required property string icon
required property var knownKinds
required property color foreground
required property string fontFamily
signal removalSelectionRequested(string pluginId)
signal enabledChangeRequested(string pluginId, bool enabled)
signal openUrlRequested(string url)
signal sourceRequested(string sourceKey)
signal updateRequested(string sourceKey)
signal menuRequested(string pluginId, var sourceItem, real x, real y)
readonly property bool listed: marketplaceEntry !== null
readonly property bool verified: listed && marketplaceEntry.verified === true
readonly property bool updateUnverified: listed && !verified
&& marketplaceEntry.snapshotStatus === "update-unverified"
readonly property string authorUrl: modelData.firstParty ? "" : Presentation.authorUrl(repoUrl)
readonly property string kindLabel: Presentation.kindLabel(modelData.kinds, knownKinds)
height: card.height + Style.space(9)
Rectangle {
id: card
width: parent.width
height: Math.max(Style.space(72), content.implicitHeight + Style.space(24))
clip: true
radius: Style.cornerRadius > 0 ? Style.cornerRadius : 4
color: hover.hovered
? Style.hoverFillFor(pluginRow.foreground, Color.accent)
: "transparent"
RowLayout {
id: content
anchors.fill: parent
anchors.leftMargin: Style.space(10)
anchors.topMargin: Style.space(10)
anchors.rightMargin: Style.space(10)
anchors.bottomMargin: Style.space(10)
spacing: Style.space(10)
Button {
visible: pluginRow.removeSelectMode
text: pluginRow.selectedForRemoval ? "\uf14a" : "\uf0c8"
tooltipText: "Select plugin for removal"
enabled: !pluginRow.removingPlugin
Layout.alignment: Qt.AlignVCenter
foreground: pluginRow.foreground
accent: Color.accent
fontFamily: pluginRow.fontFamily
fontSize: Style.font.bodySmall
horizontalPadding: Style.space(6)
verticalPadding: Style.space(3)
onClicked: pluginRow.removalSelectionRequested(pluginRow.modelData.id)
}
Item {
Layout.preferredWidth: Style.space(28)
Layout.preferredHeight: Style.space(28)
Rectangle {
x: 0
y: 0
width: Style.space(28)
height: Style.space(28)
radius: 6
clip: true
color: Presentation.iconColor(pluginRow.modelData.name)
Text {
anchors.centerIn: parent
width: parent.width - 4
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
maximumLineCount: 1
text: pluginRow.icon || pluginRow.modelData.name.trim().charAt(0).toUpperCase()
textFormat: Text.PlainText
color: "white"
font.family: pluginRow.fontFamily
font.pixelSize: Style.font.bodySmall
font.bold: true
}
}
Rectangle {
visible: pluginRow.updateState === "UPDATE"
x: -2
y: -2
width: 8
height: 8
radius: 4
color: Color.accent
border.color: pluginRow.foreground
border.width: 1
z: 1
}
}
ColumnLayout {
// preferredWidth 0 + fillWidth: take leftover row space instead of the
// description's single-line implicitWidth (otherwise Wrap/Elide never
// bind and card.clip cuts mid-word with no ellipsis).
Layout.fillWidth: true
Layout.preferredWidth: 0
Layout.minimumWidth: 0
Layout.alignment: Qt.AlignVCenter
spacing: Style.space(2)
Item {
id: nameRow
Layout.fillWidth: true
implicitHeight: Math.max(nameLabel.implicitHeight,
verificationBadge.visible ? verificationBadge.implicitHeight : 0,
versionLabel.visible ? versionLabel.implicitHeight : 0)
readonly property real spacing: Style.space(8)
Label {
id: nameLabel
text: pluginRow.modelData.name
textFormat: Text.PlainText
color: pluginRow.foreground
font.family: pluginRow.fontFamily
font.pixelSize: Style.font.body
font.bold: true
// Keep short names adjacent to their metadata. Long names use
// only the space left after the badge/version, then elide.
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
width: Math.min(implicitWidth, Math.max(0, nameRow.width
- (verificationBadge.visible ? verificationBadge.implicitWidth + nameRow.spacing : 0)
- (versionLabel.visible ? versionLabel.implicitWidth + nameRow.spacing : 0)))
elide: Label.ElideRight
// Full name only when elided · matches author/ListingLinks ToolTip style.
ToolTip.text: truncated ? text : ""
ToolTip.visible: nameHover.hovered && truncated
ToolTip.delay: 400
HoverHandler {
id: nameHover
}
}
Rectangle {
id: verificationBadge
visible: pluginRow.listed
anchors.left: nameLabel.right
anchors.leftMargin: nameRow.spacing
anchors.verticalCenter: parent.verticalCenter
radius: height / 2
implicitWidth: badgeContent.implicitWidth + Style.space(10)
implicitHeight: Style.space(16)
color: pluginRow.updateUnverified
? Qt.rgba(0.85, 0.65, 0.13, 0.18)
: pluginRow.verified
? Util.alpha(Color.accent, 0.18)
: Qt.rgba(pluginRow.foreground.r, pluginRow.foreground.g, pluginRow.foreground.b, 0.08)
Row {
id: badgeContent
anchors.centerIn: parent
spacing: Style.space(3)
Text {
visible: pluginRow.verified && !pluginRow.updateUnverified
text: "\uf058"
textFormat: Text.PlainText
color: Color.accent
font.family: pluginRow.fontFamily
font.pixelSize: Style.font.caption - 1
anchors.verticalCenter: parent.verticalCenter
}
Text {
visible: pluginRow.updateUnverified
text: "\uf071"
textFormat: Text.PlainText
color: Qt.hsla(0.12, 0.75, 0.55, 1)
font.family: pluginRow.fontFamily
font.pixelSize: Style.font.caption - 1
anchors.verticalCenter: parent.verticalCenter
}
Text {
text: pluginRow.updateUnverified ? "Update Unverified" : pluginRow.verified ? "Verified" : "Unverified"
textFormat: Text.PlainText
color: pluginRow.updateUnverified ? Qt.hsla(0.12, 0.75, 0.55, 1)
: pluginRow.verified ? Color.accent
: Qt.darker(pluginRow.foreground, 2.0)
font.family: pluginRow.fontFamily
font.pixelSize: Style.font.caption - 1
anchors.verticalCenter: parent.verticalCenter
}
}
}
Label {
id: versionLabel
visible: pluginRow.modelData.version !== "unknown"
anchors.left: verificationBadge.visible ? verificationBadge.right : nameLabel.right
anchors.leftMargin: nameRow.spacing
anchors.verticalCenter: parent.verticalCenter
text: "v" + pluginRow.modelData.version
textFormat: Text.PlainText
color: Qt.darker(pluginRow.foreground, 2.0)
font.family: pluginRow.fontFamily
font.pixelSize: Style.font.caption
}
}
Label {
id: descriptionLabel
text: pluginRow.modelData.description !== "" ? pluginRow.modelData.description : "No description"
textFormat: Text.PlainText
color: Qt.darker(pluginRow.foreground, 1.6)
font.family: pluginRow.fontFamily
font.pixelSize: Style.font.bodySmall
Layout.fillWidth: true
Layout.preferredWidth: 0
Layout.minimumWidth: 0
wrapMode: Label.Wrap
maximumLineCount: 2
elide: Label.ElideRight
ToolTip.text: truncated ? text : ""
ToolTip.visible: descriptionHover.hovered && truncated
ToolTip.delay: 400
HoverHandler {
id: descriptionHover
}
}
RowLayout {
visible: pluginRow.modelData.author !== ""
spacing: Style.space(6)
Layout.fillWidth: true
Text {
text: "by " + pluginRow.modelData.author + (pluginRow.authorUrl !== "" ? " ↗" : "")
Layout.minimumWidth: 0
elide: Text.ElideRight
textFormat: Text.PlainText
color: pluginRow.authorUrl !== "" ? Color.accent : Qt.darker(pluginRow.foreground, 2.0)
font.family: pluginRow.fontFamily
font.pixelSize: Style.font.caption
font.underline: authorHover.hovered && pluginRow.authorUrl !== ""
ToolTip.text: pluginRow.authorUrl !== "" ? pluginRow.authorUrl : ("by " + pluginRow.modelData.author)
ToolTip.visible: authorHover.hovered
ToolTip.delay: 400
HoverHandler {
id: authorHover
cursorShape: pluginRow.authorUrl !== "" ? Qt.PointingHandCursor : Qt.ArrowCursor
}
MouseArea {
anchors.fill: parent
enabled: pluginRow.authorUrl !== ""
cursorShape: Qt.PointingHandCursor
onClicked: pluginRow.openUrlRequested(pluginRow.authorUrl)
}
}
Text {
visible: pluginRow.kindLabel !== ""
text: "·"
textFormat: Text.PlainText
color: Qt.darker(pluginRow.foreground, 2.0)
font.family: pluginRow.fontFamily
font.pixelSize: Style.font.caption
}
Label {
visible: pluginRow.kindLabel !== ""
text: pluginRow.kindLabel
textFormat: Text.PlainText
color: Qt.darker(pluginRow.foreground, 2.0)
font.family: pluginRow.fontFamily
font.pixelSize: Style.font.caption
elide: Label.ElideRight
Layout.minimumWidth: 0
Layout.alignment: Qt.AlignRight
}
Item {
Layout.fillWidth: true
Layout.preferredHeight: 1
}
}
ListingLinks {
visible: pluginRow.listed
pluginId: pluginRow.modelData.id
entry: pluginRow.marketplaceEntry
localCommit: pluginRow.localCommit
repoUrl: pluginRow.repoUrl
foreground: pluginRow.foreground
fontFamily: pluginRow.fontFamily
onOpenUrlRequested: function(url) { pluginRow.openUrlRequested(url) }
}
}
Actions {
plugin: pluginRow.modelData
pluginEnabled: pluginRow.pluginEnabled
repoKnown: pluginRow.repoKnown
updateState: pluginRow.updateState
updateRunning: pluginRow.updateRunning
updatingId: pluginRow.updatingId
foreground: pluginRow.foreground
fontFamily: pluginRow.fontFamily
onEnabledChangeRequested: function(enabled) {
pluginRow.enabledChangeRequested(pluginRow.modelData.id, enabled)
}
onSourceRequested: function(sourceKey) { pluginRow.sourceRequested(sourceKey) }
onUpdateRequested: function(sourceKey) { pluginRow.updateRequested(sourceKey) }
onMenuRequested: function(sourceItem, x, y) {
pluginRow.menuRequested(pluginRow.modelData.id, sourceItem, x, y)
}
}
}
HoverHandler {
id: hover
}
TapHandler {
id: contextTap
acceptedButtons: Qt.RightButton
onTapped: function(eventPoint) {
pluginRow.menuRequested(
pluginRow.modelData.id,
card,
eventPoint.position.x,
eventPoint.position.y
)
}
}
}
Rectangle {
visible: pluginRow.index < pluginRow.rowCount - 1
anchors.top: card.bottom
anchors.topMargin: Style.space(4)
anchors.left: parent.left
anchors.right: parent.right
anchors.leftMargin: Style.space(10)
anchors.rightMargin: Style.space(10)
height: 1
color: Qt.rgba(pluginRow.foreground.r, pluginRow.foreground.g, pluginRow.foreground.b, 0.12)
}
}
+497
View File
@@ -0,0 +1,497 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Commons
import qs.Ui
import "../Presentation.js" as Presentation
Rectangle {
id: page
required property bool open
required property real topInset
required property color foreground
required property string fontFamily
required property color panelBackground
required property var rows
required property var updateStates
required property var marketplaceMap
required property bool marketplaceFetching
required property bool marketplaceFetchFailed
required property bool checking
required property bool updateRunning
required property bool updatingAll
required property int pendingCount
required property string summary
required property var iconFor
required property var whatsNewUrlFor
required property bool autoCheckEnabled
required property real autoCheckIntervalHours
readonly property var autoCheckIntervalChoices: [1, 3, 6, 12, 24]
signal closeRequested
signal tabRequested(int direction)
signal openUrlRequested(string url)
signal updatePluginRequested(string sourceKey)
signal updateAllRequested
signal autoCheckEnabledRequested(bool value)
signal autoCheckIntervalRequested(int hours)
visible: open
color: panelBackground
// Preserve scroll and delegate state after the page has been opened once.
property bool _stayLoaded: false
onOpenChanged: {
if (open) _stayLoaded = true
}
function statusText(key) {
var state = updateStates[key]
if (!state) return "Pending"
if (state === "CHECK") return "Checking…"
if (state === "CURRENT") return "Up to date"
if (state === "UPDATE") return "Update available"
if (state === "LOCAL_CHANGES") return "Local changes"
if (state === "LOCAL") return "Local plugin"
if (state === "ERROR") return "Error"
return state
}
function statusColor(key) {
var state = updateStates[key]
if (state === "UPDATE") return Style.selectedStateColor(foreground, Color.accent)
if (state === "ERROR") return Color.urgent
if (state === "CURRENT") return Qt.darker(foreground, 1.6)
if (state === "LOCAL_CHANGES" || state === "LOCAL") return Qt.darker(foreground, 1.5)
return Qt.darker(foreground, 1.4)
}
function verificationText(id, sourceKey) {
var entry = marketplaceMap[String(id)]
if (entry) {
if (entry.verified === true) return "Verified"
if (entry.snapshotStatus === "update-unverified") return "Update Unverified"
return "Unverified"
}
if (marketplaceFetching) return "Checking verification…"
if (marketplaceFetchFailed) return "Verification unavailable"
return "Not listed"
}
function verificationColor(id, sourceKey) {
var entry = marketplaceMap[String(id)]
if (entry && entry.verified === true) return Color.accent
if (entry && entry.snapshotStatus === "update-unverified")
return Qt.hsla(0.12, 0.75, 0.55, 1)
return Qt.darker(foreground, 1.6)
}
// Swallows clicks that land in a gap between controls (margins, spacing,
// below a short list) so they don't fall through to whatever is rendered
// behind this page - the main plugin list is still visible/enabled there.
// Declared before the Loader so its buttons/rows still take priority for
// clicks that actually land on them; same pattern as the dialogs'
// full-page MouseArea (panel/dialogs/Confirm.qml, Install.qml).
MouseArea {
anchors.fill: parent
onClicked: {}
}
Loader {
anchors.fill: parent
active: page.open || page._stayLoaded
sourceComponent: Component {
Item {
PanelKeyCatcher {
anchors.fill: parent
onCloseRequested: page.closeRequested()
onTabRequested: function(direction) { page.tabRequested(direction) }
}
ColumnLayout {
anchors.fill: parent
anchors.margins: Style.space(16)
anchors.topMargin: page.topInset
spacing: Style.space(10)
RowLayout {
Layout.fillWidth: true
spacing: Style.space(8)
Label {
text: "Check for updates"
color: page.foreground
font.family: page.fontFamily
font.pixelSize: Style.font.body
font.bold: true
Layout.fillWidth: true
}
Button {
text: "Back"
foreground: page.foreground
accent: Color.accent
fontFamily: page.fontFamily
fontSize: Style.font.bodySmall
horizontalPadding: Style.space(10)
verticalPadding: Style.space(5)
onClicked: page.closeRequested()
}
}
Toggle {
Layout.fillWidth: true
label: "Auto-check for updates"
description: page.autoCheckEnabled
? "Checks in the background on shell start and every " + page.autoCheckIntervalHours + "h"
: "Only checks when you open this page or click Update"
checked: page.autoCheckEnabled
foreground: page.foreground
accent: Color.accent
fontFamily: page.fontFamily
onClicked: page.autoCheckEnabledRequested(!page.autoCheckEnabled)
}
RowLayout {
Layout.fillWidth: true
visible: page.autoCheckEnabled
spacing: Style.space(6)
Label {
text: "Check every"
color: Qt.darker(page.foreground, 1.5)
font.family: page.fontFamily
font.pixelSize: Style.font.bodySmall
}
ButtonGroup {
options: page.autoCheckIntervalChoices.map(function(h) {
return { value: String(h), label: h + "h" }
})
value: String(page.autoCheckIntervalHours)
foreground: page.foreground
accent: Color.accent
fontFamily: page.fontFamily
fontSize: Style.font.caption
onChanged: function(v) { page.autoCheckIntervalRequested(Number(v)) }
}
Item {
Layout.fillWidth: true
}
}
Rectangle {
id: checkProgress
visible: page.checking
Layout.fillWidth: true
Layout.preferredHeight: 3
radius: 1.5
color: Qt.rgba(page.foreground.r, page.foreground.g, page.foreground.b, 0.15)
clip: true
Rectangle {
id: checkProgressChunk
width: checkProgress.width * 0.4
height: checkProgress.height
radius: checkProgress.radius
color: Style.selectedStateColor(page.foreground, Color.accent)
NumberAnimation on x {
running: page.checking
loops: Animation.Infinite
from: -checkProgressChunk.width
to: checkProgress.width
duration: 1100
easing.type: Easing.InOutQuad
}
}
}
ListView {
id: updateList
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
spacing: Style.space(4)
model: page.rows
ScrollBar.vertical: ScrollBar {
policy: ScrollBar.AsNeeded
implicitWidth: Style.space(6)
contentItem: Rectangle {
implicitWidth: Style.space(6)
implicitHeight: Style.space(6)
radius: width / 2
color: Util.alpha(page.foreground, 0.45)
}
}
delegate: Rectangle {
id: updateRow
required property var modelData
width: updateList.width
height: Math.max(Style.space(72), row.implicitHeight + Style.space(24))
radius: Style.cornerRadius > 0 ? Style.cornerRadius : 4
color: hover.hovered
? Style.hoverFillFor(page.foreground, Color.accent)
: "transparent"
RowLayout {
id: row
anchors.fill: parent
anchors.leftMargin: Style.space(10)
anchors.topMargin: Style.space(8)
anchors.rightMargin: Style.space(10)
anchors.bottomMargin: Style.space(12)
spacing: Style.space(10)
Rectangle {
id: updateIcon
Layout.preferredWidth: Style.space(28)
Layout.preferredHeight: Layout.preferredWidth
radius: 6
clip: true
color: Presentation.iconColor(updateRow.modelData.name)
Text {
anchors.centerIn: parent
width: parent.width - 4
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
maximumLineCount: 1
text: page.iconFor(updateRow.modelData.id) || updateRow.modelData.name.trim().charAt(0).toUpperCase()
textFormat: Text.PlainText
color: "white"
font.family: page.fontFamily
font.pixelSize: Style.font.bodySmall
font.bold: true
}
}
ColumnLayout {
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
spacing: Style.space(2)
Label {
text: updateRow.modelData.name
textFormat: Text.PlainText
color: page.foreground
font.family: page.fontFamily
font.pixelSize: Style.font.body
font.bold: true
Layout.fillWidth: true
elide: Label.ElideRight
}
RowLayout {
Layout.fillWidth: true
Layout.topMargin: Style.space(8)
spacing: Style.space(5)
Label {
text: page.statusText(updateRow.modelData.sourceKey)
textFormat: Text.PlainText
color: page.statusColor(updateRow.modelData.sourceKey)
font.family: page.fontFamily
font.pixelSize: Style.font.caption
}
Label {
text: "·"
textFormat: Text.PlainText
color: Qt.darker(page.foreground, 2.0)
font.family: page.fontFamily
font.pixelSize: Style.font.caption
}
Rectangle {
readonly property bool updateUnverified: !!page.marketplaceMap[String(updateRow.modelData.id)]
&& page.marketplaceMap[String(updateRow.modelData.id)].verified !== true
&& page.marketplaceMap[String(updateRow.modelData.id)].snapshotStatus === "update-unverified"
implicitWidth: verificationLabel.implicitWidth + Style.space(10)
implicitHeight: Style.space(16)
radius: height / 2
color: updateUnverified ? Qt.rgba(0.85, 0.65, 0.13, 0.18) : "transparent"
Label {
id: verificationLabel
anchors.centerIn: parent
text: page.verificationText(updateRow.modelData.id, updateRow.modelData.sourceKey)
textFormat: Text.PlainText
color: page.verificationColor(updateRow.modelData.id, updateRow.modelData.sourceKey)
font.family: page.fontFamily
font.pixelSize: Style.font.caption
}
}
}
Text {
id: whatsNewLink
readonly property string url: page.whatsNewUrlFor(updateRow.modelData.sourceKey, updateRow.modelData.id)
visible: page.updateStates[String(updateRow.modelData.sourceKey)] === "UPDATE" && url !== ""
text: "What's new ↗"
textFormat: Text.PlainText
color: Color.accent
font.family: page.fontFamily
font.pixelSize: Style.font.caption
font.underline: whatsNewLinkHover.hovered
ToolTip.text: url
ToolTip.visible: whatsNewLinkHover.hovered
ToolTip.delay: 400
HoverHandler {
id: whatsNewLinkHover
cursorShape: Qt.PointingHandCursor
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: page.openUrlRequested(whatsNewLink.url)
}
}
}
Item {
id: checkRing
visible: page.updateStates[updateRow.modelData.sourceKey] === "CHECK"
|| page.updateStates[updateRow.modelData.sourceKey] === undefined
Layout.alignment: Qt.AlignVCenter
Layout.preferredWidth: Style.space(18)
Layout.preferredHeight: Style.space(18)
Rectangle {
anchors.fill: parent
radius: width / 2
color: "transparent"
border.width: 2
border.color: Qt.rgba(page.foreground.r, page.foreground.g, page.foreground.b, 0.18)
}
Item {
id: checkRingArc
anchors.fill: parent
visible: page.updateStates[updateRow.modelData.sourceKey] === "CHECK"
|| page.updateStates[updateRow.modelData.sourceKey] === undefined
RotationAnimation on rotation {
running: checkRingArc.visible
loops: Animation.Infinite
from: 0
to: 360
duration: 900
}
Canvas {
anchors.fill: parent
onPaint: {
var context = getContext("2d")
context.reset()
context.strokeStyle = Style.selectedStateColor(page.foreground, Color.accent)
context.lineWidth = 2
context.lineCap = "round"
var radius = width / 2 - 2
context.beginPath()
context.arc(width / 2, height / 2, radius, -Math.PI / 2, Math.PI / 3, false)
context.stroke()
}
}
}
}
Button {
id: statusButton
readonly property string updateState: String(page.updateStates[updateRow.modelData.sourceKey] || "")
visible: updateState === "CURRENT" || updateState === "UPDATE" || updateState === "LOCAL_CHANGES"
|| updateState === "LOCAL" || updateState === "ERROR"
text: updateState === "UPDATE" ? "\uEAC2 UPDATE" : "\uF00C"
enabled: updateState === "UPDATE" && !page.updateRunning
onClicked: page.updatePluginRequested(updateRow.modelData.sourceKey)
bordered: true
borderSpec: statusButton.hot ? Border.none()
: Border.controlSpec("normal", statusButton.foreground, Color.accent)
foreground: updateState === "ERROR" ? Color.urgent : page.foreground
accent: Color.accent
fontFamily: page.fontFamily
fontSize: Style.font.caption
horizontalPadding: Style.space(8)
verticalPadding: Style.space(3)
Layout.alignment: Qt.AlignVCenter
}
}
HoverHandler {
id: hover
}
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.leftMargin: Style.space(10)
anchors.rightMargin: Style.space(10)
height: 1
color: Qt.rgba(page.foreground.r, page.foreground.g, page.foreground.b, 0.12)
}
}
}
RowLayout {
Layout.fillWidth: true
spacing: Style.space(8)
Layout.maximumHeight: implicitHeight
Label {
text: page.checking
? (page.pendingCount > 0
? "Checking… " + page.pendingCount + " update" + (page.pendingCount > 1 ? "s" : "") + " found"
: "Checking…")
: (page.pendingCount > 0
? page.pendingCount + " update" + (page.pendingCount > 1 ? "s" : "") + " available"
: "No updates available")
color: Qt.darker(page.foreground, 1.5)
font.family: page.fontFamily
font.pixelSize: Style.font.bodySmall
}
Label {
visible: page.summary !== ""
text: page.summary
textFormat: Text.PlainText
color: Style.selectedStateColor(page.foreground, Color.accent)
font.family: page.fontFamily
font.pixelSize: Style.font.bodySmall
}
Item {
Layout.fillWidth: true
}
Button {
text: page.updatingAll ? "Updating all…" : "Update all"
enabled: page.pendingCount > 0 && !page.checking && !page.updateRunning
visible: page.pendingCount > 0 && !page.checking
foreground: page.foreground
accent: Color.accent
fontFamily: page.fontFamily
fontSize: Style.font.bodySmall
horizontalPadding: Style.space(12)
verticalPadding: Style.space(3)
onClicked: page.updateAllRequested()
}
}
}
}
}
}
}
+104
View File
@@ -0,0 +1,104 @@
#!/bin/bash
# Classify every installed third-party plugin for the update UI.
#
# Output is tab-separated and streamed one record at a time:
# CHECK <id>
# <state> <id> <origin-url> <detail>
#
# Stable states are CURRENT, UPDATE, LOCAL_CHANGES, LOCAL, and ERROR.
set -uo pipefail
export GIT_TERMINAL_PROMPT=0
export GIT_SSH_COMMAND="${GIT_SSH_COMMAND:-ssh -oBatchMode=yes}"
PLUGINS_DIR="${1:-$HOME/.config/omarchy/plugins}"
emit_state() {
local state="$1"
local id="$2"
local url="${3:-}"
local detail="${4:-}"
printf '%s\t%s\t%s\t%s\n' "$state" "$id" "$url" "$detail"
}
origin_url() {
local dir="$1"
local url
url=$(git -C "$dir" remote get-url origin 2>/dev/null) || return 1
url=${url//$'\t'/ }
url=${url//$'\r'/ }
url=${url//$'\n'/ }
printf '%s' "$url"
}
[[ -d $PLUGINS_DIR ]] || exit 0
for dir in "$PLUGINS_DIR"/*; do
[[ -d $dir && -f $dir/manifest.json ]] || continue
id="${dir##*/}"
printf 'CHECK\t%s\n' "$id"
# Omarchy uses symlinks for development plugins. Even when the target is a
# Git repository, updating it here would mutate its source workspace.
if [[ -L $dir ]]; then
url=""
resolved=$(realpath -e -- "$dir" 2>/dev/null || true)
top=$(git -C "$dir" rev-parse --show-toplevel 2>/dev/null || true)
if [[ -n $resolved && $resolved == "$top" ]]; then
url=$(origin_url "$dir")
fi
emit_state LOCAL "$id" "$url" symlink
continue
fi
# Match the public Omarchy updater: only a checkout with its own .git
# directory is managed. Plain folders and linked worktrees stay local.
if [[ ! -d $dir/.git ]]; then
emit_state LOCAL "$id" "" not-git-managed
continue
fi
url=$(origin_url "$dir")
if [[ -z $url ]]; then
emit_state LOCAL "$id" "" no-origin
continue
fi
if ! head=$(git -C "$dir" rev-parse --verify HEAD 2>/dev/null); then
emit_state LOCAL "$id" "$url" no-commits
continue
fi
if ! git -C "$dir" symbolic-ref --quiet HEAD >/dev/null 2>&1; then
emit_state LOCAL_CHANGES "$id" "$url" detached
continue
fi
if ! changes=$(git -C "$dir" status --porcelain --untracked-files=normal 2>/dev/null); then
emit_state ERROR "$id" "$url" inspect-failed
continue
elif [[ -n $changes ]]; then
emit_state LOCAL_CHANGES "$id" "$url" modified
continue
fi
if ! timeout 15 git -C "$dir" fetch --quiet origin HEAD 2>/dev/null; then
emit_state ERROR "$id" "$url" fetch-failed
continue
fi
remote=$(git -C "$dir" rev-parse --verify FETCH_HEAD 2>/dev/null || true)
if [[ -z $remote ]]; then
emit_state ERROR "$id" "$url" invalid-fetch-head
elif [[ $head == "$remote" ]]; then
emit_state CURRENT "$id" "$url" equal
elif git -C "$dir" merge-base --is-ancestor "$head" "$remote" 2>/dev/null; then
emit_state UPDATE "$id" "$url" behind
elif git -C "$dir" merge-base --is-ancestor "$remote" "$head" 2>/dev/null; then
emit_state LOCAL_CHANGES "$id" "$url" ahead
else
emit_state LOCAL_CHANGES "$id" "$url" diverged
fi
done
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

+264
View File
@@ -0,0 +1,264 @@
#!/usr/bin/env python3
"""Private state and bounded process supervision for Omaplug (stdlib only)."""
import contextlib
import fcntl
import json
import os
from pathlib import Path
import re
import secrets
import selectors
import signal
import stat
import subprocess
import sys
import time
LIMIT = 131072
HERE = Path(__file__).resolve().parent
class PrivateDirectory:
def __init__(self, path):
path = Path(path)
if not path.is_absolute() or ".." in path.parts or len(path.parts) < 3:
raise ValueError("unsafe state directory")
fd = os.open("/", os.O_RDONLY | os.O_DIRECTORY)
try:
for part in path.parts[1:]:
try:
os.mkdir(part, 0o700, dir_fd=fd)
except FileExistsError:
pass
child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd)
os.close(fd)
fd = child
info = os.fstat(fd)
sticky_root = info.st_mode & stat.S_ISVTX
if (info.st_uid not in (0, os.getuid()) and not sticky_root) or (info.st_mode & 0o022 and not sticky_root):
raise ValueError("unsafe state ancestor")
info = os.fstat(fd)
if info.st_uid != os.getuid() or info.st_mode & 0o022:
raise ValueError("state directory must be privately owned")
# Safely migrate an owned 0755 directory from previous versions.
os.fchmod(fd, 0o700)
self.fd = fd
except BaseException:
os.close(fd)
raise
def close(self):
os.close(self.fd)
def check(self, name):
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}", name):
raise ValueError("invalid state name")
try:
info = os.stat(name, dir_fd=self.fd, follow_symlinks=False)
except FileNotFoundError:
return
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_nlink != 1:
raise ValueError("unsafe state file")
if info.st_mode & 0o022:
raise ValueError("writable shared state file")
def write(self, name, data):
self.check(name)
if len(data) > LIMIT:
raise ValueError("state exceeds size limit")
temporary = ".tmp-" + secrets.token_hex(16)
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
0o600, dir_fd=self.fd)
try:
with os.fdopen(fd, "wb") as stream:
stream.write(data)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, name, src_dir_fd=self.fd, dst_dir_fd=self.fd)
finally:
with contextlib.suppress(FileNotFoundError):
os.unlink(temporary, dir_fd=self.fd)
def read(self, name):
self.check(name)
fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=self.fd)
with os.fdopen(fd, "rb") as stream:
info = os.fstat(stream.fileno())
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_nlink != 1:
raise ValueError("unsafe state file")
data = stream.read(LIMIT + 1)
if len(data) > LIMIT:
raise ValueError("state exceeds size limit")
return data
@contextlib.contextmanager
def lock(self, name):
self.check(name)
fd = os.open(name, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_NONBLOCK,
0o600, dir_fd=self.fd)
try:
info = os.fstat(fd)
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_nlink != 1:
raise ValueError("unsafe lock file")
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
yield
finally:
os.close(fd)
def state_root():
base = os.environ.get("XDG_RUNTIME_DIR") or os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache")
return Path(base) / "omaplug"
def stop_child(child):
# Descendants can keep stdout open after the leader exits.
with contextlib.suppress(ProcessLookupError):
os.killpg(child.pid, signal.SIGTERM)
try:
child.wait(timeout=1)
except subprocess.TimeoutExpired:
pass
with contextlib.suppress(ProcessLookupError):
os.killpg(child.pid, signal.SIGKILL)
child.wait()
def run(command, seconds, consume):
child = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
start_new_session=True)
deadline = time.monotonic() + seconds
total = 0
try:
with selectors.DefaultSelector() as selector:
selector.register(child.stdout, selectors.EVENT_READ)
while selector.get_map():
if time.monotonic() >= deadline:
raise TimeoutError("operation timed out")
for key, _ in selector.select(0.2):
chunk = os.read(key.fileobj.fileno(), 4096)
if not chunk:
selector.unregister(key.fileobj)
continue
total += len(chunk)
if total > LIMIT:
raise ValueError("process output exceeds limit")
consume(chunk)
return child.wait(timeout=max(0.1, deadline - time.monotonic()))
finally:
stop_child(child)
child.stdout.close()
def emit(data):
sys.stdout.buffer.write(data)
sys.stdout.buffer.flush()
def check_plugins(directory, store, coordinated=True):
command = ["bash", str(HERE / "plugin-state.sh"), directory]
# A fetch is bounded individually; allow the complete sequential inventory.
count = min(1024, len(os.listdir(directory))) if os.path.isdir(directory) else 0
deadline = min(3600, max(60, count * 20))
output = bytearray()
def consume(chunk):
output.extend(chunk)
emit(chunk)
if not coordinated:
return run(command, deadline, consume)
waiting = False
until = time.monotonic() + deadline
while time.monotonic() < until:
try:
with store.lock("auto-check.flock"):
if waiting:
data = json.loads(store.read("auto-check.cache"))
if data["directory"] != directory:
raise ValueError("cached check belongs to another plugin directory")
emit(data["output"].encode())
return data["code"]
code = run(command, deadline, consume)
store.write("auto-check.cache", json.dumps({"directory": directory,
"output": output.decode(errors="replace"), "code": code}).encode())
return code
except BlockingIOError:
waiting = True
time.sleep(1)
raise TimeoutError("waiting for update check timed out")
def update(status, job, ids):
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,99}", job) or not 1 <= len(ids) <= 256:
raise ValueError("invalid update job")
path = Path(status)
with contextlib.closing(PrivateDirectory(path.parent)) as store:
with store.lock(path.name + ".flock"):
store.check(path.name)
output = bytearray()
def consume(chunk):
output.extend(chunk)
store.write(path.name, bytes(output))
return run(["bash", str(HERE / "update-helper.sh"), "--run", job, *ids],
min(3600, len(ids) * 140 + 30), consume)
def install(url, job, store):
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,99}", job):
raise ValueError("invalid install job")
if not re.fullmatch(r"(?:https://github\.com/|git@github\.com:|ssh://git@github\.com/)[A-Za-z0-9][A-Za-z0-9_.-]*/[A-Za-z0-9][A-Za-z0-9_.-]*/?", url) or ".." in url:
raise ValueError("invalid GitHub repository URL")
with store.lock("install.flock"):
store.write("install.status", json.dumps({"job": job, "running": True, "pid": os.getpid()}).encode())
output = bytearray()
try:
code = run(["timeout", "-k", "2", "180", "omarchy", "plugin", "add", url, "--yes"],
185, lambda chunk: output.extend(chunk[:max(0, 8000 - len(output))]))
result = {"running": False, "failed": code != 0, "finished": int(time.time())}
except (TimeoutError, ValueError, OSError) as error:
result = {"running": False, "failed": True, "finished": int(time.time()), "error": str(error)}
result["job"] = job
store.write("install.status", json.dumps(result).encode())
return int(result["failed"])
def main():
os.umask(0o077)
signal.signal(signal.SIGTERM, lambda *_: sys.exit(143))
action, *args = sys.argv[1:]
if action == "update":
return update(args[0], args[1], args[2:])
with contextlib.closing(PrivateDirectory(state_root())) as store:
if action in ("check", "check-manual"):
return check_plugins(args[0], store, action == "check")
if action == "install":
return install(args[0], args[1], store)
if action == "read":
emit(store.read(args[0]))
elif action == "reopen-write":
if args[0] not in ("layout", "main"):
raise ValueError("invalid reopen page")
store.write("reopen.status", json.dumps({"page": args[0], "time": time.time()}).encode())
elif action == "reopen-read":
with store.lock("reopen.flock"):
data = json.loads(store.read("reopen.status"))
os.unlink("reopen.status", dir_fd=store.fd)
if time.time() - data["time"] > 10:
return 1
print(data["page"])
elif action != "read":
raise ValueError("unknown action")
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except BlockingIOError:
sys.exit(3)
except (OSError, ValueError, IndexError, KeyError, TimeoutError) as error:
print("omaplug: " + str(error), file=sys.stderr)
sys.exit(2)
@@ -0,0 +1,56 @@
---
name: omaplug-security-review
description: Review Omaplug security before committing or pushing, or review its Marketplace verification request using the local public-review checklist.
---
# Omaplug security review
Read the complete repository-root file
`omarchy-plugin-verify-security-review-public.md` before reviewing. Resolve it
relative to this skill as `../../omarchy-plugin-verify-security-review-public.md`.
It is intentionally gitignored; never stage or force-add it. If unavailable,
report `Incomplete` and stop before commit/push rather than inventing a review.
## Select the review mode
- **Marketplace verification:** follow the document's remote-only, read-only
procedure unchanged, including exact-SHA evidence and label rules. Do not run
submitted code or make GitHub mutations during that review.
- **Before commit:** apply its security checklist to the exact staged candidate
tree and the complete runtime, including callers and sibling paths. Local
read-only inspection replaces the document's remote-only evidence requirement.
Record HEAD and staged diff/tree identity. Unstaged fixes do not count as fixes
in the candidate. Report unstaged/untracked code that affects test validity.
- **Before push:** review the exact outgoing commits and final tree against the
intended destination. Record full SHAs, check earlier findings and claimed
fixes, and refresh relevant Marketplace comments when available. A pre-commit
review is reusable only for unchanged content; inspect changes since it.
For local modes, pending remote validation for an unpublished commit is not by
itself a blocker to publishing a corrective commit. Mark remote approval as
pending/not applicable; never transfer Marketplace approval from an older SHA.
Missing evidence required for the local security decision means `Incomplete`.
## Local review gate
Keep the review itself read-only. Apply the document's trust-boundary,
filesystem, process, network, QML, IPC, and supply-chain checks. Trace every
earlier finding through the proposed fix; look for the same flaw in related
paths. Review tests as evidence, not proof of safety.
Ordinary authorized development tests may run separately using isolated
fixtures and reviewed commands. Do not install/update/remove real plugins or
change the user's shell to obtain review evidence without authorization.
Report concrete findings by severity with file/line, affected candidate,
impact, minimal fix, and a check that would prove the fix. Distinguish reproduced
failures, source-level findings, missing evidence, and untested behavior.
Return `Pass`, `Blocked`, or `Incomplete` for the local operation. Unresolved
security findings block commit/push; disclose unrelated correctness findings
and whether they affect readiness. Do not silently waive blockers. A pass is
not a security certification and does not itself authorize commit or push.
Immediately before an authorized commit/push, confirm the reviewed staged tree
or outgoing SHAs have not changed. Re-review changed content. Do not post
comments, edit issues, apply labels, or trigger releases as part of this gate.
+98
View File
@@ -0,0 +1,98 @@
import QtQuick
import Quickshell
// Standalone harness for the background auto-check logic added to
// Panel.qml (autoCheckEnabled, autoCheckIntervalHours, and the
// pendingUpdateCount filter). Panel.qml itself only loads inside the full
// Omarchy shell (qs.Ui, qs.Commons, the live PluginRegistry), so this copies
// the pure expressions to exercise them in isolation. Keep the three
// `readonly property` blocks below byte-for-byte in sync with Panel.qml if
// that logic changes.
ShellRoot {
id: root
property var settings: {
var raw = Quickshell.env("OMAPLUG_TEST_SETTINGS")
return raw ? JSON.parse(raw) : {}
}
// Mirrors qs.Ui/Panel.qml's setting(name, fallback).
function setting(name, fallback) {
var value = settings ? settings[name] : undefined
return value === undefined || value === null ? fallback : value
}
// Kept byte-identical to Panel.qml by auto-check-test.sh's sync guard;
// edit both or the build fails.
// AUTOCHECK-SETTINGS-BEGIN
readonly property bool autoCheckEnabled: root.setting("autoCheckUpdates", false) === true
// real, not int: an int property truncates any fractional hours value
// (e.g. 0.5) towards zero, which would silently turn into a zero-interval
// Timer below and spin checkUpdates() in a tight loop.
readonly property real autoCheckIntervalHours: {
var hours = Number(root.setting("autoCheckIntervalHours", 6))
return (isFinite(hours) && hours > 0) ? hours : 6
}
// AUTOCHECK-SETTINGS-END
property var updateStates: JSON.parse(Quickshell.env("OMAPLUG_TEST_UPDATE_STATES") || "{}")
property var pluginRepos: JSON.parse(Quickshell.env("OMAPLUG_TEST_PLUGIN_REPOS") || "{}")
// Kept byte-identical to Panel.qml by auto-check-test.sh's sync guard;
// edit both or the build fails.
// PENDING-UPDATE-COUNT-BEGIN
readonly property int pendingUpdateCount: {
var n = 0
for (var k in root.updateStates) {
if (root.pluginRepos[k] === undefined) continue
if (root.updateStates[k] === "UPDATE") n++
}
n
}
// PENDING-UPDATE-COUNT-END
Component.onCompleted: {
console.log("RESOLVED_ENABLED", root.autoCheckEnabled)
console.log("RESOLVED_HOURS", root.autoCheckIntervalHours)
console.log("RESOLVED_PENDING", root.pendingUpdateCount)
}
// The property under test: same shape as Panel.qml's autoUpdateCheckTimer.
// running/interval stay bound to autoCheckEnabled/autoCheckIntervalHours so
// a live settings change (see toggleTimer below) reaches this Timer the
// same way persistAutoCheckSetting()'s write to root.settings does in the
// real Panel.qml - no restart, just the binding re-evaluating.
Timer {
interval: root.autoCheckIntervalHours * 3600000
running: root.autoCheckEnabled
repeat: true
triggeredOnStart: true
onTriggered: console.log("TIMER_FIRED")
}
// Simulates the user flipping the panel's toggle (or picking a new
// interval) while the shell is already running: OMAPLUG_TEST_TOGGLE_AFTER_MS
// schedules a one-shot settings mutation partway through the run, proving
// the Timer above reacts live rather than only at startup.
property real toggleAfterMs: Number(Quickshell.env("OMAPLUG_TEST_TOGGLE_AFTER_MS") || "0")
Timer {
interval: root.toggleAfterMs
running: root.toggleAfterMs > 0
onTriggered: {
var next = {}
for (var k in root.settings) next[k] = root.settings[k]
next.autoCheckUpdates = Quickshell.env("OMAPLUG_TEST_TOGGLE_TO") === "true"
root.settings = next
console.log("TOGGLED_ENABLED", root.autoCheckEnabled)
}
}
// Bounds how long the process waits for triggers before exiting; must be
// well under any interval used above so a firing timer wins the race, and
// comfortably after toggleAfterMs so the post-toggle behavior is observed.
Timer {
interval: 1200
running: true
onTriggered: Qt.quit()
}
}
+21
View File
@@ -0,0 +1,21 @@
import QtQuick
import Quickshell
ShellRoot {
Component.onCompleted: {
Quickshell.execDetached([
Quickshell.env("OMAPLUG_TEST_HELPER"),
Quickshell.env("OMAPLUG_TEST_STATUS"),
"job-quickshell",
"slow",
"good"
])
exitTimer.start()
}
Timer {
id: exitTimer
interval: 50
onTriggered: Qt.quit()
}
}
@@ -0,0 +1,86 @@
#!/bin/bash
set -euo pipefail
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
TMP=$(mktemp -d)
trap 'rm -rf -- "$TMP"' EXIT
SCRIPTDIR="$TMP/scriptdir"
mkdir -p -- "$SCRIPTDIR"
cp -- "$ROOT/auto-check-coordinator.sh" "$SCRIPTDIR/"
cp -- "$ROOT/runtime-state.py" "$SCRIPTDIR/"
chmod +x "$SCRIPTDIR/auto-check-coordinator.sh"
CALLS="$TMP/calls.log"
: > "$CALLS"
# Stands in for plugin-state.sh: records that it ran, takes long enough that
# a second, concurrent coordinator invocation is guaranteed to still find
# the lock held, and prints one fixed, recognizable result line.
cat > "$SCRIPTDIR/plugin-state.sh" <<EOF
#!/bin/bash
printf '%s\n' "\$\$" >> "$CALLS"
sleep 0.4
printf 'UPDATE\tfixture\thttps://example.invalid/fixture.git\tbehind\n'
EOF
chmod +x "$SCRIPTDIR/plugin-state.sh"
export XDG_RUNTIME_DIR="$TMP/xdg"
mkdir -p -- "$XDG_RUNTIME_DIR"
PLUGINS_DIR="$TMP/plugins"
mkdir -p -- "$PLUGINS_DIR"
EXPECTED=$'UPDATE\tfixture\thttps://example.invalid/fixture.git\tbehind'
# Two "monitors" ticking their auto-check timer at effectively the same
# moment. The second is staggered by 50ms - well under the stub's 400ms
# hold time - so it deterministically finds the lock already taken instead
# of racing for it, making this a real leader/follower run rather than a
# coin flip.
"$SCRIPTDIR/auto-check-coordinator.sh" "$PLUGINS_DIR" > "$TMP/out1" 2>"$TMP/err1" &
pid1=$!
sleep 0.05
"$SCRIPTDIR/auto-check-coordinator.sh" "$PLUGINS_DIR" > "$TMP/out2" 2>"$TMP/err2" &
pid2=$!
wait "$pid1"
wait "$pid2"
call_count=$(wc -l < "$CALLS")
if [[ $call_count -ne 1 ]]; then
printf 'FAIL: expected plugin-state.sh to run exactly once for 2 concurrent monitors, ran %s times\n' "$call_count" >&2
cat "$CALLS" >&2
exit 1
fi
for f in out1 out2; do
if [[ $(cat "$TMP/$f") != "$EXPECTED" ]]; then
printf 'FAIL: %s did not receive the leader'"'"'s result\n' "$f" >&2
printf 'got: %q\n' "$(cat "$TMP/$f")" >&2
cat "$TMP/err1" "$TMP/err2" >&2
exit 1
fi
done
# A lock directory left behind by a process that no longer exists (e.g.
# Panel.qml's checkWatchdog SIGKILLing a coordinator mid-run, which skips
# any EXIT trap) must not wedge every future auto-check.
: > "$CALLS"
LOCK="$XDG_RUNTIME_DIR/omaplug/auto-check.flock"
printf 'stale\n' > "$LOCK"
OUT=$("$SCRIPTDIR/auto-check-coordinator.sh" "$PLUGINS_DIR")
if [[ $OUT != "$EXPECTED" ]]; then
printf 'FAIL: stale lock (dead pid) blocked a fresh check instead of being reclaimed\n' >&2
printf 'got: %q\n' "$OUT" >&2
exit 1
fi
[[ $(wc -l < "$CALLS") -eq 1 ]] || {
printf 'FAIL: stale-lock recovery did not actually run plugin-state.sh\n' >&2
exit 1
}
# The flock inode is intentionally retained; the kernel releases ownership
# when the process exits, avoiding stale-PID deletion races.
printf 'auto-check-coordinator-test: ok\n'
+172
View File
@@ -0,0 +1,172 @@
#!/bin/bash
set -euo pipefail
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
if ! command -v quickshell >/dev/null 2>&1; then
printf 'auto-check-test: skipped (quickshell not installed)\n'
exit 0
fi
run() {
QT_QPA_PLATFORM=offscreen \
OMAPLUG_TEST_SETTINGS="${1:-{\}}" \
OMAPLUG_TEST_UPDATE_STATES="${2:-{\}}" \
OMAPLUG_TEST_PLUGIN_REPOS="${3:-{\}}" \
OMAPLUG_TEST_TOGGLE_AFTER_MS="${4:-0}" \
OMAPLUG_TEST_TOGGLE_TO="${5:-false}" \
quickshell --no-color -p "$ROOT/tests/AutoCheckLogic.qml" 2>&1
}
assert_contains() {
local needle="$1"
local haystack="$2"
local label="$3"
if [[ $haystack != *"$needle"* ]]; then
printf 'FAIL: %s: expected output to contain %q\n' "$label" "$needle" >&2
printf '%s\n' "$haystack" >&2
exit 1
fi
}
assert_not_contains() {
local needle="$1"
local haystack="$2"
local label="$3"
if [[ $haystack == *"$needle"* ]]; then
printf 'FAIL: %s: expected output to NOT contain %q\n' "$label" "$needle" >&2
printf '%s\n' "$haystack" >&2
exit 1
fi
}
# True if a TIMER_FIRED line appears strictly after the first line matching
# marker, i.e. after the point in the run's timeline where the setting changed.
found_fire_after() {
local marker="$1"
local haystack="$2"
awk -v m="$marker" '
$0 ~ m { seen=1; next }
seen && /TIMER_FIRED/ { found=1 }
END { exit !found }
' <<<"$haystack"
}
assert_no_fire_after() {
local marker="$1" haystack="$2" label="$3"
if found_fire_after "$marker" "$haystack"; then
printf 'FAIL: %s: TIMER_FIRED appeared after %q\n' "$label" "$marker" >&2
printf '%s\n' "$haystack" >&2
exit 1
fi
}
assert_fire_after() {
local marker="$1" haystack="$2" label="$3"
if ! found_fire_after "$marker" "$haystack"; then
printf 'FAIL: %s: expected TIMER_FIRED after %q but none seen\n' "$label" "$marker" >&2
printf '%s\n' "$haystack" >&2
exit 1
fi
}
# Pulls the text between "<marker>-BEGIN" and "<marker>-END" comment lines.
extract_block() {
local file="$1" marker="$2"
awk -v b="${marker}-BEGIN" -v e="${marker}-END" '
$0 ~ b { flag=1; next }
$0 ~ e { flag=0 }
flag
' "$file"
}
# Panel.qml only loads inside the full Omarchy shell, so AutoCheckLogic.qml
# duplicates its auto-check expressions to test them standalone (see the
# header comment in each file). A copy that's never re-checked against the
# original is worse than no test: it would keep passing while the real
# Panel.qml logic silently drifts. This fails loudly the moment they disagree.
assert_blocks_match() {
local marker="$1" label="$2"
local a b
a=$(extract_block "$ROOT/Panel.qml" "$marker")
b=$(extract_block "$ROOT/tests/AutoCheckLogic.qml" "$marker")
if [[ -z $a || -z $b ]]; then
printf 'FAIL: %s: marker %q missing from Panel.qml or AutoCheckLogic.qml\n' "$label" "$marker" >&2
exit 1
fi
if [[ $a != "$b" ]]; then
printf 'FAIL: %s: Panel.qml and tests/AutoCheckLogic.qml have drifted apart\n' "$label" >&2
diff <(printf '%s\n' "$a") <(printf '%s\n' "$b") >&2 || true
exit 1
fi
}
assert_blocks_match AUTOCHECK-SETTINGS auto-check-settings-in-sync
assert_blocks_match PENDING-UPDATE-COUNT pending-update-count-in-sync
# The sync guard above only proves the settings math agrees; it says nothing
# about what the real Timer's onTriggered calls. Pin that down directly so a
# future edit can't quietly swap in a new/duplicate check function instead of
# the one the manual "Check for updates" button already uses.
grep -Fq 'onTriggered: root.checkUpdates(root.autoCheckCoordinatorPath)' "$ROOT/Panel.qml" || {
printf 'FAIL: Panel.qml autoUpdateCheckTimer is not wired to the existing checkUpdates()\n' >&2
exit 1
}
# Defaults: disabled, 6h interval, stays quiet until enabled.
OUT=$(run '{}')
assert_contains 'RESOLVED_ENABLED false' "$OUT" default-disabled
assert_contains 'RESOLVED_HOURS 6' "$OUT" default-hours
assert_not_contains 'TIMER_FIRED' "$OUT" default-does-not-fire
# Explicitly disabled: never fires, even once, within the test window.
OUT=$(run '{"autoCheckUpdates":false}')
assert_contains 'RESOLVED_ENABLED false' "$OUT" disabled-flag
assert_not_contains 'TIMER_FIRED' "$OUT" disabled-does-not-fire
# Custom interval passes through unchanged.
OUT=$(run '{"autoCheckIntervalHours":3}')
assert_contains 'RESOLVED_HOURS 3' "$OUT" custom-hours
# A fractional value must survive as-is, not truncate to 0 (which an `int`
# property would silently do, turning into a zero-interval, tight-looping
# Timer - not reachable through the shipped 1/3/6/12/24h picker, but settings
# are hand-editable in shell.json).
OUT=$(run '{"autoCheckIntervalHours":0.5}')
assert_contains 'RESOLVED_HOURS 0.5' "$OUT" fractional-hours-not-truncated
# Zero, negative, and non-numeric intervals all fall back to 6h rather than
# producing a zero/negative-interval Timer (which would fire in a tight loop).
OUT=$(run '{"autoCheckIntervalHours":0}')
assert_contains 'RESOLVED_HOURS 6' "$OUT" zero-hours-fallback
OUT=$(run '{"autoCheckIntervalHours":-2}')
assert_contains 'RESOLVED_HOURS 6' "$OUT" negative-hours-fallback
OUT=$(run '{"autoCheckIntervalHours":"banana"}')
assert_contains 'RESOLVED_HOURS 6' "$OUT" non-numeric-hours-fallback
# pendingUpdateCount only counts UPDATE states for keys plugin-state.sh also
# reported a repo URL for (mirrors the real property's join against
# pluginRepos, which is what keeps a stale/removed plugin out of the count).
OUT=$(run '{}' '{"a":"UPDATE","b":"CURRENT","c":"UPDATE","d":"UPDATE"}' '{"a":"url","b":"url","d":"url"}')
assert_contains 'RESOLVED_PENDING 2' "$OUT" pending-count-filters-unknown-repo
# Live toggle off: this is what happens when the user flips the panel's
# switch off while the shell keeps running - no restart, so the same process
# must stop firing right away. A sub-second interval (0.0001h) guarantees a
# refire would land inside the test window if disabling didn't take effect.
OUT=$(run '{"autoCheckIntervalHours":0.0001}' '{}' '{}' 500 false)
assert_contains 'TOGGLED_ENABLED false' "$OUT" live-disable-toggled
assert_no_fire_after 'TOGGLED_ENABLED false' "$OUT" live-disable-stops-firing
# Live toggle on: starts disabled (proven quiet up to the toggle line), then
# flips on mid-run - the resumed Timer's triggeredOnStart must fire again
# immediately rather than waiting for the next shell restart.
OUT=$(run '{"autoCheckUpdates":false}' '{}' '{}' 300 true)
assert_not_contains 'TIMER_FIRED' "${OUT%%TOGGLED_ENABLED*}" live-enable-quiet-before-toggle
assert_contains 'TOGGLED_ENABLED true' "$OUT" live-enable-toggled
assert_fire_after 'TOGGLED_ENABLED true' "$OUT" live-enable-resumes-firing
printf 'auto-check-test: ok\n'
@@ -0,0 +1,51 @@
#!/bin/bash
set -euo pipefail
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
PANEL="$ROOT/Panel.qml"
HELPER="$ROOT/marketplace-catalog.sh"
TMP=$(mktemp -d)
trap 'rm -rf -- "$TMP"' EXIT
grep -Fq 'Qt.resolvedUrl("marketplace-catalog.sh")' "$PANEL"
grep -Fq 'marketplaceProcess.command = [root.marketplaceHelperPath]' "$PANEL"
grep -Fq 'if (exitCode !== 0)' "$PANEL"
grep -Fq '!Array.isArray(catalog.plugins)' "$PANEL"
grep -Fq -- '--max-filesize 16777216' "$HELPER"
if grep -Fq 'head -c 4194304' "$PANEL"; then
printf 'FAIL: marketplace fetch must reject oversized catalogs, not truncate them\n' >&2
exit 1
fi
FIXTURE="$TMP/catalog.json"
jq -n '{
ignored: ("x" * 4500000),
plugins: [
{
id: "fixture",
verificationStatus: "verified",
verificationCommit: "abc",
upstreamObservedCommit: "def",
repositoryRelease: {url: "https://example.invalid/release"},
extra: ("y" * 10000)
}
]
}' > "$FIXTURE"
test "$(wc -c < "$FIXTURE")" -gt 4194304
SUMMARY=$("$HELPER" "file://$FIXTURE")
jq -e '.plugins | length == 1' <<< "$SUMMARY" >/dev/null
jq -e '.plugins[0] == {
id: "fixture",
verificationStatus: "verified",
verificationCommit: "abc",
verificationSnapshotStatus: null,
verificationCoverage: null,
upstreamObservedCommit: "def",
repositoryRelease: {url: "https://example.invalid/release"}
}' <<< "$SUMMARY" >/dev/null
test "$(wc -c <<< "$SUMMARY")" -lt 4096
printf 'catalog-fetch-test: ok\n'
+21
View File
@@ -0,0 +1,21 @@
#!/bin/bash
set -euo pipefail
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
PANEL="$ROOT/Panel.qml"
assert_contains() {
local needle="$1"
grep -Fq -- "$needle" "$PANEL" || {
printf 'FAIL: missing %q in %s\n' "$needle" "$PANEL" >&2
exit 1
}
}
assert_contains 'function leadingIconGlyph(text)'
assert_contains 'var first = s.charCodeAt(0)'
assert_contains 'return codePoint >= 0xF0000 && codePoint <= 0x10FFFD ? s.substring(0, 2) : ""'
assert_contains 'var live = root.leadingIconGlyph(root.liveGlyphFor(id))'
printf 'icon-glyph-test: ok\n'
@@ -0,0 +1,76 @@
#!/bin/bash
set -euo pipefail
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
HELPER="$ROOT/nested-widget-toggle.sh"
TASK_DIR=$(mktemp -d)
trap 'rm -rf -- "$TASK_DIR"' EXIT
export CALL_LOG="$TASK_DIR/calls"
export CONFIG BAR_FAIL=0
# Executable stubs also intercept the helper's final exec command.
omarchy-shell() {
[[ $* == 'shell listShellConfig' ]] || return 90
printf '%s\n' "$CONFIG"
}
omarchy() {
jq -cn --args '$ARGS.positional' -- "$@" >> "$CALL_LOG"
if [[ ${1:-} == bar && $BAR_FAIL == 1 ]]; then return 42; fi
}
mkdir "$TASK_DIR/bin"
printf '#!/bin/bash\n%s\nomarchy-shell "$@"\n' "$(declare -f omarchy-shell)" > "$TASK_DIR/bin/omarchy-shell"
printf '#!/bin/bash\n%s\nomarchy "$@"\n' "$(declare -f omarchy)" > "$TASK_DIR/bin/omarchy"
chmod +x "$TASK_DIR/bin/omarchy-shell" "$TASK_DIR/bin/omarchy"
export PATH="$TASK_DIR/bin:$PATH"
failures=0
check() {
local label=$1
shift
if ! "$@" > /dev/null; then
printf 'FAIL: %s\n' "$label" >&2
failures=$((failures + 1))
fi
}
run_helper() {
: > "$CALL_LOG"
result=0
bash "$HELPER" "$@" > "$TASK_DIR/stdout" 2> "$TASK_DIR/stderr" || result=$?
}
CONFIG='{"bar":{"layout":{"left":[{"id":"host","widgets":["before","child","after","child"]}],"right":[{"id":"other","widgets":["untouched"]}]}}}'
run_helper child disable
check 'nested disable succeeds' test "$result" -eq 0
check 'remove only requested child before disabling it' jq -se '
. == [["bar","set","host","widgets","[\"before\",\"after\"]","--json"],
["plugin","disable","child"]]
' "$CALL_LOG"
CONFIG='{"bar":{"layout":{"left":[{"id":"host","widgets":["other"]}]}}}'
run_helper standalone disable
check 'non-nested disable succeeds' test "$result" -eq 0
check 'non-nested disable does not modify hosts' jq -se '. == [["plugin","disable","standalone"]]' "$CALL_LOG"
CONFIG='{not valid json'
run_helper child disable
check 'invalid JSON fails' test "$result" -ne 0
check 'invalid JSON never disables a plugin' test ! -s "$CALL_LOG"
CONFIG='{"bar":{"layout":42}}'
run_helper child disable
check 'invalid layout fails' test "$result" -ne 0
check 'jq failure never disables a plugin' test ! -s "$CALL_LOG"
CONFIG='{"bar":{"layout":{"left":[{"id":"host","widgets":["child","other"]}]}}}'
BAR_FAIL=1
run_helper child disable
check 'host update error propagates' test "$result" -eq 42
check 'failed host update prevents plugin disable' jq -se '
. == [["bar","set","host","widgets","[\"other\"]","--json"]]
' "$CALL_LOG"
(( failures == 0 )) || exit 1
printf 'nested-widget-toggle-test: ok\n'
@@ -0,0 +1,115 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const vm = require('node:vm');
const { execFileSync } = require('node:child_process');
const source = fs.readFileSync(path.join(__dirname, '../Panel.qml'), 'utf8');
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'omaplug-metadata-'));
try {
const context = vm.createContext({
console, pluginRows: [], marketplaceMap: {},
pluginManifestProcess: {}, scanPluginRepos() {},
Quickshell: { env: () => temporary }
});
context.root = context;
for (const name of ['applyPluginList', 'applyPluginMetadata', 'mergeMarketplaceMetadata', 'applyMarketplaceCatalog']) {
const start = source.indexOf(' function ' + name + '(');
assert.notEqual(start, -1);
const end = source.indexOf('\n }', start) + 4;
vm.runInContext(source.slice(start, end), context);
}
const manifests = [
['shell/plugins/panels/clock/clock.manifest.json', 'omarchy.clock'],
['.config/omarchy/plugins/test plugin/manifest.json', 'test.plugin']
];
for (const [relative, id] of manifests) {
const file = path.join(temporary, relative);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify({ id, description: 'Installed ' + id }));
}
const catalog = JSON.stringify([
{ id: 'omarchy.clock', firstParty: true, enabled: true, kinds: ['bar-widget'] },
{ id: 'test.plugin', enabled: false, kinds: ['panel'] },
{ id: 'catalog.only', enabled: true, kinds: ['panel'] }
]);
context.marketplaceMap = {
'test.plugin': { description: 'Marketplace description' },
'catalog.only': { description: 'Cached description' }
};
// Repeat to cover a refresh after the marketplace has already loaded.
for (let refresh = 0; refresh < 2; refresh++) {
context.applyPluginList(catalog);
const [command, ...args] = context.pluginManifestProcess.command;
const output = execFileSync(command, args, {
env: { ...process.env, HOME: temporary }, encoding: 'utf8'
});
context.applyPluginMetadata(output);
const rows = Object.fromEntries(context.pluginRows.map(row => [row.id, row]));
assert.equal(rows['omarchy.clock'].description, 'Installed omarchy.clock');
assert.equal(rows['test.plugin'].description, 'Installed test.plugin');
assert.equal(rows['catalog.only'].description, 'Cached description');
assert.equal(rows['test.plugin'].enabled, false);
assert.equal(rows['omarchy.clock'].enabled, true);
// A later marketplace response must not replace installed metadata.
context.mergeMarketplaceMetadata(context.marketplaceMap);
assert.equal(context.pluginRows.find(row => row.id === 'test.plugin').description,
'Installed test.plugin');
}
console.log('plugin-metadata-test: ok');
const page = fs.readFileSync(path.join(__dirname, '../panel/updates/Page.qml'), 'utf8');
const start = page.indexOf(' function verificationText(');
vm.runInContext(page.slice(start, page.indexOf('\n }', start) + 4), context);
context.applyMarketplaceCatalog(JSON.stringify({ plugins: [{
id: 'listed', verificationStatus: 'unverified',
verificationSnapshotStatus: 'verified', verificationCoverage: 'update-unverified'
}] }));
assert.equal(context.marketplaceMap.listed.snapshotStatus, 'update-unverified');
context.updateStates = { folder: 'UPDATE' };
context.marketplaceFetching = false;
context.marketplaceFetchFailed = false;
assert.equal(context.verificationText('listed', 'folder'), 'Update Unverified');
assert.equal(context.verificationText('unlisted', 'folder'), 'Not listed');
context.updateStates.folder = 'CURRENT';
assert.equal(context.verificationText('listed', 'folder'), 'Update Unverified');
context.marketplaceMap.listed.verified = true;
assert.equal(context.verificationText('listed', 'folder'), 'Verified');
context.marketplaceMap.listed.verified = false;
context.marketplaceMap.listed.snapshotStatus = '';
assert.equal(context.verificationText('listed', 'folder'), 'Unverified');
context.updateStates.folder = 'UPDATE';
assert.equal(context.verificationText('listed', 'folder'), 'Unverified');
console.log('update-verification-test: ok');
const reopenStart = source.indexOf(' function keepOpenAcrossRebuild(');
vm.runInContext(source.slice(reopenStart, source.indexOf('\n }', reopenStart) + 4), context);
context.keepOpenFlagWrite = {};
context.layoutPageOpen = true;
context.keepOpenAcrossRebuild();
assert.equal(context.keepOpenFlagWrite.command.at(-1), 'layout');
assert.equal(context.keepOpenFlagWrite.running, true);
context.layoutPageOpen = false;
context.keepOpenAcrossRebuild();
assert.equal(context.keepOpenFlagWrite.command.at(-1), 'main');
assert.match(source, /^ keepOpenFlagRead\.running = true$/m);
console.log('panel-reopen-test: ok');
const settingsStart = source.indexOf(' function persistAutoCheckSetting(');
vm.runInContext(source.slice(settingsStart, source.indexOf('\n }', settingsStart) + 4), context);
context.autoCheckSettingsProcess = { running: false };
context.moduleName = 'omaplug';
context.persistAutoCheckSetting({ autoCheckUpdates: false });
assert.deepEqual(Array.from(context.autoCheckSettingsProcess.command),
['omarchy', 'bar', 'set', 'omaplug', 'autoCheckUpdates', 'false', '--json']);
assert.equal(context.autoCheckSettingsProcess.running, true);
context.persistAutoCheckSetting({ autoCheckIntervalHours: 3 });
assert.equal(context.autoCheckSettingsProcess.command[4], 'autoCheckUpdates');
context.autoCheckSettingsProcess.running = false;
context.persistAutoCheckSetting({ autoCheckIntervalHours: 3 });
assert.deepEqual(Array.from(context.autoCheckSettingsProcess.command),
['omarchy', 'bar', 'set', 'omaplug', 'autoCheckIntervalHours', '3', '--json']);
console.log('auto-check-settings-cli-test: ok');
} finally {
fs.rmSync(temporary, { recursive: true, force: true });
}
+112
View File
@@ -0,0 +1,112 @@
#!/bin/bash
set -euo pipefail
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
TMP=$(mktemp -d)
trap 'rm -rf -- "$TMP"' EXIT
PLUGINS="$TMP/plugins"
SEED="$TMP/seed"
REMOTE="$TMP/origin.git"
mkdir -p -- "$PLUGINS"
git init --bare -q "$REMOTE"
git -C "$REMOTE" symbolic-ref HEAD refs/heads/main
git init -q -b main "$SEED"
git -C "$SEED" config user.name "Omaplug Tests"
git -C "$SEED" config user.email "omaplug-tests@example.invalid"
printf '{"id":"fixture"}\n' > "$SEED/manifest.json"
git -C "$SEED" add manifest.json
git -C "$SEED" commit -qm "initial"
git -C "$SEED" remote add origin "$REMOTE"
# Fixture pushes must not inherit the user's global hooks. Those hooks may
# intentionally reject non-GitHub remotes, while this test uses a local bare
# repository by design.
git -C "$SEED" -c core.hooksPath=/dev/null push -q -u origin main
# Keep one checkout at the initial commit. It becomes behind after the second
# upstream commit, and a local commit on a copy of it creates a divergence.
git clone -q "$REMOTE" "$PLUGINS/behind"
git clone -q "$REMOTE" "$PLUGINS/diverged"
printf '{"id":"fixture","version":2}\n' > "$SEED/manifest.json"
git -C "$SEED" add manifest.json
git -C "$SEED" commit -qm "upstream update"
git -C "$SEED" -c core.hooksPath=/dev/null push -q
git clone -q "$REMOTE" "$PLUGINS/current"
git clone -q "$REMOTE" "$PLUGINS/dirty"
git clone -q "$REMOTE" "$PLUGINS/ahead"
git clone -q "$REMOTE" "$PLUGINS/fetch-error"
git clone -q "$REMOTE" "$PLUGINS/detached"
printf '\n' >> "$PLUGINS/dirty/manifest.json"
git -C "$PLUGINS/detached" checkout -q --detach
git -C "$PLUGINS/ahead" config user.name "Omaplug Tests"
git -C "$PLUGINS/ahead" config user.email "omaplug-tests@example.invalid"
printf '{"id":"fixture","version":3}\n' > "$PLUGINS/ahead/manifest.json"
git -C "$PLUGINS/ahead" add manifest.json
git -C "$PLUGINS/ahead" commit -qm "local commit"
git -C "$PLUGINS/diverged" config user.name "Omaplug Tests"
git -C "$PLUGINS/diverged" config user.email "omaplug-tests@example.invalid"
printf '{"id":"fixture","local":true}\n' > "$PLUGINS/diverged/manifest.json"
git -C "$PLUGINS/diverged" add manifest.json
git -C "$PLUGINS/diverged" commit -qm "divergent local commit"
git -C "$PLUGINS/fetch-error" remote set-url origin "$TMP/missing.git"
mkdir -p -- "$PLUGINS/plain" "$PLUGINS/no-origin"
printf '{"id":"plain"}\n' > "$PLUGINS/plain/manifest.json"
printf '{"id":"no-origin"}\n' > "$PLUGINS/no-origin/manifest.json"
git init -q "$PLUGINS/no-origin"
git -C "$PLUGINS/no-origin" config user.name "Omaplug Tests"
git -C "$PLUGINS/no-origin" config user.email "omaplug-tests@example.invalid"
git -C "$PLUGINS/no-origin" add manifest.json
git -C "$PLUGINS/no-origin" commit -qm "local plugin"
ln -s -- "$PLUGINS/current" "$PLUGINS/symlinked"
OUTPUT=$($ROOT/plugin-state.sh "$PLUGINS")
state_for() {
local id="$1"
awk -F '\t' -v id="$id" '$1 != "CHECK" && $2 == id { print $1 }' <<< "$OUTPUT"
}
detail_for() {
local id="$1"
awk -F '\t' -v id="$id" '$1 != "CHECK" && $2 == id { print $4 }' <<< "$OUTPUT"
}
assert_eq() {
local expected="$1"
local actual="$2"
local label="$3"
if [[ $actual != "$expected" ]]; then
printf 'FAIL: %s: expected %q, got %q\n' "$label" "$expected" "$actual" >&2
printf '%s\n' "$OUTPUT" >&2
exit 1
fi
}
assert_eq CURRENT "$(state_for current)" current
assert_eq UPDATE "$(state_for behind)" behind
assert_eq LOCAL_CHANGES "$(state_for dirty)" dirty
assert_eq modified "$(detail_for dirty)" dirty-detail
assert_eq LOCAL_CHANGES "$(state_for detached)" detached
assert_eq detached "$(detail_for detached)" detached-detail
assert_eq LOCAL_CHANGES "$(state_for ahead)" ahead
assert_eq ahead "$(detail_for ahead)" ahead-detail
assert_eq LOCAL_CHANGES "$(state_for diverged)" diverged
assert_eq diverged "$(detail_for diverged)" diverged-detail
assert_eq LOCAL "$(state_for plain)" plain
assert_eq LOCAL "$(state_for no-origin)" no-origin
assert_eq LOCAL "$(state_for symlinked)" symlinked
assert_eq ERROR "$(state_for fetch-error)" fetch-error
current_url=$(awk -F '\t' '$1 == "CURRENT" && $2 == "current" { print $3 }' <<< "$OUTPUT")
assert_eq "$REMOTE" "$current_url" origin-url-without-trailing-space
printf 'plugin-state-test: ok\n'
@@ -0,0 +1,52 @@
#!/bin/bash
set -euo pipefail
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
if ! command -v quickshell >/dev/null 2>&1; then
printf 'quickshell-detached-test: skipped (quickshell not installed)\n'
exit 0
fi
TMP=$(mktemp -d)
trap 'rm -rf -- "$TMP"' EXIT
BIN="$TMP/bin"
TEST_HOME="$TMP/home"
PLUGINS="$TEST_HOME/.config/omarchy/plugins"
STATUS="$TMP/update.status"
mkdir -p -- "$BIN" "$PLUGINS"
for id in slow good; do
dir="$PLUGINS/$id"
git init -q -b main "$dir"
git -C "$dir" config user.name "Omaplug Tests"
git -C "$dir" config user.email "omaplug-tests@example.invalid"
printf '{"id":"%s"}\n' "$id" > "$dir/manifest.json"
git -C "$dir" add manifest.json
git -C "$dir" commit -qm "fixture"
done
cat > "$BIN/omarchy" <<'EOF'
#!/bin/bash
[[ ${3:-} == slow ]] && sleep 0.4
printf 'updated %s\n' "${3:-}"
EOF
chmod +x "$BIN/omarchy"
PATH="$BIN:$PATH" \
HOME="$TEST_HOME" \
OMAPLUG_TEST_HELPER="$ROOT/update-helper.sh" \
OMAPLUG_TEST_STATUS="$STATUS" \
QT_QPA_PLATFORM=offscreen \
quickshell --no-color -p "$ROOT/tests/DetachedLaunch.qml" >/dev/null 2>&1
for _ in {1..200}; do
grep -Fqx $'done\t2\t0' "$STATUS" 2>/dev/null && break
sleep 0.01
done
grep -Fqx $'job\tjob-quickshell' "$STATUS"
grep -Fqx $'done\t2\t0' "$STATUS"
printf 'quickshell-detached-test: ok\n'
+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash
set -euo pipefail
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
"$ROOT/plugin-state-test.sh"
"$ROOT/update-helper-test.sh"
"$ROOT/quickshell-detached-test.sh"
bash "$ROOT/verification-status-test.sh"
node "$ROOT/plugin-metadata-test.cjs"
"$ROOT/auto-check-test.sh"
"$ROOT/auto-check-coordinator-test.sh"
"$ROOT/nested-widget-toggle-test.sh"
python3 "$ROOT/security-regression-test.py"
@@ -0,0 +1,40 @@
#!/usr/bin/env python3
"""Isolated filesystem regressions; never touches installed plugins."""
import os
from pathlib import Path
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
class StateSafety(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory(prefix="omaplug-security-")
self.addCleanup(self.tmp.cleanup)
self.path = Path(self.tmp.name)
self.env = dict(os.environ, XDG_RUNTIME_DIR=str(self.path))
(self.path / "omaplug").mkdir(mode=0o700)
(self.path / "plugins").mkdir()
self.victim = self.path / "victim"
self.victim.write_text("keep me")
def run_helper(self, *args):
return subprocess.run(args, env=self.env, capture_output=True, timeout=10)
def test_cache_symlink_never_truncates_target(self):
(self.path / "omaplug/auto-check.cache.tmp").symlink_to(self.victim)
self.run_helper("bash", str(ROOT / "auto-check-coordinator.sh"), str(self.path / "plugins"))
self.assertEqual(self.victim.read_text(), "keep me")
def test_update_status_symlink_is_rejected(self):
status = self.path / "update.status"
status.symlink_to(self.victim)
result = self.run_helper("bash", str(ROOT / "update-helper.sh"), str(status), "test", "invalid/id")
self.assertNotEqual(result.returncode, 0)
self.assertEqual(self.victim.read_text(), "keep me")
if __name__ == "__main__":
unittest.main()
+124
View File
@@ -0,0 +1,124 @@
#!/bin/bash
set -euo pipefail
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
TMP=$(mktemp -d)
trap 'rm -rf -- "$TMP"' EXIT
BIN="$TMP/bin"
LOG="$TMP/calls.log"
STATUS="$TMP/update.status"
TEST_HOME="$TMP/home"
PLUGINS="$TEST_HOME/.config/omarchy/plugins"
mkdir -p -- "$BIN" "$PLUGINS"
make_plugin() {
local id="$1"
local dir="$PLUGINS/$id"
git init -q -b main "$dir"
git -C "$dir" config user.name "Omaplug Tests"
git -C "$dir" config user.email "omaplug-tests@example.invalid"
printf '{"id":"%s"}\n' "$id" > "$dir/manifest.json"
git -C "$dir" add manifest.json
git -C "$dir" commit -qm "fixture"
}
for id in good fail slow dirty detached; do make_plugin "$id"; done
printf '\n' >> "$PLUGINS/dirty/manifest.json"
git -C "$PLUGINS/detached" checkout -q --detach
ln -s -- "$PLUGINS/good" "$PLUGINS/linked"
cat > "$BIN/omarchy" <<'EOF'
#!/bin/bash
printf '%s\n' "$*" >> "$OMAPLUG_TEST_LOG"
case "${3:-}" in
slow) sleep 0.4 ;;
esac
if [[ ${3:-} == fail ]]; then
printf 'could not update %s\n' "$3" >&2
exit 7
fi
printf 'updated %s\n' "${3:-}"
EOF
chmod +x "$BIN/omarchy"
export PATH="$BIN:$PATH"
export OMAPLUG_TEST_LOG="$LOG"
export HOME="$TEST_HOME"
# A prior run may have left a status file created under a permissive umask.
# The helper must tighten it before writing any job details.
: > "$STATUS"
chmod 0644 "$STATUS"
assert_line() {
local line="$1"
local file="$2"
grep -Fqx -- "$line" "$file" || {
printf 'FAIL: missing line %q in %s\n' "$line" "$file" >&2
cat "$file" >&2
exit 1
}
}
if "$ROOT/update-helper.sh" "$STATUS" job-direct good fail dirty detached linked 'bad/id'; then
printf 'FAIL: a partially failed update job returned success\n' >&2
exit 1
fi
assert_line $'job\tjob-direct' "$STATUS"
assert_line $'start\tgood' "$STATUS"
assert_line $'ok\tgood\tupdated good' "$STATUS"
assert_line $'start\tfail' "$STATUS"
assert_line $'failed\tfail\tcould not update fail' "$STATUS"
assert_line $'failed\tdirty\tplugin gained local changes after the update check' "$STATUS"
assert_line $'failed\tdetached\tplugin is on a detached HEAD' "$STATUS"
assert_line $'failed\tlinked\tplugin became a symlink after the update check' "$STATUS"
assert_line $'failed\tbad/id\tinvalid plugin id' "$STATUS"
assert_line $'done\t1\t5' "$STATUS"
[[ $(stat -c '%a' "$STATUS") == 600 ]] || {
printf 'FAIL: status file mode is %s instead of 600\n' "$(stat -c '%a' "$STATUS")" >&2
exit 1
}
[[ $(tail -n 1 "$STATUS") == $'done\t1\t5' ]] || {
printf 'FAIL: done is not the final status record\n' >&2
cat "$STATUS" >&2
exit 1
}
assert_line 'plugin update good --yes' "$LOG"
assert_line 'plugin update fail --yes' "$LOG"
[[ $(wc -l < "$LOG") -eq 2 ]] || {
printf 'FAIL: invalid plugin id reached omarchy\n' >&2
cat "$LOG" >&2
exit 1
}
# A live runner owns the stable status path. A second runner must fail without
# replacing or appending to that job's status stream.
: > "$LOG"
"$ROOT/update-helper.sh" "$STATUS" job-first slow &
first_pid=$!
for _ in {1..100}; do
[[ -f $STATUS.flock ]] && break
sleep 0.01
done
sleep 0.05
if "$ROOT/update-helper.sh" "$STATUS" job-second good; then
printf 'FAIL: a concurrent update runner acquired the same lock\n' >&2
exit 1
else
second_rc=$?
fi
[[ $second_rc -eq 3 ]] || {
printf 'FAIL: concurrent runner returned %s instead of 3\n' "$second_rc" >&2
exit 1
}
wait "$first_pid"
assert_line $'job\tjob-first' "$STATUS"
if grep -Fq $'job\tjob-second' "$STATUS"; then
printf 'FAIL: concurrent runner clobbered the active status\n' >&2
exit 1
fi
printf 'update-helper-test: ok\n'
@@ -0,0 +1,28 @@
#!/bin/bash
set -euo pipefail
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
PAGE="$ROOT/panel/updates/Page.qml"
PANEL="$ROOT/Panel.qml"
assert_contains() {
local needle="$1"
local file="$2"
grep -Fq -- "$needle" "$file" || {
printf 'FAIL: missing %q in %s\n' "$needle" "$file" >&2
exit 1
}
}
assert_contains 'required property var marketplaceMap' "$PAGE"
assert_contains 'required property bool marketplaceFetchFailed' "$PAGE"
assert_contains 'function verificationText(id, sourceKey)' "$PAGE"
assert_contains 'page.verificationText(updateRow.modelData.id, updateRow.modelData.sourceKey)' "$PAGE"
assert_contains 'marketplaceMap: root.marketplaceMap' "$PANEL"
assert_contains 'marketplaceFetching: root.marketplaceFetching' "$PANEL"
assert_contains 'marketplaceFetchFailed: root.marketplaceFetchFailed' "$PANEL"
assert_contains 'marketplaceProcess.command = [root.marketplaceHelperPath]' "$PANEL"
assert_contains '--max-filesize 16777216' "$ROOT/marketplace-catalog.sh"
printf 'verification-status-test: ok\n'
+100
View File
@@ -0,0 +1,100 @@
#!/bin/bash
# Detached update runner for omaplug.
#
# The Omarchy shell hot-reloads plugins whenever a checkout changes. Running
# updates from a tracked QML Process therefore destroys the process owner after
# the first merge. Quickshell launches this helper detached, and it records
# progress outside the watched plugin directory so a reloaded panel can
# reconnect.
#
# Usage: update-helper.sh <status-file> <job-id> <plugin-id> [plugin-id ...]
set -uo pipefail
umask 077
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
if [[ ${1:-} != --run ]]; then
exec python3 "$SCRIPT_DIR/runtime-state.py" update "$@"
fi
shift
JOB_ID="${1:-}"
[[ $JOB_ID =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || exit 2
shift
(( $# > 0 )) || exit 2
PLUGINS_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/omarchy/plugins"
write_status() {
printf '%s\n' "$1"
}
one_line() {
local text="$1"
local last
last=$(printf '%s\n' "$text" | awk 'NF { line=$0 } END { print line }')
last=${last//$'\t'/ }
last=${last//$'\r'/ }
printf '%.300s' "$last"
}
preflight_error() {
local id="$1"
local dir="$PLUGINS_DIR/$id"
local changes
if [[ -L $dir ]]; then
printf 'plugin became a symlink after the update check'
elif [[ ! -d $dir/.git ]]; then
printf 'plugin is no longer a direct git checkout'
elif ! git -C "$dir" rev-parse --verify HEAD >/dev/null 2>&1; then
printf 'plugin has no commit to update'
elif ! git -C "$dir" symbolic-ref --quiet HEAD >/dev/null 2>&1; then
printf 'plugin is on a detached HEAD'
elif ! changes=$(git -C "$dir" status --porcelain --untracked-files=normal 2>/dev/null); then
printf 'could not inspect plugin changes'
elif [[ -n $changes ]]; then
printf 'plugin gained local changes after the update check'
else
return 1
fi
}
write_status $'version\t1'
write_status $'job\t'"$JOB_ID"
write_status $'pid\t'"$$"
write_status $'started\t'"$(date +%s)"
write_status $'total\t'"$#"
updated=0
failed=0
for id in "$@"; do
if [[ ! $id =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ || $id == *..* ]]; then
write_status $'failed\t'"$(one_line "$id")"$'\tinvalid plugin id'
failed=$((failed + 1))
continue
fi
if safety_error=$(preflight_error "$id"); then
write_status $'failed\t'"$id"$'\t'"$safety_error"
failed=$((failed + 1))
continue
fi
write_status $'start\t'"$id"
out=$(timeout -k 2 120 omarchy plugin update "$id" --yes 2>&1 | { head -c 8000; cat >/dev/null; }; exit "${PIPESTATUS[0]}")
rc=$?
detail=$(one_line "$out")
if (( rc == 0 )); then
write_status $'ok\t'"$id"$'\t'"$detail"
updated=$((updated + 1))
else
write_status $'failed\t'"$id"$'\t'"$detail"
failed=$((failed + 1))
fi
done
write_status $'finished\t'"$(date +%s)"
write_status $'done\t'"$updated"$'\t'"$failed"
(( failed == 0 ))