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
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,93 @@
|
||||
# 🧩 Omaplug
|
||||
|
||||
**A small tool for managing your Omarchy plugins.**
|
||||
|
||||
[](https://plugins.omarchy.org/plugin.html?id=omaplug) [](https://github.com/omacom/omarchy-plugin-marketplace/blob/main/SECURITY.md#automated-security-baseline) [](https://stats.ussego.com/plugins/omaplug) [.
|
||||
|
||||
Omaplug is listed on the marketplace: [plugins.omarchy.org/plugin.html?id=omaplug](https://plugins.omarchy.org/plugin.html?id=omaplug)
|
||||
|
||||
## Screenshots
|
||||
|
||||

|
||||
|
||||
<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
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
'
|
||||
@@ -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"
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 188 KiB |
@@ -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.
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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()
|
||||
@@ -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'
|
||||
@@ -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 ))
|
||||