mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-05-14 16:22:46 -04:00
Compare commits
113 Commits
1abb221024
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| e5cd9caba1 | |||
| 9c4aa06664 | |||
| 66e38c5efe | |||
| 018795125e | |||
| be4ea71756 | |||
| 39b90bb140 | |||
| fb5198fd0b | |||
| 71438530a8 | |||
| 79fe956058 | |||
| a33c7e0250 | |||
| 459ec47310 | |||
| 4bb3dd8310 | |||
| 9a630fad92 | |||
| 5bde54fa89 | |||
| 022f47a580 | |||
| 0dfa95ffe4 | |||
| e6da762870 | |||
| 8f958658dc | |||
| 392eaea5fc | |||
| 0aea542e8f | |||
| 7a566e1088 | |||
| 4bc62cc6ec | |||
| 9b68fc8213 | |||
| c878ffb7f9 | |||
| 3fc805ba53 | |||
| 371a7d0cd1 | |||
| 189b7c84ce | |||
| b8f4c350a8 | |||
| 3989c7f801 | |||
| 2690305724 | |||
| 676219bc09 | |||
| b192b5f779 | |||
| a5352623fd | |||
| 2b6ae58bff | |||
| b12511481d | |||
| c7d44cfb12 | |||
| 4193cf51ff | |||
| 1ec0311086 | |||
| c6a1473d2f | |||
| ee16047e15 | |||
| 4968b80268 | |||
| e28b0c695e | |||
| 7f6486b3e7 | |||
| faa30c4d48 | |||
| cf641b4e08 | |||
| b75453c7d6 | |||
| 10872b5fc8 | |||
| 80c853f16c | |||
| 6167f22837 | |||
| d8835f2bc6 | |||
| 1c01774fde | |||
| 0d3eb774e2 | |||
| 7fb4b6e0d9 | |||
| 5df2b5fc33 | |||
| d49c49cd99 | |||
| b209827f38 | |||
| 1b9d1c667c | |||
| 04d961ad0b | |||
| e60caf8028 | |||
| 1e67927f8a | |||
| e6e343dacb | |||
| f87ad3d2ca | |||
| a6ba4b1c79 | |||
| 7cf718cd50 | |||
| d223a74740 | |||
| 408beb202c | |||
| cfe6e6867e | |||
| 7c991bc4e3 | |||
| 50f0cbb122 | |||
| 7ee0879103 | |||
| 56ef186ce8 | |||
| 5b507136e3 | |||
| 19c561da14 | |||
| cc47703d48 | |||
| 31e60a3df5 | |||
| 082de6f1f0 | |||
| fd24b4a36d | |||
| dd668469d7 | |||
| 434490e100 | |||
| d2f6cb3ae4 | |||
| c1cbd0994f | |||
| c81645bacb | |||
| cdc4ca7e1f | |||
| 7d92842ff2 | |||
| d8bf3bdfe8 | |||
| 23ed795e85 | |||
| 2877c63c97 | |||
| 86096db26b | |||
| f76724f7cd | |||
| 3b96c6ab22 | |||
| 1467f5dba9 | |||
| baaa30c94e | |||
| 24a3cd5a3d | |||
| 65151dbfd7 | |||
| 7bd9574868 | |||
| a4cfdf4a59 | |||
| fd651dc943 | |||
| 919b09fc96 | |||
| aeb3fdd637 | |||
| dc5636bed5 | |||
| 36a7692da7 | |||
| c9b38023d5 | |||
| 536e654b5e | |||
| e805f6b5ac | |||
| 94f4b6d4a9 | |||
| 28f68ac702 | |||
| 441ec42ee0 | |||
| 5415444e15 | |||
| bd5276b40d | |||
| dd3f17f51e | |||
| a459b7d1b4 | |||
| 0f71c29776 | |||
| 4a32739d3f |
@@ -0,0 +1,104 @@
|
|||||||
|
# Agent Skills
|
||||||
|
|
||||||
|
This directory contains agent skills following the [Agent Skills](https://agentskills.io) open standard - a portable, version-controlled format for giving AI agents specialized capabilities.
|
||||||
|
|
||||||
|
Each skill is a directory with a `SKILL.md` entrypoint, optional reference docs, scripts, and templates. Agents load skills progressively: metadata at startup, full instructions on activation, and supporting files on demand.
|
||||||
|
|
||||||
|
## Available Skills
|
||||||
|
|
||||||
|
| Skill | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| [dms-plugin-dev](dms-plugin-dev/) | Develop plugins for DankMaterialShell - covers all 4 plugin types (widget, daemon, launcher, desktop), manifest creation, QML components, settings UI, data persistence, theme integration, and PopoutService usage. |
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
The `.agents/skills/` directory at the project root is the standard location defined by the agentskills.io spec. Many agents discover skills from this path automatically. Some agents use their own directory conventions and need a symlink or copy.
|
||||||
|
|
||||||
|
### Claude Code
|
||||||
|
|
||||||
|
Claude Code discovers skills from `.claude/skills/` (project-level) or `~/.claude/skills/` (personal). To make skills from `.agents/skills/` available, symlink them into the Claude Code skills directory:
|
||||||
|
|
||||||
|
**Project-level** (this repo only):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p .claude/skills
|
||||||
|
ln -s ../../.agents/skills/dms-plugin-dev .claude/skills/dms-plugin-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
**Personal** (all your projects):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ln -s /path/to/DankMaterialShell/.agents/skills/dms-plugin-dev ~/.claude/skills/dms-plugin-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
After linking, the skill appears in Claude Code's `/` menu as `/dms-plugin-dev`, and Claude loads it automatically when you ask about DMS plugin development.
|
||||||
|
|
||||||
|
See the [Claude Code skills docs](https://code.claude.com/docs/en/skills) for more on skill configuration, invocation control, and frontmatter options.
|
||||||
|
|
||||||
|
### Cursor
|
||||||
|
|
||||||
|
Cursor discovers skills from `.cursor/skills/` in the project root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p .cursor/skills
|
||||||
|
ln -s ../../.agents/skills/dms-plugin-dev .cursor/skills/dms-plugin-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
See [Cursor skills docs](https://cursor.com/docs/context/skills) for details.
|
||||||
|
|
||||||
|
### VS Code (Copilot)
|
||||||
|
|
||||||
|
VS Code Copilot discovers skills from `.github/skills/` or `.vscode/skills/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p .github/skills
|
||||||
|
ln -s ../../.agents/skills/dms-plugin-dev .github/skills/dms-plugin-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
See [VS Code skills docs](https://code.visualstudio.com/docs/copilot/customization/agent-skills) for details.
|
||||||
|
|
||||||
|
### Gemini CLI
|
||||||
|
|
||||||
|
Gemini CLI discovers skills from `.gemini/skills/` in the project root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p .gemini/skills
|
||||||
|
ln -s ../../.agents/skills/dms-plugin-dev .gemini/skills/dms-plugin-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
See [Gemini CLI skills docs](https://geminicli.com/docs/cli/skills/) for details.
|
||||||
|
|
||||||
|
### OpenAI Codex
|
||||||
|
|
||||||
|
Codex discovers skills from `.codex/skills/` in the project root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p .codex/skills
|
||||||
|
ln -s ../../.agents/skills/dms-plugin-dev .codex/skills/dms-plugin-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
See [Codex skills docs](https://developers.openai.com/codex/skills/) for details.
|
||||||
|
|
||||||
|
### Other Agents
|
||||||
|
|
||||||
|
The Agent Skills standard is supported by 30+ tools including Goose, Roo Code, JetBrains Junie, Amp, OpenCode, OpenHands, Kiro, and more. Most discover skills from a dot-directory at the project root (e.g., `.goose/skills/`, `.roo/skills/`). Some read `.agents/skills/` directly.
|
||||||
|
|
||||||
|
Check the [Agent Skills client showcase](https://agentskills.io/clients) for setup instructions specific to your agent.
|
||||||
|
|
||||||
|
The general pattern is:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p .<agent>/skills
|
||||||
|
ln -s ../../.agents/skills/dms-plugin-dev .<agent>/skills/dms-plugin-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adding New Skills
|
||||||
|
|
||||||
|
To add a new skill to this directory:
|
||||||
|
|
||||||
|
1. Create a subdirectory named with lowercase letters, numbers, and hyphens (e.g., `my-new-skill/`)
|
||||||
|
2. Add a `SKILL.md` file with YAML frontmatter (`name`, `description`) and markdown instructions
|
||||||
|
3. Optionally add `references/`, `scripts/`, and `assets/` subdirectories
|
||||||
|
4. Keep `SKILL.md` under 500 lines - move detailed content to reference files
|
||||||
|
|
||||||
|
See the [Agent Skills specification](https://agentskills.io/specification) for the full format.
|
||||||
@@ -0,0 +1,497 @@
|
|||||||
|
---
|
||||||
|
name: dms-plugin-dev
|
||||||
|
description: >
|
||||||
|
Develop plugins for DankMaterialShell (DMS), a QML-based Linux desktop shell built on
|
||||||
|
Quickshell. Supports four plugin types: widget (bar + Control Center), daemon (background
|
||||||
|
service), launcher (search + actions), and desktop (draggable desktop widgets). Covers
|
||||||
|
manifest creation, QML component development, settings UI, data persistence, theme
|
||||||
|
integration, PopoutService usage, and external command execution. Use when the user wants
|
||||||
|
to create, modify, or debug a DMS plugin, or asks about the DMS plugin API.
|
||||||
|
compatibility: Designed for Claude Code (or similar products)
|
||||||
|
metadata:
|
||||||
|
author: DankMaterialShell
|
||||||
|
version: "1.0"
|
||||||
|
domain: qml-desktop-development
|
||||||
|
framework: DankMaterialShell
|
||||||
|
languages: qml, javascript
|
||||||
|
allowed-tools: Bash Read Write Edit
|
||||||
|
---
|
||||||
|
|
||||||
|
# DankMaterialShell Plugin Development
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
DMS plugins extend the desktop shell with custom widgets, background services, launcher
|
||||||
|
integrations, and desktop widgets. Plugins are QML components discovered from
|
||||||
|
`~/.config/DankMaterialShell/plugins/`.
|
||||||
|
|
||||||
|
**Minimum plugin structure:**
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.config/DankMaterialShell/plugins/YourPlugin/
|
||||||
|
plugin.json # Required: manifest with metadata
|
||||||
|
YourComponent.qml # Required: main QML component
|
||||||
|
YourSettings.qml # Optional: settings UI
|
||||||
|
*.js # Optional: JavaScript utilities
|
||||||
|
```
|
||||||
|
|
||||||
|
**Plugin registry:** Community plugins are available at https://plugins.danklinux.com/
|
||||||
|
|
||||||
|
**Four plugin types:**
|
||||||
|
|
||||||
|
| Type | Purpose | Base Component | Bar pills | CC integration |
|
||||||
|
|------------|--------------------------------|----------------------------|-----------|----------------|
|
||||||
|
| `widget` | Bar widget + popout | `PluginComponent` | Yes | Yes |
|
||||||
|
| `daemon` | Background service | `PluginComponent` (no UI) | No | Optional |
|
||||||
|
| `launcher` | Searchable items in launcher | `Item` | No | No |
|
||||||
|
| `desktop` | Draggable desktop widget | `DesktopPluginComponent` | No | No |
|
||||||
|
|
||||||
|
## Step 1: Determine Plugin Type
|
||||||
|
|
||||||
|
Choose the type based on what the plugin does:
|
||||||
|
|
||||||
|
- **Shows in the bar?** - Use `widget`. Displays a pill in DankBar, optionally opens a popout,
|
||||||
|
optionally integrates with Control Center.
|
||||||
|
- **Runs in background only?** - Use `daemon`. No visible UI, reacts to events (wallpaper
|
||||||
|
changes, notifications, battery level, etc.).
|
||||||
|
- **Provides searchable/actionable items?** - Use `launcher`. Items appear in the DMS launcher
|
||||||
|
with trigger-based filtering (e.g., type `=` for calculator, `:` for emoji).
|
||||||
|
- **Shows on the desktop background?** - Use `desktop`. Draggable, resizable widget on the
|
||||||
|
desktop layer.
|
||||||
|
|
||||||
|
## Step 2: Create the Manifest
|
||||||
|
|
||||||
|
Create `plugin.json` in your plugin directory. See [plugin-manifest-reference.md](references/plugin-manifest-reference.md) for the full schema.
|
||||||
|
|
||||||
|
**Minimal manifest:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "yourPlugin",
|
||||||
|
"name": "Your Plugin Name",
|
||||||
|
"description": "Brief description of what your plugin does",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "Your Name",
|
||||||
|
"type": "widget",
|
||||||
|
"capabilities": ["your-capability"],
|
||||||
|
"component": "./YourWidget.qml"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**With settings and permissions:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "yourPlugin",
|
||||||
|
"name": "Your Plugin Name",
|
||||||
|
"description": "Brief description",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "Your Name",
|
||||||
|
"type": "widget",
|
||||||
|
"capabilities": ["your-capability"],
|
||||||
|
"component": "./YourWidget.qml",
|
||||||
|
"icon": "extension",
|
||||||
|
"settings": "./Settings.qml",
|
||||||
|
"requires_dms": ">=0.1.0",
|
||||||
|
"permissions": ["settings_read", "settings_write"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key rules:**
|
||||||
|
- `id` must be camelCase, matching pattern `^[a-zA-Z][a-zA-Z0-9]*$`
|
||||||
|
- `version` must be semver (e.g., `1.0.0`)
|
||||||
|
- `component` must start with `./` and end with `.qml`
|
||||||
|
- `type: "launcher"` requires a `trigger` field
|
||||||
|
- `settings_write` permission is **required** if the plugin has a settings component
|
||||||
|
|
||||||
|
## Step 3: Create the Main Component
|
||||||
|
|
||||||
|
### Widget
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import QtQuick
|
||||||
|
import qs.Common
|
||||||
|
import qs.Widgets
|
||||||
|
import qs.Modules.Plugins
|
||||||
|
|
||||||
|
PluginComponent {
|
||||||
|
property var popoutService: null
|
||||||
|
|
||||||
|
horizontalBarPill: Component {
|
||||||
|
StyledRect {
|
||||||
|
width: label.implicitWidth + Theme.spacingM * 2
|
||||||
|
height: parent.widgetThickness
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: Theme.surfaceContainerHigh
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
id: label
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: "Hello"
|
||||||
|
color: Theme.surfaceText
|
||||||
|
font.pixelSize: Theme.fontSizeMedium
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
verticalBarPill: Component {
|
||||||
|
StyledRect {
|
||||||
|
width: parent.widgetThickness
|
||||||
|
height: label.implicitHeight + Theme.spacingM * 2
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: Theme.surfaceContainerHigh
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
id: label
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: "Hi"
|
||||||
|
color: Theme.surfaceText
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
rotation: 90
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
See [widget-plugin-guide.md](references/widget-plugin-guide.md) for popouts, CC integration, and advanced features.
|
||||||
|
|
||||||
|
### Launcher
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import QtQuick
|
||||||
|
import qs.Services
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property var pluginService: null
|
||||||
|
property string trigger: "#"
|
||||||
|
|
||||||
|
signal itemsChanged()
|
||||||
|
|
||||||
|
function getItems(query) {
|
||||||
|
const items = [
|
||||||
|
{ name: "Item One", icon: "material:star", comment: "Description",
|
||||||
|
action: "toast:Hello!", categories: ["MyPlugin"] }
|
||||||
|
]
|
||||||
|
if (!query) return items
|
||||||
|
const q = query.toLowerCase()
|
||||||
|
return items.filter(i => i.name.toLowerCase().includes(q))
|
||||||
|
}
|
||||||
|
|
||||||
|
function executeItem(item) {
|
||||||
|
const [type, ...rest] = item.action.split(":")
|
||||||
|
const data = rest.join(":")
|
||||||
|
if (type === "toast") ToastService?.showInfo(data)
|
||||||
|
else if (type === "copy") Quickshell.execDetached(["dms", "cl", "copy", data])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
See [launcher-plugin-guide.md](references/launcher-plugin-guide.md) for triggers, icon types, context menus, and image tiles.
|
||||||
|
|
||||||
|
### Desktop
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import QtQuick
|
||||||
|
import qs.Common
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property var pluginService: null
|
||||||
|
property string pluginId: ""
|
||||||
|
property bool editMode: false
|
||||||
|
property real widgetWidth: 200
|
||||||
|
property real widgetHeight: 200
|
||||||
|
property real minWidth: 150
|
||||||
|
property real minHeight: 150
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: Theme.surfaceContainer
|
||||||
|
opacity: 0.85
|
||||||
|
border.color: root.editMode ? Theme.primary : "transparent"
|
||||||
|
border.width: root.editMode ? 2 : 0
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: "Desktop Widget"
|
||||||
|
color: Theme.surfaceText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
See [desktop-plugin-guide.md](references/desktop-plugin-guide.md) for sizing, persistence, and edit mode.
|
||||||
|
|
||||||
|
### Daemon
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import QtQuick
|
||||||
|
import qs.Common
|
||||||
|
import qs.Services
|
||||||
|
import qs.Modules.Plugins
|
||||||
|
|
||||||
|
PluginComponent {
|
||||||
|
property var popoutService: null
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: SessionData
|
||||||
|
function onSomeSignal() {
|
||||||
|
console.log("Event received")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
See [daemon-plugin-guide.md](references/daemon-plugin-guide.md) for event-driven patterns and process execution.
|
||||||
|
|
||||||
|
## Step 4: Add Settings (Optional)
|
||||||
|
|
||||||
|
Wrap settings in `PluginSettings` with your `pluginId`. All settings auto-save and auto-load.
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import QtQuick
|
||||||
|
import qs.Common
|
||||||
|
import qs.Widgets
|
||||||
|
import qs.Modules.Plugins
|
||||||
|
|
||||||
|
PluginSettings {
|
||||||
|
pluginId: "yourPlugin"
|
||||||
|
|
||||||
|
StringSetting {
|
||||||
|
settingKey: "apiKey"
|
||||||
|
label: "API Key"
|
||||||
|
description: "Your API key"
|
||||||
|
placeholder: "sk-..."
|
||||||
|
}
|
||||||
|
|
||||||
|
ToggleSetting {
|
||||||
|
settingKey: "enabled"
|
||||||
|
label: "Enable Feature"
|
||||||
|
defaultValue: true
|
||||||
|
}
|
||||||
|
|
||||||
|
SelectionSetting {
|
||||||
|
settingKey: "interval"
|
||||||
|
label: "Refresh Interval"
|
||||||
|
options: [
|
||||||
|
{ label: "1 min", value: "60" },
|
||||||
|
{ label: "5 min", value: "300" }
|
||||||
|
]
|
||||||
|
defaultValue: "300"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Available setting components:** StringSetting, ToggleSetting, SelectionSetting, SliderSetting, ColorSetting, ListSetting, ListSettingWithInput.
|
||||||
|
|
||||||
|
See [settings-components-reference.md](references/settings-components-reference.md) for full property lists.
|
||||||
|
|
||||||
|
**Important:** Your plugin must declare `"permissions": ["settings_write"]` in plugin.json, or the settings UI will show an error.
|
||||||
|
|
||||||
|
## Step 5: Use Data Persistence
|
||||||
|
|
||||||
|
Three tiers of persistence:
|
||||||
|
|
||||||
|
| API | Persisted | Use case |
|
||||||
|
|-----|-----------|----------|
|
||||||
|
| `pluginService.savePluginData(id, key, val)` / `loadPluginData(id, key, default)` | Yes (settings.json) | User preferences, config |
|
||||||
|
| `pluginService.savePluginState(id, key, val)` / `loadPluginState(id, key, default)` | Yes (separate state file) | Runtime state, history, cache |
|
||||||
|
| `PluginGlobalVar { varName; defaultValue; value; set() }` | No (runtime only) | Cross-instance shared state |
|
||||||
|
|
||||||
|
- `pluginData` is a reactive property on PluginComponent, auto-loaded from settings
|
||||||
|
- React to settings changes with `Connections { target: pluginService; function onPluginDataChanged(id) { ... } }`
|
||||||
|
- Global vars sync across all instances (multi-monitor, multiple bar sections)
|
||||||
|
|
||||||
|
See [data-persistence-guide.md](references/data-persistence-guide.md) for details and examples.
|
||||||
|
|
||||||
|
## Step 6: Theme Integration
|
||||||
|
|
||||||
|
Always use `Theme.*` properties from `qs.Common` - never hardcode colors or sizes.
|
||||||
|
|
||||||
|
**Essential properties:**
|
||||||
|
- Colors: `Theme.surfaceContainerHigh`, `Theme.surfaceText`, `Theme.primary`, `Theme.onPrimary`
|
||||||
|
- Fonts: `Theme.fontSizeSmall` (12), `Theme.fontSizeMedium` (14), `Theme.fontSizeLarge` (16), `Theme.fontSizeXLarge` (20)
|
||||||
|
- Spacing: `Theme.spacingXS`, `Theme.spacingS`, `Theme.spacingM`, `Theme.spacingL`, `Theme.spacingXL`
|
||||||
|
- Radius: `Theme.cornerRadius`, `Theme.cornerRadiusSmall`, `Theme.cornerRadiusLarge`
|
||||||
|
- Icons: `Theme.iconSizeSmall` (16), `Theme.iconSize` (24), `Theme.iconSizeLarge` (32)
|
||||||
|
|
||||||
|
**Common widgets from `qs.Widgets`:** `StyledText`, `StyledRect`, `DankIcon`, `DankButton`, `DankToggle`, `DankTextField`, `DankSlider`, `DankGridView`, `CachingImage`.
|
||||||
|
|
||||||
|
See [theme-reference.md](references/theme-reference.md) for the complete property list.
|
||||||
|
|
||||||
|
## Step 7: Add Popout Content (Widgets Only)
|
||||||
|
|
||||||
|
Add a popout that opens when the bar pill is clicked:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
PluginComponent {
|
||||||
|
popoutWidth: 400
|
||||||
|
popoutHeight: 300
|
||||||
|
|
||||||
|
popoutContent: Component {
|
||||||
|
PopoutComponent {
|
||||||
|
headerText: "My Plugin"
|
||||||
|
detailsText: "Optional subtitle"
|
||||||
|
showCloseButton: true
|
||||||
|
|
||||||
|
Column {
|
||||||
|
width: parent.width
|
||||||
|
spacing: Theme.spacingM
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
text: "Content here"
|
||||||
|
color: Theme.surfaceText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
horizontalBarPill: Component { /* ... */ }
|
||||||
|
verticalBarPill: Component { /* ... */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**PopoutComponent properties:** `headerText`, `detailsText`, `showCloseButton`, `closePopout()` (auto-injected), `headerHeight` (readonly), `detailsHeight` (readonly).
|
||||||
|
|
||||||
|
Calculate available content height: `popoutHeight - headerHeight - detailsHeight - spacing`
|
||||||
|
|
||||||
|
## Step 8: Control Center Integration (Widgets Only)
|
||||||
|
|
||||||
|
Add your widget to the Control Center grid:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
PluginComponent {
|
||||||
|
ccWidgetIcon: "toggle_on"
|
||||||
|
ccWidgetPrimaryText: "My Feature"
|
||||||
|
ccWidgetSecondaryText: isActive ? "On" : "Off"
|
||||||
|
ccWidgetIsActive: isActive
|
||||||
|
|
||||||
|
onCcWidgetToggled: {
|
||||||
|
isActive = !isActive
|
||||||
|
pluginService?.savePluginData(pluginId, "active", isActive)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: expandable detail panel (for CompoundPill)
|
||||||
|
ccDetailContent: Component {
|
||||||
|
Rectangle {
|
||||||
|
implicitHeight: 200
|
||||||
|
color: Theme.surfaceContainerHigh
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**CC sizing:** 25% width = SmallToggleButton (icon only), 50% width = ToggleButton or CompoundPill (if ccDetailContent is defined).
|
||||||
|
|
||||||
|
## Step 9: External Commands and Clipboard
|
||||||
|
|
||||||
|
**Run commands and capture output:**
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import qs.Common
|
||||||
|
|
||||||
|
Proc.runCommand(
|
||||||
|
"myPlugin.fetch",
|
||||||
|
["curl", "-s", "https://api.example.com/data"],
|
||||||
|
(stdout, exitCode) => {
|
||||||
|
if (exitCode === 0) processData(stdout)
|
||||||
|
},
|
||||||
|
500 // debounce ms
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fire-and-forget (clipboard, notifications):**
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import Quickshell
|
||||||
|
|
||||||
|
Quickshell.execDetached(["dms", "cl", "copy", textToCopy])
|
||||||
|
```
|
||||||
|
|
||||||
|
**Long-running processes:** Use the `Process` QML component from `Quickshell.Io` with `StdioCollector`.
|
||||||
|
|
||||||
|
**Shell commands with pipes:** `["sh", "-c", "ps aux | grep foo"]`
|
||||||
|
|
||||||
|
**Do NOT use** `globalThis.clipboard` or browser JavaScript APIs - they don't exist in the QML runtime.
|
||||||
|
|
||||||
|
## Step 10: Validate and Test
|
||||||
|
|
||||||
|
1. Validate `plugin.json` against the schema at [assets/plugin-schema.json](assets/plugin-schema.json)
|
||||||
|
2. Run the shell with verbose output: `qs -v -p $CONFIGPATH/quickshell/dms/shell.qml`
|
||||||
|
3. Open Settings > Plugins > Scan for Plugins
|
||||||
|
4. Enable your plugin and add it to the DankBar layout
|
||||||
|
|
||||||
|
**Common issues:**
|
||||||
|
- Plugin not detected: check plugin.json syntax with `jq . plugin.json`
|
||||||
|
- Widget not showing: ensure it's enabled AND added to a DankBar section
|
||||||
|
- Settings error: verify `settings_write` permission is declared
|
||||||
|
- Data not persisting: check pluginService injection and permissions
|
||||||
|
|
||||||
|
## Common Mistakes
|
||||||
|
|
||||||
|
1. **Missing `settings_write` permission** - Settings UI shows error without it
|
||||||
|
2. **Missing `property var popoutService: null`** - Must declare for injection to work
|
||||||
|
3. **Missing vertical bar pill** - Widget disappears when bar is on left/right edge
|
||||||
|
4. **Hardcoded colors** - Use `Theme.*` properties, not hex values
|
||||||
|
5. **Using `globalThis.clipboard`** - Does not exist; use `Quickshell.execDetached(["dms", "cl", "copy", text])`
|
||||||
|
6. **Wrong Theme property names** - `Theme.fontSizeS` does not exist, use `Theme.fontSizeSmall`
|
||||||
|
7. **Wrong import for Quickshell** - Use `import Quickshell` (not `import QtQuick` for execDetached)
|
||||||
|
8. **Forgetting `categories` in launcher items** - Items won't display without it
|
||||||
|
9. **Not handling null pluginService** - Always use optional chaining or null checks
|
||||||
|
10. **Using `PluginComponent` for launchers** - Launchers use plain `Item`, not `PluginComponent`
|
||||||
|
|
||||||
|
## Quick Reference: Imports
|
||||||
|
|
||||||
|
**Widget / Daemon:**
|
||||||
|
```qml
|
||||||
|
import QtQuick
|
||||||
|
import qs.Common
|
||||||
|
import qs.Widgets
|
||||||
|
import qs.Modules.Plugins
|
||||||
|
```
|
||||||
|
|
||||||
|
**Launcher:**
|
||||||
|
```qml
|
||||||
|
import QtQuick
|
||||||
|
import qs.Services
|
||||||
|
```
|
||||||
|
|
||||||
|
**Desktop:**
|
||||||
|
```qml
|
||||||
|
import QtQuick
|
||||||
|
import qs.Common
|
||||||
|
```
|
||||||
|
|
||||||
|
**For clipboard/exec:** `import Quickshell`
|
||||||
|
**For processes:** `import Quickshell.Io`
|
||||||
|
**For networking:** `import Quickshell.Networking`
|
||||||
|
**For toast notifications:** access `ToastService` from `qs.Services`
|
||||||
|
|
||||||
|
## Quick Reference: File Naming
|
||||||
|
|
||||||
|
- **Directory name:** PascalCase (e.g., `MyAwesomePlugin/`)
|
||||||
|
- **Plugin ID:** camelCase (e.g., `myAwesomePlugin`)
|
||||||
|
- **QML files:** PascalCase (e.g., `MyWidget.qml`, `Settings.qml`)
|
||||||
|
- **Component paths in manifest:** relative with `./` prefix (e.g., `"./MyWidget.qml"`)
|
||||||
|
- **JS utility files:** camelCase (e.g., `utils.js`, `apiAdapter.js`)
|
||||||
|
|
||||||
|
## Reference Files
|
||||||
|
|
||||||
|
Load these on demand for detailed API documentation:
|
||||||
|
|
||||||
|
- [plugin-manifest-reference.md](references/plugin-manifest-reference.md) - Complete plugin.json field reference and JSON schema
|
||||||
|
- [widget-plugin-guide.md](references/widget-plugin-guide.md) - PluginComponent, bar pills, popouts, click actions, CC integration
|
||||||
|
- [launcher-plugin-guide.md](references/launcher-plugin-guide.md) - getItems/executeItem, triggers, icon types, context menus, tile view
|
||||||
|
- [desktop-plugin-guide.md](references/desktop-plugin-guide.md) - DesktopPluginComponent, sizing, edit mode, position persistence
|
||||||
|
- [daemon-plugin-guide.md](references/daemon-plugin-guide.md) - Event-driven background services, process execution
|
||||||
|
- [settings-components-reference.md](references/settings-components-reference.md) - All 7 setting components with complete property lists
|
||||||
|
- [theme-reference.md](references/theme-reference.md) - Theme colors, spacing, fonts, radii, common patterns
|
||||||
|
- [data-persistence-guide.md](references/data-persistence-guide.md) - pluginData, state API, global variables
|
||||||
|
- [popout-service-reference.md](references/popout-service-reference.md) - PopoutService API for controlling shell popouts and modals
|
||||||
|
- [advanced-patterns.md](references/advanced-patterns.md) - Variants, JS utilities, qmldir, IPC, multi-file plugins
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
{
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"$id": "https://danklinux.com/schemas/plugin.json",
|
||||||
|
"title": "DankMaterialShell Plugin Manifest",
|
||||||
|
"description": "Schema for DankMaterialShell plugin.json manifest files",
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"version",
|
||||||
|
"author",
|
||||||
|
"type",
|
||||||
|
"capabilities",
|
||||||
|
"component"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Unique plugin identifier (camelCase, no spaces)",
|
||||||
|
"pattern": "^[a-zA-Z][a-zA-Z0-9]*$"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Human-readable plugin name",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Short description of plugin functionality",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Semantic version string (e.g., '1.0.0')",
|
||||||
|
"pattern": "^\\d+\\.\\d+\\.\\d+(-[a-zA-Z0-9.-]+)?(\\+[a-zA-Z0-9.-]+)?$"
|
||||||
|
},
|
||||||
|
"author": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Plugin creator name or email",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Plugin type",
|
||||||
|
"enum": ["widget", "daemon", "launcher", "desktop"]
|
||||||
|
},
|
||||||
|
"capabilities": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Array of plugin capabilities",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"minItems": 1
|
||||||
|
},
|
||||||
|
"component": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Relative path to main QML component file",
|
||||||
|
"pattern": "^\\./.*\\.qml$"
|
||||||
|
},
|
||||||
|
"trigger": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Trigger string for launcher activation (required for launcher type)"
|
||||||
|
},
|
||||||
|
"icon": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Material Design icon name"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Path to settings component QML file",
|
||||||
|
"pattern": "^\\./.*\\.qml$"
|
||||||
|
},
|
||||||
|
"requires_dms": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Minimum DMS version requirement (e.g., '>=0.1.18', '>0.1.0')",
|
||||||
|
"pattern": "^(>=?|<=?|=|>|<)\\d+\\.\\d+\\.\\d+$"
|
||||||
|
},
|
||||||
|
"requires": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Array of required system tools/dependencies",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"permissions": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Required capabilities",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"settings_read",
|
||||||
|
"settings_write",
|
||||||
|
"process",
|
||||||
|
"network"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"if": {
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"const": "launcher"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
|
"required": ["trigger"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import QtQuick
|
||||||
|
import qs.Common
|
||||||
|
import qs.Services
|
||||||
|
import qs.Modules.Plugins
|
||||||
|
|
||||||
|
PluginComponent {
|
||||||
|
id: root
|
||||||
|
property var popoutService: null
|
||||||
|
|
||||||
|
// TODO: Read configuration from settings
|
||||||
|
property string configValue: pluginData?.configValue || ""
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: pluginService
|
||||||
|
function onPluginDataChanged(changedId) {
|
||||||
|
if (changedId !== pluginId) return
|
||||||
|
configValue = pluginService.loadPluginData(pluginId, "configValue", "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Connect to the service events you need
|
||||||
|
// Connections {
|
||||||
|
// target: SessionData
|
||||||
|
// function onWallpaperPathChanged() {
|
||||||
|
// console.log("[MyDaemon] Wallpaper changed:", SessionData.wallpaperPath)
|
||||||
|
// handleEvent(SessionData.wallpaperPath)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
function handleEvent(data) {
|
||||||
|
Proc.runCommand(
|
||||||
|
"myDaemon.handle",
|
||||||
|
["echo", "Event received:", data],
|
||||||
|
(stdout, exitCode) => {
|
||||||
|
if (exitCode === 0) {
|
||||||
|
console.log("[MyDaemon] Output:", stdout)
|
||||||
|
} else {
|
||||||
|
console.error("[MyDaemon] Failed:", exitCode)
|
||||||
|
ToastService?.showInfo("Daemon action failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: {
|
||||||
|
console.log("[MyDaemon] Started")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import QtQuick
|
||||||
|
import qs.Common
|
||||||
|
import qs.Widgets
|
||||||
|
import qs.Modules.Plugins
|
||||||
|
|
||||||
|
PluginSettings {
|
||||||
|
pluginId: "myDaemon"
|
||||||
|
|
||||||
|
StringSetting {
|
||||||
|
settingKey: "configValue"
|
||||||
|
label: "Configuration"
|
||||||
|
description: "Value used by the daemon"
|
||||||
|
placeholder: "Enter value..."
|
||||||
|
defaultValue: ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"id": "myDaemon",
|
||||||
|
"name": "My Daemon",
|
||||||
|
"description": "A background service that reacts to events",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "Your Name",
|
||||||
|
"type": "daemon",
|
||||||
|
"capabilities": ["background-service"],
|
||||||
|
"component": "./Daemon.qml",
|
||||||
|
"icon": "settings",
|
||||||
|
"settings": "./Settings.qml",
|
||||||
|
"permissions": ["settings_read", "settings_write", "process"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import QtQuick
|
||||||
|
import qs.Common
|
||||||
|
import qs.Widgets
|
||||||
|
import qs.Modules.Plugins
|
||||||
|
|
||||||
|
PluginSettings {
|
||||||
|
pluginId: "myDesktopWidget"
|
||||||
|
|
||||||
|
SliderSetting {
|
||||||
|
settingKey: "opacity"
|
||||||
|
label: "Opacity"
|
||||||
|
description: "Widget background opacity"
|
||||||
|
defaultValue: 85
|
||||||
|
minimum: 10
|
||||||
|
maximum: 100
|
||||||
|
unit: "%"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import QtQuick
|
||||||
|
import qs.Common
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property var pluginService: null
|
||||||
|
property string pluginId: ""
|
||||||
|
property bool editMode: false
|
||||||
|
property real widgetWidth: 200
|
||||||
|
property real widgetHeight: 200
|
||||||
|
property real minWidth: 150
|
||||||
|
property real minHeight: 150
|
||||||
|
|
||||||
|
// TODO: Load settings reactively
|
||||||
|
property real bgOpacity: {
|
||||||
|
if (!pluginService) return 0.85
|
||||||
|
var val = pluginService.loadPluginData(pluginId, "opacity", 85)
|
||||||
|
return val / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: pluginService
|
||||||
|
function onPluginDataChanged(changedId) {
|
||||||
|
if (changedId !== pluginId) return
|
||||||
|
var val = pluginService.loadPluginData(pluginId, "opacity", 85)
|
||||||
|
bgOpacity = val / 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: Theme.surfaceContainer
|
||||||
|
opacity: root.bgOpacity
|
||||||
|
border.color: root.editMode ? Theme.primary : "transparent"
|
||||||
|
border.width: root.editMode ? 2 : 0
|
||||||
|
|
||||||
|
// TODO: Add your widget content here
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: "Desktop Widget"
|
||||||
|
color: Theme.surfaceText
|
||||||
|
font.pixelSize: Theme.fontSizeLarge
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"id": "myDesktopWidget",
|
||||||
|
"name": "My Desktop Widget",
|
||||||
|
"description": "A custom desktop widget",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "Your Name",
|
||||||
|
"type": "desktop",
|
||||||
|
"capabilities": ["desktop-widget"],
|
||||||
|
"component": "./Widget.qml",
|
||||||
|
"icon": "widgets",
|
||||||
|
"settings": "./Settings.qml",
|
||||||
|
"permissions": ["settings_read", "settings_write"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import qs.Services
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property var pluginService: null
|
||||||
|
property string trigger: "#"
|
||||||
|
|
||||||
|
signal itemsChanged()
|
||||||
|
|
||||||
|
// TODO: Define your items
|
||||||
|
property var allItems: [
|
||||||
|
{
|
||||||
|
name: "Example Item",
|
||||||
|
icon: "material:star",
|
||||||
|
comment: "An example launcher item",
|
||||||
|
action: "toast:Hello from my launcher!",
|
||||||
|
categories: ["MyLauncher"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
function getItems(query) {
|
||||||
|
if (!query || query.length === 0) return allItems
|
||||||
|
|
||||||
|
var q = query.toLowerCase()
|
||||||
|
return allItems.filter(function(item) {
|
||||||
|
return item.name.toLowerCase().includes(q) ||
|
||||||
|
item.comment.toLowerCase().includes(q)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function executeItem(item) {
|
||||||
|
var actionParts = item.action.split(":")
|
||||||
|
var actionType = actionParts[0]
|
||||||
|
var actionData = actionParts.slice(1).join(":")
|
||||||
|
|
||||||
|
switch (actionType) {
|
||||||
|
case "toast":
|
||||||
|
if (typeof ToastService !== "undefined")
|
||||||
|
ToastService.showInfo(actionData)
|
||||||
|
break
|
||||||
|
case "copy":
|
||||||
|
Quickshell.execDetached(["dms", "cl", "copy", actionData])
|
||||||
|
if (typeof ToastService !== "undefined")
|
||||||
|
ToastService.showInfo("Copied to clipboard")
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
console.warn("Unknown action type:", actionType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: {
|
||||||
|
if (pluginService) {
|
||||||
|
trigger = pluginService.loadPluginData("myLauncher", "trigger", "#")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import QtQuick
|
||||||
|
import qs.Common
|
||||||
|
import qs.Widgets
|
||||||
|
import qs.Modules.Plugins
|
||||||
|
|
||||||
|
PluginSettings {
|
||||||
|
pluginId: "myLauncher"
|
||||||
|
|
||||||
|
StringSetting {
|
||||||
|
settingKey: "trigger"
|
||||||
|
label: "Trigger"
|
||||||
|
description: "Type this prefix in the launcher to activate the plugin"
|
||||||
|
placeholder: "#"
|
||||||
|
defaultValue: "#"
|
||||||
|
}
|
||||||
|
|
||||||
|
ToggleSetting {
|
||||||
|
settingKey: "noTrigger"
|
||||||
|
label: "Always Visible"
|
||||||
|
description: "Show items alongside regular apps without needing a trigger"
|
||||||
|
defaultValue: false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"id": "myLauncher",
|
||||||
|
"name": "My Launcher",
|
||||||
|
"description": "Custom launcher plugin with searchable items",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "Your Name",
|
||||||
|
"type": "launcher",
|
||||||
|
"capabilities": ["launcher"],
|
||||||
|
"component": "./Launcher.qml",
|
||||||
|
"trigger": "#",
|
||||||
|
"icon": "search",
|
||||||
|
"settings": "./Settings.qml",
|
||||||
|
"permissions": ["settings_read", "settings_write"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import QtQuick
|
||||||
|
import qs.Common
|
||||||
|
import qs.Widgets
|
||||||
|
import qs.Modules.Plugins
|
||||||
|
|
||||||
|
PluginSettings {
|
||||||
|
pluginId: "myWidget"
|
||||||
|
|
||||||
|
StringSetting {
|
||||||
|
settingKey: "text"
|
||||||
|
label: "Display Text"
|
||||||
|
description: "Text shown in the bar widget"
|
||||||
|
placeholder: "Hello"
|
||||||
|
defaultValue: "Hello"
|
||||||
|
}
|
||||||
|
|
||||||
|
ToggleSetting {
|
||||||
|
settingKey: "showIcon"
|
||||||
|
label: "Show Icon"
|
||||||
|
description: "Display an icon next to the text"
|
||||||
|
defaultValue: true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import QtQuick
|
||||||
|
import qs.Common
|
||||||
|
import qs.Widgets
|
||||||
|
import qs.Modules.Plugins
|
||||||
|
|
||||||
|
PluginComponent {
|
||||||
|
id: root
|
||||||
|
property var popoutService: null
|
||||||
|
|
||||||
|
// TODO: Read settings reactively
|
||||||
|
property string displayText: pluginData?.text || "Hello"
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: pluginService
|
||||||
|
function onPluginDataChanged(changedId) {
|
||||||
|
if (changedId !== pluginId) return
|
||||||
|
displayText = pluginService.loadPluginData(pluginId, "text", "Hello")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
horizontalBarPill: Component {
|
||||||
|
StyledRect {
|
||||||
|
width: label.implicitWidth + Theme.spacingM * 2
|
||||||
|
height: parent.widgetThickness
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: Theme.surfaceContainerHigh
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
id: label
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: root.displayText
|
||||||
|
color: Theme.surfaceText
|
||||||
|
font.pixelSize: Theme.fontSizeMedium
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
verticalBarPill: Component {
|
||||||
|
StyledRect {
|
||||||
|
width: parent.widgetThickness
|
||||||
|
height: label.implicitHeight + Theme.spacingM * 2
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: Theme.surfaceContainerHigh
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
id: label
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: root.displayText
|
||||||
|
color: Theme.surfaceText
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
rotation: 90
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Uncomment and customize popout content
|
||||||
|
// popoutWidth: 350
|
||||||
|
// popoutHeight: 300
|
||||||
|
// popoutContent: Component {
|
||||||
|
// PopoutComponent {
|
||||||
|
// headerText: "My Widget"
|
||||||
|
// showCloseButton: true
|
||||||
|
//
|
||||||
|
// Column {
|
||||||
|
// width: parent.width
|
||||||
|
// spacing: Theme.spacingM
|
||||||
|
//
|
||||||
|
// StyledText {
|
||||||
|
// text: "Popout content here"
|
||||||
|
// color: Theme.surfaceText
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"id": "myWidget",
|
||||||
|
"name": "My Widget",
|
||||||
|
"description": "A custom bar widget",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "Your Name",
|
||||||
|
"type": "widget",
|
||||||
|
"capabilities": ["dankbar-widget"],
|
||||||
|
"component": "./Widget.qml",
|
||||||
|
"icon": "extension",
|
||||||
|
"settings": "./Settings.qml",
|
||||||
|
"permissions": ["settings_read", "settings_write"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
# Advanced Patterns
|
||||||
|
|
||||||
|
Patterns observed in production DMS plugins that go beyond the basics.
|
||||||
|
|
||||||
|
## Plugin Variants
|
||||||
|
|
||||||
|
Create multiple widget instances from a single plugin definition. Each variant has its own configuration.
|
||||||
|
|
||||||
|
### Manifest
|
||||||
|
|
||||||
|
No special manifest changes needed - the variant system is built into PluginComponent.
|
||||||
|
|
||||||
|
### Widget with Variant Support
|
||||||
|
|
||||||
|
```qml
|
||||||
|
PluginComponent {
|
||||||
|
property string variantId: ""
|
||||||
|
property var variantData: ({})
|
||||||
|
|
||||||
|
property string displayText: variantData?.text || "Default"
|
||||||
|
|
||||||
|
horizontalBarPill: Component {
|
||||||
|
StyledRect {
|
||||||
|
width: label.implicitWidth + Theme.spacingM * 2
|
||||||
|
height: parent.widgetThickness
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
id: label
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: root.displayText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Widget format in bar config: `pluginId:variantId` (e.g., `exampleVariants:variant_1234567890`)
|
||||||
|
|
||||||
|
### Settings with Variant Management
|
||||||
|
|
||||||
|
```qml
|
||||||
|
PluginSettings {
|
||||||
|
pluginId: "exampleVariants"
|
||||||
|
|
||||||
|
// Variant creation UI
|
||||||
|
DankButton {
|
||||||
|
text: "Add New Instance"
|
||||||
|
onClicked: {
|
||||||
|
var id = "variant_" + Date.now()
|
||||||
|
root.createVariant(id, { name: "New Instance", text: "Hello" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-variant configuration
|
||||||
|
Repeater {
|
||||||
|
model: root.variants
|
||||||
|
delegate: Column {
|
||||||
|
StringSetting {
|
||||||
|
settingKey: modelData.id + "_text"
|
||||||
|
label: modelData.name || modelData.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## JavaScript Utility Files
|
||||||
|
|
||||||
|
For complex logic, split into `.js` files:
|
||||||
|
|
||||||
|
### utils.js
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
.pragma library
|
||||||
|
|
||||||
|
function formatDuration(ms) {
|
||||||
|
if (ms < 60000) return "just now"
|
||||||
|
if (ms < 3600000) return Math.floor(ms / 60000) + "m ago"
|
||||||
|
return Math.floor(ms / 3600000) + "h ago"
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseResponse(json) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(json)
|
||||||
|
} catch (e) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using in QML
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import "utils.js" as Utils
|
||||||
|
|
||||||
|
Item {
|
||||||
|
StyledText {
|
||||||
|
text: Utils.formatDuration(Date.now() - timestamp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `.pragma library` directive makes the JS file a shared singleton - it is loaded once and shared across all QML instances that import it.
|
||||||
|
|
||||||
|
## qmldir for Singleton Services
|
||||||
|
|
||||||
|
For plugins with internal singleton services:
|
||||||
|
|
||||||
|
### qmldir
|
||||||
|
|
||||||
|
```
|
||||||
|
singleton MyService 1.0 MyService.qml
|
||||||
|
```
|
||||||
|
|
||||||
|
### MyService.qml
|
||||||
|
|
||||||
|
```qml
|
||||||
|
pragma Singleton
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
QtObject {
|
||||||
|
property var cache: ({})
|
||||||
|
|
||||||
|
function getData(key) {
|
||||||
|
return cache[key] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function setData(key, value) {
|
||||||
|
cache[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using the singleton
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import "." as Local
|
||||||
|
|
||||||
|
Item {
|
||||||
|
Component.onCompleted: {
|
||||||
|
Local.MyService.setData("key", "value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Inline Component Declarations
|
||||||
|
|
||||||
|
Reusable sub-components defined inline:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
Item {
|
||||||
|
component StatusBadge: Rectangle {
|
||||||
|
property string label: ""
|
||||||
|
property color badgeColor: Theme.primary
|
||||||
|
|
||||||
|
width: badgeText.implicitWidth + Theme.spacingM * 2
|
||||||
|
height: 24
|
||||||
|
radius: 12
|
||||||
|
color: badgeColor
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
id: badgeText
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: label
|
||||||
|
color: Theme.onPrimary
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
spacing: Theme.spacingS
|
||||||
|
StatusBadge { label: "Running"; badgeColor: Theme.success }
|
||||||
|
StatusBadge { label: "Stopped"; badgeColor: Theme.error }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Multi-Provider Adapter Pattern
|
||||||
|
|
||||||
|
For plugins supporting multiple backends (AI providers, API services):
|
||||||
|
|
||||||
|
### apiAdapters.js
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
.pragma library
|
||||||
|
|
||||||
|
function createAdapter(provider) {
|
||||||
|
switch (provider) {
|
||||||
|
case "openai": return {
|
||||||
|
url: "https://api.openai.com/v1/chat/completions",
|
||||||
|
headers: (key) => ({ "Authorization": "Bearer " + key }),
|
||||||
|
formatRequest: (messages) => JSON.stringify({ model: "gpt-4", messages: messages }),
|
||||||
|
parseResponse: (text) => JSON.parse(text).choices[0].message.content
|
||||||
|
}
|
||||||
|
case "anthropic": return {
|
||||||
|
url: "https://api.anthropic.com/v1/messages",
|
||||||
|
headers: (key) => ({ "x-api-key": key, "anthropic-version": "2023-06-01" }),
|
||||||
|
formatRequest: (messages) => JSON.stringify({ model: "claude-sonnet-4-20250514", messages: messages }),
|
||||||
|
parseResponse: (text) => JSON.parse(text).content[0].text
|
||||||
|
}
|
||||||
|
default: return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## IPC Integration
|
||||||
|
|
||||||
|
For plugins that respond to keyboard shortcuts or external commands:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
PluginComponent {
|
||||||
|
Connections {
|
||||||
|
target: DMSIpc
|
||||||
|
function onCommandReceived(command, args) {
|
||||||
|
if (command === "myPlugin.toggle") {
|
||||||
|
doToggle()
|
||||||
|
} else if (command === "myPlugin.next") {
|
||||||
|
goNext()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
External trigger: `dms ipc call myPlugin.toggle`
|
||||||
|
|
||||||
|
## Networking with Quickshell.Networking
|
||||||
|
|
||||||
|
For API calls using the built-in networking module:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import Quickshell.Networking
|
||||||
|
|
||||||
|
Item {
|
||||||
|
NetworkRequest {
|
||||||
|
id: request
|
||||||
|
url: "https://api.example.com/data"
|
||||||
|
method: "GET"
|
||||||
|
|
||||||
|
onResponseReceived: (response) => {
|
||||||
|
const data = JSON.parse(response.body)
|
||||||
|
processData(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
onErrorOccurred: (error) => {
|
||||||
|
console.error("Network error:", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchData() {
|
||||||
|
request.send()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Toast Notifications
|
||||||
|
|
||||||
|
Show user feedback:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import qs.Services
|
||||||
|
|
||||||
|
// Info toast
|
||||||
|
ToastService?.showInfo("Operation completed")
|
||||||
|
|
||||||
|
// With title
|
||||||
|
ToastService?.showInfo("Plugin Name", "Data refreshed successfully")
|
||||||
|
```
|
||||||
|
|
||||||
|
Always use optional chaining since ToastService may not be available in all contexts.
|
||||||
|
|
||||||
|
## Clipboard Operations
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import Quickshell
|
||||||
|
|
||||||
|
function copyToClipboard(text) {
|
||||||
|
Quickshell.execDetached(["dms", "cl", "copy", text])
|
||||||
|
ToastService?.showInfo("Copied to clipboard")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Do NOT use `globalThis.clipboard`, `navigator.clipboard`, or any browser API - they do not exist in the QML runtime.
|
||||||
|
|
||||||
|
## Multi-File Plugin Architecture
|
||||||
|
|
||||||
|
Large plugins can be split across multiple files:
|
||||||
|
|
||||||
|
```
|
||||||
|
MyPlugin/
|
||||||
|
plugin.json
|
||||||
|
Main.qml # Main widget component
|
||||||
|
Settings.qml # Settings UI
|
||||||
|
DetailView.qml # Popout detail view
|
||||||
|
utils.js # Utility functions
|
||||||
|
apiAdapter.js # API adapter layer
|
||||||
|
qmldir # Optional: singleton registrations
|
||||||
|
```
|
||||||
|
|
||||||
|
Import sibling files:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
// In Main.qml
|
||||||
|
import "." as Local
|
||||||
|
|
||||||
|
Item {
|
||||||
|
Loader {
|
||||||
|
source: "DetailView.qml"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Tips
|
||||||
|
|
||||||
|
1. Use `Proc.runCommand` with appropriate debounce for external commands
|
||||||
|
2. Pre-cache images and thumbnails for image-heavy plugins
|
||||||
|
3. Limit concurrent network requests
|
||||||
|
4. Use `Timer` with reasonable intervals (don't poll faster than needed)
|
||||||
|
5. Lazy-load heavy content (use `Loader` for complex popout content)
|
||||||
|
6. Avoid blocking the UI thread with synchronous operations
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
# Daemon Plugin Guide
|
||||||
|
|
||||||
|
Daemon plugins are invisible background services that react to events and execute actions. They have no bar pills or desktop presence.
|
||||||
|
|
||||||
|
## Base Component
|
||||||
|
|
||||||
|
Daemons use `PluginComponent` with no bar pills:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import QtQuick
|
||||||
|
import qs.Common
|
||||||
|
import qs.Services
|
||||||
|
import qs.Modules.Plugins
|
||||||
|
|
||||||
|
PluginComponent {
|
||||||
|
id: root
|
||||||
|
property var popoutService: null
|
||||||
|
|
||||||
|
// Event-driven logic goes here
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## When to Use Daemons
|
||||||
|
|
||||||
|
- Monitor system events (wallpaper changes, battery level, notifications)
|
||||||
|
- Run periodic background tasks (polling APIs, checking system state)
|
||||||
|
- Execute scripts in response to events
|
||||||
|
- Control shell UI via PopoutService based on conditions
|
||||||
|
|
||||||
|
## Event-Driven Pattern
|
||||||
|
|
||||||
|
Use `Connections` to react to service signals:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
PluginComponent {
|
||||||
|
property var popoutService: null
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: SessionData
|
||||||
|
function onWallpaperPathChanged() {
|
||||||
|
console.log("Wallpaper changed to:", SessionData.wallpaperPath)
|
||||||
|
runScript(SessionData.wallpaperPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: BatteryService
|
||||||
|
function onPercentageChanged() {
|
||||||
|
if (BatteryService.percentage < 10 && !BatteryService.isCharging) {
|
||||||
|
popoutService?.openBattery()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Available Services
|
||||||
|
|
||||||
|
Common services daemons can connect to:
|
||||||
|
|
||||||
|
| Service | Signals/Properties | Description |
|
||||||
|
|---------|-------------------|-------------|
|
||||||
|
| `SessionData` | `wallpaperPath`, `onWallpaperPathChanged` | Desktop session state |
|
||||||
|
| `BatteryService` | `percentage`, `isCharging`, `batteryAvailable` | Battery status |
|
||||||
|
| `NotificationService` | `onNotificationReceived(notification)` | Desktop notifications |
|
||||||
|
| `PluginService` | `onPluginLoaded`, `onGlobalVarChanged` | Plugin lifecycle |
|
||||||
|
|
||||||
|
Import services from `qs.Services`.
|
||||||
|
|
||||||
|
## Process Execution
|
||||||
|
|
||||||
|
### Simple command with Proc
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import qs.Common
|
||||||
|
|
||||||
|
PluginComponent {
|
||||||
|
function runScript(arg) {
|
||||||
|
Proc.runCommand(
|
||||||
|
"myDaemon.script",
|
||||||
|
["bash", "-c", "echo 'Processing: " + arg + "'"],
|
||||||
|
(stdout, exitCode) => {
|
||||||
|
if (exitCode === 0) {
|
||||||
|
console.log("Script output:", stdout)
|
||||||
|
} else {
|
||||||
|
ToastService?.showInfo("Script failed: exit " + exitCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Long-running process with Process component
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import Quickshell.Io
|
||||||
|
|
||||||
|
PluginComponent {
|
||||||
|
property string scriptPath: ""
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: proc
|
||||||
|
command: ["bash", scriptPath]
|
||||||
|
running: false
|
||||||
|
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onTextReceived: (text) => {
|
||||||
|
console.log("stdout:", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stderr: StdioCollector {
|
||||||
|
onTextReceived: (text) => {
|
||||||
|
console.error("stderr:", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onExited: (exitCode) => {
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
ToastService?.showInfo("Process failed: exit " + exitCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startProcess() {
|
||||||
|
if (scriptPath && !proc.running) {
|
||||||
|
proc.running = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Timer-Based Polling
|
||||||
|
|
||||||
|
```qml
|
||||||
|
PluginComponent {
|
||||||
|
Timer {
|
||||||
|
interval: 60000 // every minute
|
||||||
|
running: true
|
||||||
|
repeat: true
|
||||||
|
onTriggered: checkStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkStatus() {
|
||||||
|
Proc.runCommand(
|
||||||
|
"myDaemon.check",
|
||||||
|
["sh", "-c", "systemctl is-active myservice"],
|
||||||
|
(stdout, exitCode) => {
|
||||||
|
const active = stdout.trim() === "active"
|
||||||
|
PluginService.setGlobalVar("myDaemon", "serviceActive", active)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Persistence
|
||||||
|
|
||||||
|
Daemons access PluginService directly (it's injected via PluginComponent):
|
||||||
|
|
||||||
|
```qml
|
||||||
|
PluginComponent {
|
||||||
|
property string configuredScript: pluginData?.scriptPath || ""
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: pluginService
|
||||||
|
function onPluginDataChanged(changedId) {
|
||||||
|
if (changedId === pluginId) {
|
||||||
|
configuredScript = pluginService.loadPluginData(pluginId, "scriptPath", "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## PopoutService Usage
|
||||||
|
|
||||||
|
Daemons can control shell UI via the injected popoutService:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
PluginComponent {
|
||||||
|
property var popoutService: null
|
||||||
|
|
||||||
|
function showAlert() {
|
||||||
|
popoutService?.openNotificationCenter()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSettings() {
|
||||||
|
popoutService?.openSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
See [popout-service-reference.md](popout-service-reference.md) for the full API.
|
||||||
|
|
||||||
|
## Complete Example
|
||||||
|
|
||||||
|
Based on the WallpaperWatcherDaemon:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import qs.Common
|
||||||
|
import qs.Services
|
||||||
|
import qs.Modules.Plugins
|
||||||
|
|
||||||
|
PluginComponent {
|
||||||
|
id: root
|
||||||
|
property var popoutService: null
|
||||||
|
|
||||||
|
property string scriptPath: pluginData?.scriptPath || ""
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: pluginService
|
||||||
|
function onPluginDataChanged(changedId) {
|
||||||
|
if (changedId === pluginId) {
|
||||||
|
scriptPath = pluginService.loadPluginData(pluginId, "scriptPath", "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: SessionData
|
||||||
|
function onWallpaperPathChanged() {
|
||||||
|
if (scriptPath) {
|
||||||
|
runWallpaperScript(SessionData.wallpaperPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runWallpaperScript(wallpaperPath) {
|
||||||
|
console.log("[WallpaperWatcher] Running script:", scriptPath, wallpaperPath)
|
||||||
|
|
||||||
|
Proc.runCommand(
|
||||||
|
"wallpaperWatcher.run",
|
||||||
|
["bash", scriptPath, wallpaperPath],
|
||||||
|
(stdout, exitCode) => {
|
||||||
|
if (exitCode === 0) {
|
||||||
|
console.log("[WallpaperWatcher] Script output:", stdout)
|
||||||
|
} else {
|
||||||
|
console.error("[WallpaperWatcher] Script failed:", exitCode)
|
||||||
|
ToastService?.showInfo("Wallpaper script failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: {
|
||||||
|
console.log("[WallpaperWatcher] Daemon started")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manifest Example
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "wallpaperWatcher",
|
||||||
|
"name": "Wallpaper Watcher",
|
||||||
|
"description": "Runs a script when the wallpaper changes",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "Developer",
|
||||||
|
"type": "daemon",
|
||||||
|
"capabilities": ["wallpaper-automation"],
|
||||||
|
"component": "./WallpaperWatcher.qml",
|
||||||
|
"icon": "wallpaper",
|
||||||
|
"settings": "./Settings.qml",
|
||||||
|
"permissions": ["settings_read", "settings_write", "process"]
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
# Data Persistence Guide
|
||||||
|
|
||||||
|
DMS plugins have three tiers of data persistence, each suited for different use cases.
|
||||||
|
|
||||||
|
## Tier 1: Plugin Data (Settings)
|
||||||
|
|
||||||
|
Persisted to `settings.json`. Use for user preferences and configuration.
|
||||||
|
|
||||||
|
### Saving
|
||||||
|
|
||||||
|
```qml
|
||||||
|
pluginService.savePluginData(pluginId, "key", value)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Loading
|
||||||
|
|
||||||
|
```qml
|
||||||
|
var value = pluginService.loadPluginData(pluginId, "key", defaultValue)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reactive Access via pluginData
|
||||||
|
|
||||||
|
`PluginComponent` has a reactive `pluginData` property that auto-loads from settings:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
PluginComponent {
|
||||||
|
property string displayText: pluginData?.text || "Default"
|
||||||
|
property bool showIcon: pluginData?.showIcon !== undefined ? pluginData.showIcon : true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reacting to Settings Changes
|
||||||
|
|
||||||
|
When settings are changed (e.g., from the settings UI), react with `Connections`:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
Connections {
|
||||||
|
target: pluginService
|
||||||
|
function onPluginDataChanged(changedId) {
|
||||||
|
if (changedId !== pluginId) return
|
||||||
|
displayText = pluginService.loadPluginData(pluginId, "text", "Default")
|
||||||
|
showIcon = pluginService.loadPluginData(pluginId, "showIcon", true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tier 2: Plugin State
|
||||||
|
|
||||||
|
Persisted to a separate state file. Use for runtime state that should survive restarts but is not user-configurable (history, cache, counters).
|
||||||
|
|
||||||
|
### Saving
|
||||||
|
|
||||||
|
```qml
|
||||||
|
pluginService.savePluginState(pluginId, "key", value)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Loading
|
||||||
|
|
||||||
|
```qml
|
||||||
|
var state = pluginService.loadPluginState(pluginId, "key", defaultValue)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Additional Methods
|
||||||
|
|
||||||
|
```qml
|
||||||
|
pluginService.clearPluginState(pluginId)
|
||||||
|
pluginService.removePluginStateKey(pluginId, "key")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example: Persistent History
|
||||||
|
|
||||||
|
```qml
|
||||||
|
Item {
|
||||||
|
property var history: []
|
||||||
|
|
||||||
|
Component.onCompleted: {
|
||||||
|
history = pluginService?.loadPluginState(pluginId, "history", []) || []
|
||||||
|
}
|
||||||
|
|
||||||
|
function addToHistory(entry) {
|
||||||
|
history.unshift({
|
||||||
|
text: entry,
|
||||||
|
timestamp: Date.now()
|
||||||
|
})
|
||||||
|
if (history.length > 100) history = history.slice(0, 100)
|
||||||
|
pluginService?.savePluginState(pluginId, "history", history)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearHistory() {
|
||||||
|
history = []
|
||||||
|
pluginService?.removePluginStateKey(pluginId, "history")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tier 3: Global Variables (Runtime Only)
|
||||||
|
|
||||||
|
NOT persisted. Shared across all instances of a plugin. Use for cross-instance state synchronization (multi-monitor consistency, multi-instance widgets).
|
||||||
|
|
||||||
|
### Using PluginGlobalVar Component
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import qs.Modules.Plugins
|
||||||
|
|
||||||
|
PluginComponent {
|
||||||
|
PluginGlobalVar {
|
||||||
|
id: globalCounter
|
||||||
|
varName: "counter"
|
||||||
|
defaultValue: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
horizontalBarPill: Component {
|
||||||
|
StyledRect {
|
||||||
|
// ...
|
||||||
|
StyledText {
|
||||||
|
text: "Count: " + globalCounter.value
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
onClicked: globalCounter.set(globalCounter.value + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**PluginGlobalVar properties:**
|
||||||
|
|
||||||
|
| Property | Type | Description |
|
||||||
|
|----------|------|-------------|
|
||||||
|
| `varName` | string | Required: name of the global variable |
|
||||||
|
| `defaultValue` | any | Optional: default if not set |
|
||||||
|
| `value` | any | Readonly: current value |
|
||||||
|
|
||||||
|
**Methods:**
|
||||||
|
- `set(newValue)` - update the value (triggers reactivity across all instances)
|
||||||
|
|
||||||
|
### Using PluginService API Directly
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import qs.Services
|
||||||
|
|
||||||
|
property int counter: PluginService.getGlobalVar("myPlugin", "counter", 0)
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: PluginService
|
||||||
|
function onGlobalVarChanged(pluginId, varName) {
|
||||||
|
if (pluginId === "myPlugin" && varName === "counter") {
|
||||||
|
counter = PluginService.getGlobalVar("myPlugin", "counter", 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function increment() {
|
||||||
|
var current = PluginService.getGlobalVar("myPlugin", "counter", 0)
|
||||||
|
PluginService.setGlobalVar("myPlugin", "counter", current + 1)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Decision Matrix
|
||||||
|
|
||||||
|
| Need | API | Persisted | Scope |
|
||||||
|
|------|-----|-----------|-------|
|
||||||
|
| User preferences (API keys, themes, intervals) | `savePluginData` / `loadPluginData` | Yes (settings.json) | Per plugin |
|
||||||
|
| Runtime state (history, cache, counters) | `savePluginState` / `loadPluginState` | Yes (state file) | Per plugin |
|
||||||
|
| Cross-instance sync (multi-monitor data) | `PluginGlobalVar` or `getGlobalVar`/`setGlobalVar` | No (runtime only) | All instances |
|
||||||
|
| Quick reactive reads from settings | `pluginData` property | N/A (read-only) | Per instance |
|
||||||
|
|
||||||
|
## Important Notes
|
||||||
|
|
||||||
|
1. **pluginData is reactive** - bindings update automatically when data changes
|
||||||
|
2. **Global vars are NOT persistent** - they reset when the shell restarts
|
||||||
|
3. **State vs Data** - data is for user-facing settings, state is for internal runtime data
|
||||||
|
4. **Null safety** - always check `pluginService` is not null before calling methods
|
||||||
|
5. **Signal namespacing** - global var signals include `pluginId` to filter for your plugin
|
||||||
|
6. **Performance** - global vars are efficient for frequent updates; settings writes are batched
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
# Desktop Plugin Guide
|
||||||
|
|
||||||
|
Desktop plugins are widgets that appear on the desktop background layer. They support drag-and-drop positioning and resize via corner handles.
|
||||||
|
|
||||||
|
## Base Component
|
||||||
|
|
||||||
|
Desktop widgets use a plain `Item` with injected properties:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import QtQuick
|
||||||
|
import qs.Common
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property var pluginService: null
|
||||||
|
property string pluginId: ""
|
||||||
|
property bool editMode: false
|
||||||
|
property real widgetWidth: 200
|
||||||
|
property real widgetHeight: 200
|
||||||
|
property real minWidth: 150
|
||||||
|
property real minHeight: 150
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: Theme.surfaceContainer
|
||||||
|
opacity: 0.85
|
||||||
|
|
||||||
|
// Your content here
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Injected Properties
|
||||||
|
|
||||||
|
These are set automatically by the DesktopPluginWrapper:
|
||||||
|
|
||||||
|
| Property | Type | Description |
|
||||||
|
|----------|------|-------------|
|
||||||
|
| `pluginService` | object | PluginService reference for data persistence |
|
||||||
|
| `pluginId` | string | Plugin's unique identifier |
|
||||||
|
| `editMode` | bool | `true` when user is dragging/resizing |
|
||||||
|
| `widgetWidth` | real | Current widget container width |
|
||||||
|
| `widgetHeight` | real | Current widget container height |
|
||||||
|
|
||||||
|
## Optional Properties
|
||||||
|
|
||||||
|
Define these on your root item to customize behavior:
|
||||||
|
|
||||||
|
| Property | Type | Default | Description |
|
||||||
|
|----------|------|---------|-------------|
|
||||||
|
| `minWidth` | real | 100 | Minimum allowed width during resize |
|
||||||
|
| `minHeight` | real | 100 | Minimum allowed height during resize |
|
||||||
|
|
||||||
|
## Position and Size Persistence
|
||||||
|
|
||||||
|
Position (`desktopX`, `desktopY`) and size (`desktopWidth`, `desktopHeight`) are automatically managed by the DesktopPluginWrapper. You do not need to handle persistence for positioning.
|
||||||
|
|
||||||
|
## Edit Mode
|
||||||
|
|
||||||
|
When `editMode` is true, the user is repositioning or resizing. Use this to:
|
||||||
|
- Show visual indicators (borders, handles)
|
||||||
|
- Disable interactive elements to prevent accidental actions
|
||||||
|
- Display additional controls
|
||||||
|
|
||||||
|
```qml
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
border.color: root.editMode ? Theme.primary : "transparent"
|
||||||
|
border.width: root.editMode ? 2 : 0
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
enabled: !root.editMode
|
||||||
|
onClicked: doSomething()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Loading and Saving Data
|
||||||
|
|
||||||
|
Use the injected `pluginService` for data persistence:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
property string displayMode: {
|
||||||
|
if (!pluginService) return "default"
|
||||||
|
return pluginService.loadPluginData(pluginId, "displayMode", "default")
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: pluginService
|
||||||
|
function onPluginDataChanged(changedId) {
|
||||||
|
if (changedId !== pluginId) return
|
||||||
|
root.displayMode = pluginService.loadPluginData(pluginId, "displayMode", "default")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveMode(mode) {
|
||||||
|
pluginService?.savePluginData(pluginId, "displayMode", mode)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Settings Component
|
||||||
|
|
||||||
|
Desktop plugin settings use the same `PluginSettings` wrapper as other types:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import QtQuick
|
||||||
|
import qs.Common
|
||||||
|
import qs.Widgets
|
||||||
|
import qs.Modules.Plugins
|
||||||
|
|
||||||
|
PluginSettings {
|
||||||
|
pluginId: "myDesktopWidget"
|
||||||
|
|
||||||
|
SliderSetting {
|
||||||
|
settingKey: "opacity"
|
||||||
|
label: "Opacity"
|
||||||
|
description: "Widget background opacity"
|
||||||
|
defaultValue: 85
|
||||||
|
minimum: 10
|
||||||
|
maximum: 100
|
||||||
|
unit: "%"
|
||||||
|
}
|
||||||
|
|
||||||
|
SelectionSetting {
|
||||||
|
settingKey: "style"
|
||||||
|
label: "Display Style"
|
||||||
|
options: [
|
||||||
|
{ label: "Compact", value: "compact" },
|
||||||
|
{ label: "Expanded", value: "expanded" }
|
||||||
|
]
|
||||||
|
defaultValue: "compact"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## User Interaction
|
||||||
|
|
||||||
|
Desktop widgets support:
|
||||||
|
1. **Drag** - click and drag anywhere (in edit mode)
|
||||||
|
2. **Resize** - drag bottom-right corner handle (in edit mode)
|
||||||
|
3. **Edit mode toggle** - via the desktop edit button
|
||||||
|
|
||||||
|
## Complete Example
|
||||||
|
|
||||||
|
Based on the ExampleDesktopClock pattern:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import QtQuick
|
||||||
|
import qs.Common
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property var pluginService: null
|
||||||
|
property string pluginId: ""
|
||||||
|
property bool editMode: false
|
||||||
|
property real widgetWidth: 250
|
||||||
|
property real widgetHeight: 250
|
||||||
|
property real minWidth: 150
|
||||||
|
property real minHeight: 150
|
||||||
|
|
||||||
|
property string clockStyle: {
|
||||||
|
if (!pluginService) return "digital"
|
||||||
|
return pluginService.loadPluginData(pluginId, "clockStyle", "digital")
|
||||||
|
}
|
||||||
|
|
||||||
|
property real bgOpacity: {
|
||||||
|
if (!pluginService) return 0.85
|
||||||
|
var val = pluginService.loadPluginData(pluginId, "opacity", 85)
|
||||||
|
return val / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: pluginService
|
||||||
|
function onPluginDataChanged(changedId) {
|
||||||
|
if (changedId !== pluginId) return
|
||||||
|
clockStyle = pluginService.loadPluginData(pluginId, "clockStyle", "digital")
|
||||||
|
var val = pluginService.loadPluginData(pluginId, "opacity", 85)
|
||||||
|
bgOpacity = val / 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: Theme.surfaceContainer
|
||||||
|
opacity: root.bgOpacity
|
||||||
|
border.color: root.editMode ? Theme.primary : "transparent"
|
||||||
|
border.width: root.editMode ? 2 : 0
|
||||||
|
|
||||||
|
Column {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
spacing: Theme.spacingS
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
text: Qt.formatTime(new Date(), "hh:mm:ss")
|
||||||
|
color: Theme.surfaceText
|
||||||
|
font.pixelSize: root.widgetWidth * 0.15
|
||||||
|
font.weight: Font.Bold
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
text: Qt.formatDate(new Date(), "ddd, MMM d")
|
||||||
|
color: Theme.onSurfaceVariant
|
||||||
|
font.pixelSize: Theme.fontSizeMedium
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
interval: 1000
|
||||||
|
running: true
|
||||||
|
repeat: true
|
||||||
|
onTriggered: root.widgetWidth = root.widgetWidth // force update
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manifest Example
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "myDesktopClock",
|
||||||
|
"name": "Desktop Clock",
|
||||||
|
"description": "Analog and digital clock for the desktop",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "Developer",
|
||||||
|
"type": "desktop",
|
||||||
|
"capabilities": ["desktop-widget"],
|
||||||
|
"component": "./ClockWidget.qml",
|
||||||
|
"icon": "schedule",
|
||||||
|
"settings": "./Settings.qml",
|
||||||
|
"permissions": ["settings_read", "settings_write"]
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
# Launcher Plugin Guide
|
||||||
|
|
||||||
|
Launcher plugins extend the DMS launcher with custom searchable items and actions. They use trigger-based filtering and integrate directly into the app drawer.
|
||||||
|
|
||||||
|
## Base Component
|
||||||
|
|
||||||
|
Launchers use a plain `Item` (not PluginComponent):
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import QtQuick
|
||||||
|
import qs.Services
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property var pluginService: null
|
||||||
|
property string trigger: "#"
|
||||||
|
|
||||||
|
signal itemsChanged()
|
||||||
|
|
||||||
|
function getItems(query) {
|
||||||
|
// Return array of items
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
function executeItem(item) {
|
||||||
|
// Handle item selection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Required Interface
|
||||||
|
|
||||||
|
| Member | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `pluginService` | property | Injected PluginService reference (declare as `null`) |
|
||||||
|
| `trigger` | property | Trigger string for activation |
|
||||||
|
| `itemsChanged` | signal | Emit when item list changes (triggers UI refresh) |
|
||||||
|
| `getItems(query)` | function | Return array of items matching query |
|
||||||
|
| `executeItem(item)` | function | Handle item selection |
|
||||||
|
|
||||||
|
## Item Structure
|
||||||
|
|
||||||
|
Each item returned by `getItems()`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
name: "Item Display Name", // Required: shown in launcher
|
||||||
|
icon: "material:star", // Optional: icon specification
|
||||||
|
comment: "Description text", // Required: subtitle text
|
||||||
|
action: "type:data", // Required: action identifier
|
||||||
|
categories: ["MyPlugin"], // Required: array with plugin category
|
||||||
|
imageUrl: "https://..." // Optional: image for tile view
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Icon Types
|
||||||
|
|
||||||
|
### 1. Material Design Icons
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{ icon: "material:lightbulb" }
|
||||||
|
{ icon: "material:terminal" }
|
||||||
|
{ icon: "material:translate" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Uses the Material Symbols Rounded font.
|
||||||
|
|
||||||
|
### 2. Unicode / Emoji Icons
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{ icon: "unicode:smile_face" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Rendered at 70-80% of icon size with theming.
|
||||||
|
|
||||||
|
### 3. Desktop Theme Icons
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{ icon: "firefox" }
|
||||||
|
{ icon: "folder" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Uses the user's installed icon theme.
|
||||||
|
|
||||||
|
### 4. No Icon
|
||||||
|
|
||||||
|
Omit the `icon` field entirely. The launcher hides the icon area and gives full width to the item name.
|
||||||
|
|
||||||
|
## Trigger System
|
||||||
|
|
||||||
|
**Custom trigger** (items only appear when trigger is typed):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "trigger": "#" }
|
||||||
|
```
|
||||||
|
|
||||||
|
- Type `#` alone: shows all plugin items
|
||||||
|
- Type `# query`: filters plugin items by query
|
||||||
|
- The query string (without trigger) is passed to `getItems(query)`
|
||||||
|
|
||||||
|
**No trigger** (items always visible alongside regular apps):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "trigger": "" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Save empty trigger at runtime:
|
||||||
|
```qml
|
||||||
|
Component.onCompleted: {
|
||||||
|
trigger = pluginService?.loadPluginData(pluginId, "trigger", "#") ?? "#"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Action Execution
|
||||||
|
|
||||||
|
Parse action strings in `executeItem()`:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
function executeItem(item) {
|
||||||
|
const actionParts = item.action.split(":")
|
||||||
|
const actionType = actionParts[0]
|
||||||
|
const actionData = actionParts.slice(1).join(":")
|
||||||
|
|
||||||
|
switch (actionType) {
|
||||||
|
case "toast":
|
||||||
|
ToastService?.showInfo(actionData)
|
||||||
|
break
|
||||||
|
case "copy":
|
||||||
|
Quickshell.execDetached(["dms", "cl", "copy", actionData])
|
||||||
|
ToastService?.showInfo("Copied to clipboard")
|
||||||
|
break
|
||||||
|
case "exec":
|
||||||
|
Quickshell.execDetached(actionData.split(" "))
|
||||||
|
break
|
||||||
|
case "url":
|
||||||
|
Quickshell.execDetached(["xdg-open", actionData])
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
console.warn("Unknown action type:", actionType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Search / Filtering
|
||||||
|
|
||||||
|
The `query` parameter in `getItems()` contains the user's search text (without the trigger prefix).
|
||||||
|
|
||||||
|
```qml
|
||||||
|
function getItems(query) {
|
||||||
|
const allItems = [
|
||||||
|
{ name: "Calculator", icon: "material:calculate",
|
||||||
|
comment: "Open calculator", action: "exec:gnome-calculator",
|
||||||
|
categories: ["Tools"] },
|
||||||
|
{ name: "Terminal", icon: "material:terminal",
|
||||||
|
comment: "Open terminal", action: "exec:alacritty",
|
||||||
|
categories: ["Tools"] }
|
||||||
|
]
|
||||||
|
|
||||||
|
if (!query || query.length === 0) return allItems
|
||||||
|
|
||||||
|
const q = query.toLowerCase()
|
||||||
|
return allItems.filter(item =>
|
||||||
|
item.name.toLowerCase().includes(q) ||
|
||||||
|
item.comment.toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context Menu Actions
|
||||||
|
|
||||||
|
Add right-click actions to launcher items:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
function getContextMenuActions(item) {
|
||||||
|
return [
|
||||||
|
{ name: "Copy", icon: "material:content_copy",
|
||||||
|
action: "copy:" + item.name },
|
||||||
|
{ name: "Open in Browser", icon: "material:open_in_new",
|
||||||
|
action: "url:" + item.url }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Context menu actions use the same `executeItem()` handler.
|
||||||
|
|
||||||
|
## Image Tile View
|
||||||
|
|
||||||
|
For image-heavy launchers (GIF search, sticker pickers), use tile view:
|
||||||
|
|
||||||
|
In `plugin.json`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"viewMode": "tile",
|
||||||
|
"viewModeEnforced": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In items:
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
name: "Image Title",
|
||||||
|
imageUrl: "https://example.com/image.png",
|
||||||
|
comment: "Description",
|
||||||
|
action: "copy:https://example.com/image.png",
|
||||||
|
categories: ["MyPlugin"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## State Persistence
|
||||||
|
|
||||||
|
For plugins with persistent state (notes, history, favorites):
|
||||||
|
|
||||||
|
```qml
|
||||||
|
property var notes: []
|
||||||
|
|
||||||
|
Component.onCompleted: {
|
||||||
|
const saved = pluginService?.loadPluginState(pluginId, "notes", [])
|
||||||
|
if (saved) notes = saved
|
||||||
|
}
|
||||||
|
|
||||||
|
function addNote(text) {
|
||||||
|
notes.push({ text: text, timestamp: Date.now() })
|
||||||
|
pluginService?.savePluginState(pluginId, "notes", notes)
|
||||||
|
itemsChanged()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `savePluginState/loadPluginState` for runtime data and `savePluginData/loadPluginData` for user preferences.
|
||||||
|
|
||||||
|
## Settings for Trigger Configuration
|
||||||
|
|
||||||
|
Provide a PluginSettings component for trigger customization:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import QtQuick
|
||||||
|
import qs.Common
|
||||||
|
import qs.Widgets
|
||||||
|
import qs.Modules.Plugins
|
||||||
|
|
||||||
|
PluginSettings {
|
||||||
|
pluginId: "myLauncher"
|
||||||
|
|
||||||
|
StringSetting {
|
||||||
|
settingKey: "trigger"
|
||||||
|
label: "Trigger"
|
||||||
|
description: "Type this prefix to activate the launcher plugin"
|
||||||
|
placeholder: "#"
|
||||||
|
defaultValue: "#"
|
||||||
|
}
|
||||||
|
|
||||||
|
ToggleSetting {
|
||||||
|
settingKey: "noTrigger"
|
||||||
|
label: "Always Visible"
|
||||||
|
description: "Show items alongside regular apps (no trigger needed)"
|
||||||
|
defaultValue: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Example
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import qs.Services
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property var pluginService: null
|
||||||
|
property string trigger: "!"
|
||||||
|
|
||||||
|
signal itemsChanged()
|
||||||
|
|
||||||
|
property var commands: [
|
||||||
|
{ name: "Lock Screen", icon: "material:lock",
|
||||||
|
comment: "Lock the session", action: "exec:loginctl lock-session" },
|
||||||
|
{ name: "Screenshot", icon: "material:screenshot_monitor",
|
||||||
|
comment: "Take a screenshot", action: "exec:grim" },
|
||||||
|
{ name: "File Manager", icon: "material:folder",
|
||||||
|
comment: "Open file manager", action: "exec:nautilus" }
|
||||||
|
]
|
||||||
|
|
||||||
|
function getItems(query) {
|
||||||
|
if (!query) return commands
|
||||||
|
const q = query.toLowerCase()
|
||||||
|
return commands.filter(c =>
|
||||||
|
c.name.toLowerCase().includes(q) ||
|
||||||
|
c.comment.toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function executeItem(item) {
|
||||||
|
const [type, ...rest] = item.action.split(":")
|
||||||
|
const data = rest.join(":")
|
||||||
|
if (type === "exec") {
|
||||||
|
Quickshell.execDetached(data.split(" "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: {
|
||||||
|
if (pluginService) {
|
||||||
|
trigger = pluginService.loadPluginData("quickCommands", "trigger", "!")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
# Plugin Manifest Reference (plugin.json)
|
||||||
|
|
||||||
|
## Required Fields
|
||||||
|
|
||||||
|
| Field | Type | Description | Validation |
|
||||||
|
|-------|------|-------------|------------|
|
||||||
|
| `id` | string | Unique plugin identifier | camelCase, pattern `^[a-zA-Z][a-zA-Z0-9]*$` |
|
||||||
|
| `name` | string | Human-readable name | Non-empty |
|
||||||
|
| `description` | string | Short description (shown in UI) | Non-empty |
|
||||||
|
| `version` | string | Semantic version | Pattern `^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$` |
|
||||||
|
| `author` | string | Creator name or email | Non-empty |
|
||||||
|
| `type` | string | Plugin type | One of: `widget`, `daemon`, `launcher`, `desktop` |
|
||||||
|
| `capabilities` | array | Plugin capabilities | At least 1 string item |
|
||||||
|
| `component` | string | Path to main QML file | Must start with `./`, end with `.qml` |
|
||||||
|
|
||||||
|
## Conditional Requirements
|
||||||
|
|
||||||
|
| Condition | Required Field | Description |
|
||||||
|
|-----------|---------------|-------------|
|
||||||
|
| `type: "launcher"` | `trigger` | Trigger string for launcher activation (e.g., `=`, `#`, `!`) |
|
||||||
|
|
||||||
|
## Optional Fields
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `icon` | string | Material Design icon name (displayed in plugin list UI) |
|
||||||
|
| `settings` | string | Path to settings QML file (must start with `./`, end with `.qml`) |
|
||||||
|
| `requires_dms` | string | Minimum DMS version (e.g., `>=0.1.18`), pattern `^(>=?\|<=?\|=\|>\|<)\d+\.\d+\.\d+$` |
|
||||||
|
| `requires` | array | System tool dependencies (e.g., `["curl", "jq"]`) |
|
||||||
|
| `permissions` | array | Required permissions |
|
||||||
|
| `trigger` | string | Launcher trigger string (required for launcher type) |
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
| Permission | Description | Enforced |
|
||||||
|
|------------|-------------|----------|
|
||||||
|
| `settings_read` | Read plugin configuration | No (not currently enforced) |
|
||||||
|
| `settings_write` | Write plugin configuration / use PluginSettings | **Yes** |
|
||||||
|
| `process` | Execute system commands | No (not currently enforced) |
|
||||||
|
| `network` | Network access | No (not currently enforced) |
|
||||||
|
|
||||||
|
If your plugin has a `settings` component but does not declare `settings_write`, users will see an error instead of the settings UI.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
Capabilities are free-form strings that describe what the plugin does. Common values:
|
||||||
|
|
||||||
|
- `dankbar-widget` - general bar widget
|
||||||
|
- `control-center` - integrates with Control Center
|
||||||
|
- `monitoring` - system/service monitoring
|
||||||
|
- `launcher` - launcher search provider
|
||||||
|
- `desktop-widget` - desktop background widget
|
||||||
|
- `ai` - AI/LLM integration
|
||||||
|
- `slideout` - uses slideout panel
|
||||||
|
|
||||||
|
## Complete Example
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "myPlugin",
|
||||||
|
"name": "My Plugin",
|
||||||
|
"description": "A sample plugin demonstrating all fields",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "Developer Name",
|
||||||
|
"type": "widget",
|
||||||
|
"capabilities": ["dankbar-widget", "control-center"],
|
||||||
|
"component": "./MyWidget.qml",
|
||||||
|
"icon": "extension",
|
||||||
|
"settings": "./Settings.qml",
|
||||||
|
"requires_dms": ">=0.1.18",
|
||||||
|
"requires": ["curl", "jq"],
|
||||||
|
"permissions": ["settings_read", "settings_write", "process", "network"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Launcher Example
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "myLauncher",
|
||||||
|
"name": "My Launcher",
|
||||||
|
"description": "Search and execute custom actions",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "Developer Name",
|
||||||
|
"type": "launcher",
|
||||||
|
"capabilities": ["launcher"],
|
||||||
|
"component": "./MyLauncher.qml",
|
||||||
|
"trigger": "#",
|
||||||
|
"icon": "search",
|
||||||
|
"settings": "./Settings.qml",
|
||||||
|
"requires_dms": ">=0.1.18",
|
||||||
|
"permissions": ["settings_read", "settings_write"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## JSON Schema
|
||||||
|
|
||||||
|
The complete JSON schema is available at `assets/plugin-schema.json` in this skill. Validate with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Using python
|
||||||
|
python3 -c "
|
||||||
|
import json, jsonschema
|
||||||
|
schema = json.load(open('path/to/plugin-schema.json'))
|
||||||
|
manifest = json.load(open('plugin.json'))
|
||||||
|
jsonschema.validate(manifest, schema)
|
||||||
|
print('Valid!')
|
||||||
|
"
|
||||||
|
|
||||||
|
# Using jq (syntax check only)
|
||||||
|
jq . plugin.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Additional Properties
|
||||||
|
|
||||||
|
The schema allows additional properties (`"additionalProperties": true`), so plugins can include custom fields. Common custom fields seen in production plugins:
|
||||||
|
|
||||||
|
- `viewMode` - launcher display mode (`"tile"` for image grids)
|
||||||
|
- `viewModeEnforced` - lock launcher to specific view mode (`true`/`false`)
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# PopoutService Reference
|
||||||
|
|
||||||
|
The `PopoutService` singleton lets plugins control all DMS popouts and modals. It is automatically injected into widget, daemon, and settings components.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
Declare the property in your component for injection to work:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
property var popoutService: null
|
||||||
|
```
|
||||||
|
|
||||||
|
Without this declaration, injection fails with: `Cannot assign to non-existent property "popoutService"`
|
||||||
|
|
||||||
|
## Popouts (DankPopout-based)
|
||||||
|
|
||||||
|
| Component | Open | Close | Toggle |
|
||||||
|
|-----------|------|-------|--------|
|
||||||
|
| Control Center | `openControlCenter()` | `closeControlCenter()` | `toggleControlCenter()` |
|
||||||
|
| Notification Center | `openNotificationCenter()` | `closeNotificationCenter()` | `toggleNotificationCenter()` |
|
||||||
|
| App Drawer | `openAppDrawer()` | `closeAppDrawer()` | `toggleAppDrawer()` |
|
||||||
|
| Process List | `openProcessList()` | `closeProcessList()` | `toggleProcessList()` |
|
||||||
|
| DankDash | `openDankDash(tab)` | `closeDankDash()` | `toggleDankDash(tab)` |
|
||||||
|
| Battery | `openBattery()` | `closeBattery()` | `toggleBattery()` |
|
||||||
|
| VPN | `openVpn()` | `closeVpn()` | `toggleVpn()` |
|
||||||
|
| System Update | `openSystemUpdate()` | `closeSystemUpdate()` | `toggleSystemUpdate()` |
|
||||||
|
|
||||||
|
## Modals (DankModal-based)
|
||||||
|
|
||||||
|
| Modal | Show | Hide | Notes |
|
||||||
|
|-------|------|------|-------|
|
||||||
|
| Settings | `openSettings()` | `closeSettings()` | Full settings interface |
|
||||||
|
| Clipboard History | `openClipboardHistory()` | `closeClipboardHistory()` | Clipboard integration |
|
||||||
|
| Launcher | `openDankLauncherV2()` | `closeDankLauncherV2()` | Also has `toggleDankLauncherV2()` |
|
||||||
|
| Power Menu | `openPowerMenu()` | `closePowerMenu()` | Also has `togglePowerMenu()` |
|
||||||
|
| Process List Modal | `showProcessListModal()` | `hideProcessListModal()` | Has `toggleProcessListModal()` |
|
||||||
|
| Color Picker | `showColorPicker()` | `hideColorPicker()` | Theme color selection |
|
||||||
|
| Notification | `showNotificationModal()` | `hideNotificationModal()` | Notification details |
|
||||||
|
| WiFi Password | `showWifiPasswordModal()` | `hideWifiPasswordModal()` | Network auth |
|
||||||
|
| Network Info | `showNetworkInfoModal()` | `hideNetworkInfoModal()` | Network details |
|
||||||
|
|
||||||
|
## Slideouts
|
||||||
|
|
||||||
|
| Component | Open | Close | Toggle |
|
||||||
|
|-----------|------|-------|--------|
|
||||||
|
| Notepad | `openNotepad()` | `closeNotepad()` | `toggleNotepad()` |
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Simple toggle
|
||||||
|
|
||||||
|
```qml
|
||||||
|
MouseArea {
|
||||||
|
onClicked: popoutService?.toggleControlCenter()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Conditional popout
|
||||||
|
|
||||||
|
```qml
|
||||||
|
Connections {
|
||||||
|
target: BatteryService
|
||||||
|
function onPercentageChanged() {
|
||||||
|
if (BatteryService.percentage < 10 && !BatteryService.isCharging) {
|
||||||
|
popoutService?.openBattery()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Context menu with multiple actions
|
||||||
|
|
||||||
|
```qml
|
||||||
|
MouseArea {
|
||||||
|
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||||
|
onClicked: (mouse) => {
|
||||||
|
if (mouse.button === Qt.RightButton) contextMenu.popup()
|
||||||
|
else popoutService?.toggleControlCenter()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Menu {
|
||||||
|
id: contextMenu
|
||||||
|
MenuItem { text: "Settings"; onClicked: popoutService?.openSettings() }
|
||||||
|
MenuItem { text: "Notifications"; onClicked: popoutService?.toggleNotificationCenter() }
|
||||||
|
MenuItem { text: "Power"; onClicked: popoutService?.openPowerMenu() }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Position-aware toggle (from bar pill)
|
||||||
|
|
||||||
|
Some toggle functions accept position parameters for proper popout placement:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
pillClickAction: (x, y, width, section, screen) => {
|
||||||
|
popoutService?.toggleControlCenter(x, y, width, section, screen)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Always use optional chaining** (`?.`) - the service may not be injected yet
|
||||||
|
2. **Check feature availability** before opening feature-specific popouts:
|
||||||
|
```qml
|
||||||
|
if (BatteryService.batteryAvailable) {
|
||||||
|
popoutService?.openBattery()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
3. **Lazy loading** - first access may activate lazy loaders; this is normal
|
||||||
|
4. **Popouts are shared** - avoid opening conflicting popouts simultaneously
|
||||||
|
5. **User intent** - only trigger popouts from user actions or critical system events
|
||||||
|
6. **Multi-monitor** - positioned popouts are screen-aware when using position parameters
|
||||||
|
|
||||||
|
## Injection Locations
|
||||||
|
|
||||||
|
The service is injected at these points:
|
||||||
|
- `DMSShell.qml` - daemon plugins
|
||||||
|
- `WidgetHost.qml` - widget plugins in left/right bar sections
|
||||||
|
- `CenterSection.qml` - center bar widgets
|
||||||
|
- `PluginsTab.qml` - settings components
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
# Settings Components Reference
|
||||||
|
|
||||||
|
All plugin settings use the `PluginSettings` wrapper. Setting components auto-save on change and auto-load on creation.
|
||||||
|
|
||||||
|
## PluginSettings Wrapper
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import QtQuick
|
||||||
|
import qs.Common
|
||||||
|
import qs.Widgets
|
||||||
|
import qs.Modules.Plugins
|
||||||
|
|
||||||
|
PluginSettings {
|
||||||
|
pluginId: "yourPlugin" // Required: must match plugin.json id
|
||||||
|
|
||||||
|
// Setting components go here
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important:** The plugin must declare `"permissions": ["settings_write"]` in plugin.json for the settings UI to render. Without it, users see an error.
|
||||||
|
|
||||||
|
**PluginSettings provides to children:**
|
||||||
|
- `saveValue(key, value)` - save a setting value
|
||||||
|
- `loadValue(key, defaultValue)` - load a setting value
|
||||||
|
- `saveState(key, value)` - save plugin state (separate file)
|
||||||
|
- `loadState(key, defaultValue)` - load plugin state
|
||||||
|
- `clearState()` - clear all plugin state
|
||||||
|
- Variant management functions (for variant plugins)
|
||||||
|
|
||||||
|
## StringSetting
|
||||||
|
|
||||||
|
Text input field.
|
||||||
|
|
||||||
|
```qml
|
||||||
|
StringSetting {
|
||||||
|
settingKey: "apiKey" // Required: storage key
|
||||||
|
label: "API Key" // Required: display label
|
||||||
|
description: "Your API key" // Optional: help text
|
||||||
|
placeholder: "sk-..." // Optional: input placeholder
|
||||||
|
defaultValue: "" // Optional: default (default: "")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Layout:** Vertical stack - label, description, input field.
|
||||||
|
|
||||||
|
## ToggleSetting
|
||||||
|
|
||||||
|
Boolean toggle switch.
|
||||||
|
|
||||||
|
```qml
|
||||||
|
ToggleSetting {
|
||||||
|
settingKey: "notifications" // Required: storage key
|
||||||
|
label: "Enable Notifications" // Required: display label
|
||||||
|
description: "Show alerts" // Optional: help text
|
||||||
|
defaultValue: true // Optional: default (default: false)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Layout:** Horizontal - label/description on left, toggle on right.
|
||||||
|
|
||||||
|
## SelectionSetting
|
||||||
|
|
||||||
|
Dropdown menu.
|
||||||
|
|
||||||
|
```qml
|
||||||
|
SelectionSetting {
|
||||||
|
settingKey: "theme" // Required: storage key
|
||||||
|
label: "Theme" // Required: display label
|
||||||
|
description: "Color scheme" // Optional: help text
|
||||||
|
options: [ // Required: array of options
|
||||||
|
{ label: "Dark", value: "dark" },
|
||||||
|
{ label: "Light", value: "light" },
|
||||||
|
{ label: "Auto", value: "auto" }
|
||||||
|
]
|
||||||
|
defaultValue: "dark" // Optional: default value
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Options can be `{ label, value }` objects or simple strings. Stores the `value` field, displays the `label` field.
|
||||||
|
|
||||||
|
**Layout:** Horizontal - label/description on left, dropdown on right.
|
||||||
|
|
||||||
|
**Reacting to changes:**
|
||||||
|
```qml
|
||||||
|
SelectionSetting {
|
||||||
|
settingKey: "updateInterval"
|
||||||
|
label: "Update Interval"
|
||||||
|
options: [
|
||||||
|
{ label: "1 minute", value: "60" },
|
||||||
|
{ label: "5 minutes", value: "300" }
|
||||||
|
]
|
||||||
|
defaultValue: "300"
|
||||||
|
onValueChanged: (newValue) => {
|
||||||
|
console.log("Interval changed to:", newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## SliderSetting
|
||||||
|
|
||||||
|
Numeric slider with min/max.
|
||||||
|
|
||||||
|
```qml
|
||||||
|
SliderSetting {
|
||||||
|
settingKey: "opacity" // Required: storage key
|
||||||
|
label: "Opacity" // Required: display label
|
||||||
|
description: "Background" // Optional: help text
|
||||||
|
defaultValue: 85 // Optional: default value
|
||||||
|
minimum: 0 // Required: min value
|
||||||
|
maximum: 100 // Required: max value
|
||||||
|
unit: "%" // Optional: unit label shown after value
|
||||||
|
leftIcon: "dark_mode" // Optional: Material icon on left
|
||||||
|
rightIcon: "light_mode" // Optional: Material icon on right
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## ColorSetting
|
||||||
|
|
||||||
|
Color picker.
|
||||||
|
|
||||||
|
```qml
|
||||||
|
ColorSetting {
|
||||||
|
settingKey: "accentColor" // Required: storage key
|
||||||
|
label: "Accent Color" // Required: display label
|
||||||
|
description: "Custom accent" // Optional: help text
|
||||||
|
defaultValue: "#ff5722" // Optional: default hex color
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Displays a color swatch that opens a color picker dialog.
|
||||||
|
|
||||||
|
## ListSetting
|
||||||
|
|
||||||
|
Manage a list of items with manual add/remove. Use when you need custom UI for adding items.
|
||||||
|
|
||||||
|
```qml
|
||||||
|
ListSetting {
|
||||||
|
id: itemList
|
||||||
|
settingKey: "items" // Required: storage key
|
||||||
|
label: "Saved Items" // Required: display label
|
||||||
|
description: "Your items" // Optional: help text
|
||||||
|
defaultValue: [] // Optional: default array
|
||||||
|
delegate: Component { // Optional: custom item display
|
||||||
|
StyledRect {
|
||||||
|
width: parent.width
|
||||||
|
height: 40
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: Theme.surfaceContainerHigh
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: Theme.spacingM
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: modelData.name
|
||||||
|
color: Theme.surfaceText
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: Theme.spacingM
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: 60
|
||||||
|
height: 28
|
||||||
|
color: removeArea.containsMouse ? Theme.errorHover : Theme.error
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: "Remove"
|
||||||
|
color: Theme.errorText
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
font.weight: Font.Medium
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: removeArea
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: itemList.removeItem(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Methods:**
|
||||||
|
- `addItem(item)` - add an item to the list
|
||||||
|
- `removeItem(index)` - remove item at index
|
||||||
|
|
||||||
|
## ListSettingWithInput
|
||||||
|
|
||||||
|
Complete list management with built-in form. Best for collecting structured data.
|
||||||
|
|
||||||
|
```qml
|
||||||
|
ListSettingWithInput {
|
||||||
|
settingKey: "locations" // Required: storage key
|
||||||
|
label: "Locations" // Required: display label
|
||||||
|
description: "Track zones" // Optional: help text
|
||||||
|
defaultValue: [] // Optional: default array
|
||||||
|
fields: [ // Required: field definitions
|
||||||
|
{
|
||||||
|
id: "name", // Required: key in saved object
|
||||||
|
label: "Name", // Required: column header
|
||||||
|
placeholder: "Home", // Optional: input placeholder
|
||||||
|
width: 150, // Optional: column width (default: 200)
|
||||||
|
required: true // Optional: must have value to add
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "timezone",
|
||||||
|
label: "Timezone",
|
||||||
|
placeholder: "America/New_York",
|
||||||
|
width: 200,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Automatically generates: column headers, input fields, add button with validation, list display, remove buttons.
|
||||||
|
|
||||||
|
## Mixing Custom UI with Settings
|
||||||
|
|
||||||
|
You can interleave regular QML elements with setting components:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
PluginSettings {
|
||||||
|
pluginId: "myPlugin"
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
width: parent.width
|
||||||
|
text: "General Settings"
|
||||||
|
font.pixelSize: Theme.fontSizeLarge
|
||||||
|
font.weight: Font.Bold
|
||||||
|
color: Theme.surfaceText
|
||||||
|
}
|
||||||
|
|
||||||
|
StringSetting {
|
||||||
|
settingKey: "name"
|
||||||
|
label: "Display Name"
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
width: parent.width
|
||||||
|
text: "Advanced Settings"
|
||||||
|
font.pixelSize: Theme.fontSizeLarge
|
||||||
|
font.weight: Font.Bold
|
||||||
|
color: Theme.surfaceText
|
||||||
|
topPadding: Theme.spacingL
|
||||||
|
}
|
||||||
|
|
||||||
|
ToggleSetting {
|
||||||
|
settingKey: "debug"
|
||||||
|
label: "Debug Mode"
|
||||||
|
defaultValue: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Default Values
|
||||||
|
|
||||||
|
Define sensible defaults in every setting component. The default is used when no saved value exists:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
StringSetting { settingKey: "text"; defaultValue: "Hello" }
|
||||||
|
ToggleSetting { settingKey: "enabled"; defaultValue: true }
|
||||||
|
SelectionSetting { settingKey: "mode"; defaultValue: "auto" }
|
||||||
|
SliderSetting { settingKey: "opacity"; defaultValue: 85 }
|
||||||
|
ColorSetting { settingKey: "color"; defaultValue: "#ff5722" }
|
||||||
|
ListSetting { settingKey: "items"; defaultValue: [] }
|
||||||
|
ListSettingWithInput { settingKey: "data"; defaultValue: [] }
|
||||||
|
```
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
# Theme Property Reference
|
||||||
|
|
||||||
|
All theme properties are accessed via the `Theme` singleton from `qs.Common`. Always use these instead of hardcoded values.
|
||||||
|
|
||||||
|
## Font Sizes
|
||||||
|
|
||||||
|
```qml
|
||||||
|
Theme.fontSizeSmall // 12px (scaled by SettingsData.fontScale)
|
||||||
|
Theme.fontSizeMedium // 14px (scaled)
|
||||||
|
Theme.fontSizeLarge // 16px (scaled)
|
||||||
|
Theme.fontSizeXLarge // 20px (scaled)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Icon Sizes
|
||||||
|
|
||||||
|
```qml
|
||||||
|
Theme.iconSizeSmall // 16px
|
||||||
|
Theme.iconSize // 24px (default)
|
||||||
|
Theme.iconSizeLarge // 32px
|
||||||
|
```
|
||||||
|
|
||||||
|
## Spacing
|
||||||
|
|
||||||
|
```qml
|
||||||
|
Theme.spacingXS // Extra small
|
||||||
|
Theme.spacingS // Small
|
||||||
|
Theme.spacingM // Medium
|
||||||
|
Theme.spacingL // Large
|
||||||
|
Theme.spacingXL // Extra large
|
||||||
|
```
|
||||||
|
|
||||||
|
## Border Radius
|
||||||
|
|
||||||
|
```qml
|
||||||
|
Theme.cornerRadius // Standard
|
||||||
|
Theme.cornerRadiusSmall // Smaller
|
||||||
|
Theme.cornerRadiusLarge // Larger
|
||||||
|
```
|
||||||
|
|
||||||
|
## Surface Colors
|
||||||
|
|
||||||
|
```qml
|
||||||
|
Theme.surface
|
||||||
|
Theme.surfaceContainerLow
|
||||||
|
Theme.surfaceContainer
|
||||||
|
Theme.surfaceContainerHigh
|
||||||
|
Theme.surfaceContainerHighest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Text Colors
|
||||||
|
|
||||||
|
```qml
|
||||||
|
Theme.onSurface // Primary text on surface
|
||||||
|
Theme.onSurfaceVariant // Secondary text on surface
|
||||||
|
Theme.surfaceText // Alias for primary surface text
|
||||||
|
Theme.surfaceVariantText // Alias for secondary surface text
|
||||||
|
Theme.outline // Border/divider color
|
||||||
|
```
|
||||||
|
|
||||||
|
## Semantic Colors
|
||||||
|
|
||||||
|
```qml
|
||||||
|
Theme.primary
|
||||||
|
Theme.onPrimary
|
||||||
|
Theme.secondary
|
||||||
|
Theme.onSecondary
|
||||||
|
Theme.error
|
||||||
|
Theme.errorHover
|
||||||
|
Theme.errorText
|
||||||
|
Theme.warning
|
||||||
|
Theme.success
|
||||||
|
```
|
||||||
|
|
||||||
|
## Special Functions
|
||||||
|
|
||||||
|
```qml
|
||||||
|
Theme.popupBackground() // Popup background with proper opacity
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Widget Patterns
|
||||||
|
|
||||||
|
### Icon with Text
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import qs.Widgets
|
||||||
|
|
||||||
|
Row {
|
||||||
|
spacing: Theme.spacingS
|
||||||
|
|
||||||
|
DankIcon {
|
||||||
|
name: "icon_name"
|
||||||
|
color: Theme.onSurface
|
||||||
|
font.pixelSize: Theme.iconSize
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
text: "Label"
|
||||||
|
color: Theme.onSurface
|
||||||
|
font.pixelSize: Theme.fontSizeMedium
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Container with Border
|
||||||
|
|
||||||
|
```qml
|
||||||
|
Rectangle {
|
||||||
|
color: Theme.surfaceContainerHigh
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hover Effect
|
||||||
|
|
||||||
|
```qml
|
||||||
|
Rectangle {
|
||||||
|
id: container
|
||||||
|
color: Theme.surfaceContainerHigh
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
onEntered: container.color = Qt.lighter(Theme.surfaceContainerHigh, 1.1)
|
||||||
|
onExited: container.color = Theme.surfaceContainerHigh
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Clickable Pill
|
||||||
|
|
||||||
|
```qml
|
||||||
|
StyledRect {
|
||||||
|
width: content.implicitWidth + Theme.spacingM * 2
|
||||||
|
height: parent.widgetThickness
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: mouseArea.containsMouse
|
||||||
|
? Qt.lighter(Theme.surfaceContainerHigh, 1.1)
|
||||||
|
: Theme.surfaceContainerHigh
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: mouseArea
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: content
|
||||||
|
anchors.centerIn: parent
|
||||||
|
spacing: Theme.spacingS
|
||||||
|
|
||||||
|
DankIcon {
|
||||||
|
name: "star"
|
||||||
|
color: Theme.surfaceText
|
||||||
|
font.pixelSize: Theme.iconSize
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
text: "Label"
|
||||||
|
color: Theme.surfaceText
|
||||||
|
font.pixelSize: Theme.fontSizeMedium
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Mistakes
|
||||||
|
|
||||||
|
**Wrong property names** (these do NOT exist):
|
||||||
|
```qml
|
||||||
|
Theme.fontSizeS // Use Theme.fontSizeSmall
|
||||||
|
Theme.iconSizeS // Use Theme.iconSizeSmall
|
||||||
|
Theme.spacingSmall // Use Theme.spacingS
|
||||||
|
Theme.borderRadius // Use Theme.cornerRadius
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hardcoded values** (do NOT do this):
|
||||||
|
```qml
|
||||||
|
color: "#1e1e1e" // Use Theme.surfaceContainerHigh
|
||||||
|
color: "white" // Use Theme.surfaceText
|
||||||
|
font.pixelSize: 14 // Use Theme.fontSizeMedium
|
||||||
|
```
|
||||||
|
|
||||||
|
## Available Widgets from qs.Widgets
|
||||||
|
|
||||||
|
| Widget | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `StyledText` | Themed text with proper color defaults |
|
||||||
|
| `StyledRect` | Themed rectangle |
|
||||||
|
| `DankIcon` | Material Symbols icon renderer |
|
||||||
|
| `DankNFIcon` | Nerd Font icon renderer |
|
||||||
|
| `DankButton` | Themed button |
|
||||||
|
| `DankToggle` | Toggle switch |
|
||||||
|
| `DankTextField` | Text input field |
|
||||||
|
| `DankSlider` | Slider control |
|
||||||
|
| `DankDropdown` | Dropdown menu |
|
||||||
|
| `DankGridView` | Grid layout view |
|
||||||
|
| `DankListView` | List layout view |
|
||||||
|
| `DankFlickable` | Scrollable container |
|
||||||
|
| `DankTabBar` | Tab bar navigation |
|
||||||
|
| `DankCollapsibleSection` | Collapsible content section |
|
||||||
|
| `DankTooltip` | Hover tooltip |
|
||||||
|
| `DankNumberStepper` | Number +/- control |
|
||||||
|
| `DankFilterChips` | Filter chip row |
|
||||||
|
| `CachingImage` | Image with disk cache |
|
||||||
|
| `NumericText` | Fixed-width numeric display |
|
||||||
|
|
||||||
|
## Checking All Properties
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep "property" Common/Theme.qml
|
||||||
|
```
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
# Widget Plugin Guide
|
||||||
|
|
||||||
|
Widgets are bar plugins that display pills in DankBar, optionally open popouts, and can integrate with the Control Center.
|
||||||
|
|
||||||
|
## Base Component
|
||||||
|
|
||||||
|
Widgets use `PluginComponent` from `qs.Modules.Plugins`.
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import QtQuick
|
||||||
|
import qs.Common
|
||||||
|
import qs.Widgets
|
||||||
|
import qs.Modules.Plugins
|
||||||
|
|
||||||
|
PluginComponent {
|
||||||
|
property var popoutService: null
|
||||||
|
|
||||||
|
horizontalBarPill: Component { /* ... */ }
|
||||||
|
verticalBarPill: Component { /* ... */ }
|
||||||
|
popoutContent: Component { /* ... */ }
|
||||||
|
popoutWidth: 400
|
||||||
|
popoutHeight: 300
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Injected Properties
|
||||||
|
|
||||||
|
These are automatically set by the plugin host:
|
||||||
|
|
||||||
|
| Property | Type | Description |
|
||||||
|
|----------|------|-------------|
|
||||||
|
| `axis` | object | Bar axis info (horizontal/vertical) |
|
||||||
|
| `section` | string | Bar section: `"left"`, `"center"`, or `"right"` |
|
||||||
|
| `parentScreen` | object | Screen reference for multi-monitor |
|
||||||
|
| `widgetThickness` | real | Widget size perpendicular to bar edge |
|
||||||
|
| `barThickness` | real | Bar thickness parallel to edge |
|
||||||
|
| `pluginId` | string | This plugin's ID |
|
||||||
|
| `pluginService` | object | PluginService reference |
|
||||||
|
| `pluginData` | object | Reactive plugin settings data |
|
||||||
|
|
||||||
|
## Bar Pills
|
||||||
|
|
||||||
|
Define `horizontalBarPill` (for top/bottom bars) and `verticalBarPill` (for left/right bars).
|
||||||
|
|
||||||
|
### Horizontal Bar Pill
|
||||||
|
|
||||||
|
```qml
|
||||||
|
horizontalBarPill: Component {
|
||||||
|
StyledRect {
|
||||||
|
width: content.implicitWidth + Theme.spacingM * 2
|
||||||
|
height: parent.widgetThickness
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: Theme.surfaceContainerHigh
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: content
|
||||||
|
anchors.centerIn: parent
|
||||||
|
spacing: Theme.spacingS
|
||||||
|
|
||||||
|
DankIcon {
|
||||||
|
name: "star"
|
||||||
|
color: Theme.surfaceText
|
||||||
|
font.pixelSize: Theme.iconSize
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
text: "Label"
|
||||||
|
color: Theme.surfaceText
|
||||||
|
font.pixelSize: Theme.fontSizeMedium
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vertical Bar Pill
|
||||||
|
|
||||||
|
```qml
|
||||||
|
verticalBarPill: Component {
|
||||||
|
StyledRect {
|
||||||
|
width: parent.widgetThickness
|
||||||
|
height: content.implicitHeight + Theme.spacingM * 2
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: Theme.surfaceContainerHigh
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: content
|
||||||
|
anchors.centerIn: parent
|
||||||
|
spacing: Theme.spacingS
|
||||||
|
|
||||||
|
DankIcon {
|
||||||
|
name: "star"
|
||||||
|
color: Theme.surfaceText
|
||||||
|
font.pixelSize: Theme.iconSizeSmall
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important:** Always define both pills. If a pill is missing, the widget disappears when the bar is on that orientation's edge.
|
||||||
|
|
||||||
|
## Popout Content
|
||||||
|
|
||||||
|
Open a popout window when the bar pill is clicked:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
PluginComponent {
|
||||||
|
popoutWidth: 400
|
||||||
|
popoutHeight: 300
|
||||||
|
|
||||||
|
popoutContent: Component {
|
||||||
|
PopoutComponent {
|
||||||
|
headerText: "My Plugin"
|
||||||
|
detailsText: "Optional subtitle"
|
||||||
|
showCloseButton: true
|
||||||
|
|
||||||
|
Column {
|
||||||
|
width: parent.width
|
||||||
|
spacing: Theme.spacingM
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
text: "Content here"
|
||||||
|
color: Theme.surfaceText
|
||||||
|
font.pixelSize: Theme.fontSizeMedium
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**PopoutComponent properties:**
|
||||||
|
|
||||||
|
| Property | Type | Default | Description |
|
||||||
|
|----------|------|---------|-------------|
|
||||||
|
| `headerText` | string | `""` | Main header (bold, large). Hidden if empty. |
|
||||||
|
| `detailsText` | string | `""` | Subtitle below header. Hidden if empty. |
|
||||||
|
| `showCloseButton` | bool | `false` | Show X button in top-right corner. |
|
||||||
|
| `closePopout` | function | (injected) | Call to close the popout programmatically. |
|
||||||
|
| `headerHeight` | int | (readonly) | Height of header area (0 if hidden). |
|
||||||
|
| `detailsHeight` | int | (readonly) | Height of details area (0 if hidden). |
|
||||||
|
|
||||||
|
**Content sizing:** Content children render below the header/details. Calculate available height: `popoutHeight - headerHeight - detailsHeight - spacing`
|
||||||
|
|
||||||
|
## Custom Click Actions
|
||||||
|
|
||||||
|
Override the default popout behavior:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
PluginComponent {
|
||||||
|
// Simple no-args handler
|
||||||
|
pillClickAction: () => {
|
||||||
|
popoutService?.toggleControlCenter()
|
||||||
|
}
|
||||||
|
|
||||||
|
// With position params (x, y, width, section, screen)
|
||||||
|
pillClickAction: (x, y, width, section, screen) => {
|
||||||
|
popoutService?.toggleControlCenter(x, y, width, section, screen)
|
||||||
|
}
|
||||||
|
|
||||||
|
pillRightClickAction: () => {
|
||||||
|
popoutService?.openSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Control Center Integration
|
||||||
|
|
||||||
|
Add CC properties to show your widget in the Control Center grid:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
PluginComponent {
|
||||||
|
ccWidgetIcon: "toggle_on"
|
||||||
|
ccWidgetPrimaryText: "Feature Name"
|
||||||
|
ccWidgetSecondaryText: isActive ? "Active" : "Off"
|
||||||
|
ccWidgetIsActive: isActive
|
||||||
|
|
||||||
|
onCcWidgetToggled: {
|
||||||
|
isActive = !isActive
|
||||||
|
pluginService?.savePluginData(pluginId, "active", isActive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**CC properties:**
|
||||||
|
|
||||||
|
| Property | Type | Description |
|
||||||
|
|----------|------|-------------|
|
||||||
|
| `ccWidgetIcon` | string | Material icon name |
|
||||||
|
| `ccWidgetPrimaryText` | string | Main label |
|
||||||
|
| `ccWidgetSecondaryText` | string | Subtitle / status text |
|
||||||
|
| `ccWidgetIsActive` | bool | Active state (changes styling) |
|
||||||
|
|
||||||
|
**CC signals:**
|
||||||
|
|
||||||
|
| Signal | When fired |
|
||||||
|
|--------|-----------|
|
||||||
|
| `ccWidgetToggled()` | Icon area clicked |
|
||||||
|
| `ccWidgetExpanded()` | Expand area clicked (CompoundPill only) |
|
||||||
|
|
||||||
|
**CC sizing rules:**
|
||||||
|
- 25% width - SmallToggleButton (icon only)
|
||||||
|
- 50% width - ToggleButton (no detail) or CompoundPill (with ccDetailContent)
|
||||||
|
- Users can resize in CC edit mode
|
||||||
|
|
||||||
|
### Detail Content (CompoundPill)
|
||||||
|
|
||||||
|
Add an expandable panel below the CC widget:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
ccDetailContent: Component {
|
||||||
|
Rectangle {
|
||||||
|
implicitHeight: 200
|
||||||
|
color: Theme.surfaceContainerHigh
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
|
||||||
|
Column {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Theme.spacingM
|
||||||
|
spacing: Theme.spacingS
|
||||||
|
|
||||||
|
// Detail UI here
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Visibility Control
|
||||||
|
|
||||||
|
Conditionally show/hide the bar pill:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
PluginComponent {
|
||||||
|
visibilityCommand: "pgrep -x myapp"
|
||||||
|
visibilityInterval: 5000 // check every 5 seconds
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Popout Namespace
|
||||||
|
|
||||||
|
For plugins with multiple popout instances, use `layerNamespacePlugin` to isolate popout state:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
PluginComponent {
|
||||||
|
layerNamespacePlugin: true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reading Plugin Data
|
||||||
|
|
||||||
|
Access saved settings reactively via the injected `pluginData`:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
PluginComponent {
|
||||||
|
property string displayText: pluginData?.text || "Default"
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: pluginService
|
||||||
|
function onPluginDataChanged(changedId) {
|
||||||
|
if (changedId === pluginId)
|
||||||
|
displayText = pluginService.loadPluginData(pluginId, "text", "Default")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Example
|
||||||
|
|
||||||
|
Based on the ExampleEmojiPlugin pattern:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import qs.Common
|
||||||
|
import qs.Widgets
|
||||||
|
import qs.Services
|
||||||
|
import qs.Modules.Plugins
|
||||||
|
|
||||||
|
PluginComponent {
|
||||||
|
id: root
|
||||||
|
property var popoutService: null
|
||||||
|
|
||||||
|
property var emojis: ["star", "heart", "smile"]
|
||||||
|
property int currentIndex: 0
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
interval: 2000
|
||||||
|
running: true
|
||||||
|
repeat: true
|
||||||
|
onTriggered: currentIndex = (currentIndex + 1) % emojis.length
|
||||||
|
}
|
||||||
|
|
||||||
|
popoutWidth: 350
|
||||||
|
popoutHeight: 400
|
||||||
|
|
||||||
|
horizontalBarPill: Component {
|
||||||
|
StyledRect {
|
||||||
|
width: label.implicitWidth + Theme.spacingM * 2
|
||||||
|
height: parent.widgetThickness
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: Theme.surfaceContainerHigh
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
id: label
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: root.emojis[root.currentIndex]
|
||||||
|
font.pixelSize: Theme.fontSizeLarge
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
verticalBarPill: Component {
|
||||||
|
StyledRect {
|
||||||
|
width: parent.widgetThickness
|
||||||
|
height: label.implicitHeight + Theme.spacingM * 2
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: Theme.surfaceContainerHigh
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
id: label
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: root.emojis[root.currentIndex]
|
||||||
|
font.pixelSize: Theme.fontSizeMedium
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
popoutContent: Component {
|
||||||
|
PopoutComponent {
|
||||||
|
headerText: "Emoji Picker"
|
||||||
|
showCloseButton: true
|
||||||
|
|
||||||
|
DankGridView {
|
||||||
|
width: parent.width
|
||||||
|
height: 300
|
||||||
|
cellWidth: 50
|
||||||
|
cellHeight: 50
|
||||||
|
model: root.emojis
|
||||||
|
|
||||||
|
delegate: Rectangle {
|
||||||
|
width: 48
|
||||||
|
height: 48
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: mouseArea.containsMouse ? Theme.surfaceContainerHighest : "transparent"
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: modelData
|
||||||
|
font.pixelSize: 24
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: mouseArea
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
onClicked: {
|
||||||
|
Quickshell.execDetached(["dms", "cl", "copy", modelData])
|
||||||
|
ToastService?.showInfo("Copied " + modelData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Check nix flake
|
name: Nix flake and NixOS tests
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
@@ -9,6 +9,7 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
check-flake:
|
check-flake:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 120
|
||||||
steps:
|
steps:
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -18,6 +19,25 @@ jobs:
|
|||||||
|
|
||||||
- name: Install Nix
|
- name: Install Nix
|
||||||
uses: cachix/install-nix-action@v31
|
uses: cachix/install-nix-action@v31
|
||||||
|
with:
|
||||||
|
enable_kvm: true
|
||||||
|
extra_nix_config: |
|
||||||
|
system-features = nixos-test benchmark big-parallel kvm
|
||||||
|
|
||||||
- name: Check the flake
|
- name: Check the flake
|
||||||
run: nix flake check
|
run: nix flake check -L
|
||||||
|
|
||||||
|
- name: Run NixOS module test
|
||||||
|
run: nix build .#nixosTests.x86_64-linux.nixos-module -L
|
||||||
|
|
||||||
|
- name: Run NixOS service start test
|
||||||
|
run: nix build .#nixosTests.x86_64-linux.nixos-service-start-module -L
|
||||||
|
|
||||||
|
- name: Run greeter niri test
|
||||||
|
run: nix build .#nixosTests.x86_64-linux.greeter-niri-module -L
|
||||||
|
|
||||||
|
- name: Run home-manager module test
|
||||||
|
run: nix build .#nixosTests.x86_64-linux.home-manager-module -L
|
||||||
|
|
||||||
|
- name: Run niri home-manager module test
|
||||||
|
run: nix build .#nixosTests.x86_64-linux.niri-home-module -L
|
||||||
|
|||||||
@@ -367,6 +367,16 @@ jobs:
|
|||||||
EOF
|
EOF
|
||||||
chmod 600 ~/.config/osc/oscrc
|
chmod 600 ~/.config/osc/oscrc
|
||||||
|
|
||||||
|
# Cache OBS bundled Go toolchains
|
||||||
|
- name: Cache OBS bundled Go toolchains (dms-git)
|
||||||
|
if: contains(steps.packages.outputs.packages, 'dms-git')
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: /home/runner/.cache/dms-obs-go-toolchain
|
||||||
|
key: dms-obs-go-toolchain-${{ runner.os }}-${{ hashFiles('core/go.mod') }}
|
||||||
|
restore-keys: |
|
||||||
|
dms-obs-go-toolchain-${{ runner.os }}-
|
||||||
|
|
||||||
- name: Upload to OBS
|
- name: Upload to OBS
|
||||||
id: upload
|
id: upload
|
||||||
env:
|
env:
|
||||||
|
|||||||
+65
-205
@@ -22,12 +22,13 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check-updates:
|
check-updates:
|
||||||
name: Check for updates
|
name: Check package/series updates
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
outputs:
|
outputs:
|
||||||
has_updates: ${{ steps.check.outputs.has_updates }}
|
has_updates: ${{ steps.check.outputs.has_updates }}
|
||||||
packages: ${{ steps.check.outputs.packages }}
|
targets: ${{ steps.check.outputs.targets }}
|
||||||
|
targets_json: ${{ steps.check.outputs.targets_json }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -35,125 +36,57 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y jq curl git
|
||||||
|
|
||||||
- name: Check for updates
|
- name: Check for updates
|
||||||
id: check
|
id: check
|
||||||
run: |
|
run: |
|
||||||
# Helper function to check dms-git commit
|
chmod +x distro/scripts/ppa-sync-plan.sh
|
||||||
check_dms_git() {
|
|
||||||
local CURRENT_COMMIT=$(git rev-parse --short=8 HEAD)
|
|
||||||
local PPA_VERSION=$(curl -s "https://api.launchpad.net/1.0/~avengemedia/+archive/ubuntu/dms-git?ws.op=getPublishedSources&source_name=dms-git&status=Published" | grep -oP '"source_package_version":\s*"\K[^"]+' | head -1 || echo "")
|
|
||||||
local PPA_COMMIT=$(echo "$PPA_VERSION" | grep -oP '\.[a-f0-9]{8}' | tr -d '.' || echo "")
|
|
||||||
|
|
||||||
if [[ -n "$PPA_COMMIT" && "$CURRENT_COMMIT" == "$PPA_COMMIT" ]]; then
|
|
||||||
echo "📋 dms-git: Commit $CURRENT_COMMIT already exists, skipping"
|
|
||||||
return 1 # No update needed
|
|
||||||
else
|
|
||||||
echo "📋 dms-git: New commit $CURRENT_COMMIT (PPA has ${PPA_COMMIT:-none})"
|
|
||||||
return 0 # Update needed
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Helper function to check stable package tag
|
|
||||||
check_stable_package() {
|
|
||||||
local PKG="$1"
|
|
||||||
local PPA_NAME="$2"
|
|
||||||
# Use git ls-remote to find the latest tag, sorted by version (descending)
|
|
||||||
local LATEST_TAG=$(git ls-remote --tags --refs --sort='-v:refname' https://github.com/AvengeMedia/DankMaterialShell.git | head -n1 | awk -F/ '{print $NF}' | sed 's/^v//')
|
|
||||||
local PPA_VERSION=$(curl -s "https://api.launchpad.net/1.0/~avengemedia/+archive/ubuntu/$PPA_NAME?ws.op=getPublishedSources&source_name=$PKG&status=Published" | grep -oP '"source_package_version":\s*"\K[^"]+' | head -1 || echo "")
|
|
||||||
local PPA_BASE_VERSION=$(echo "$PPA_VERSION" | sed 's/ppa[0-9]*$//')
|
|
||||||
|
|
||||||
if [[ -n "$LATEST_TAG" && "$LATEST_TAG" == "$PPA_BASE_VERSION" ]]; then
|
|
||||||
echo "📋 $PKG: Tag $LATEST_TAG already exists, skipping"
|
|
||||||
return 1 # No update needed
|
|
||||||
else
|
|
||||||
echo "📋 $PKG: New tag ${LATEST_TAG:-unknown} (PPA has ${PPA_BASE_VERSION:-none})"
|
|
||||||
return 0 # Update needed
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Main logic
|
|
||||||
REBUILD="${{ github.event.inputs.rebuild_release }}"
|
|
||||||
|
|
||||||
if [[ "${{ github.event_name }}" == "schedule" ]]; then
|
if [[ "${{ github.event_name }}" == "schedule" ]]; then
|
||||||
# Scheduled run - check dms-git only
|
PACKAGE="dms-git"
|
||||||
echo "packages=dms-git" >> $GITHUB_OUTPUT
|
|
||||||
if check_dms_git; then
|
|
||||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
|
||||||
else
|
|
||||||
echo "has_updates=false" >> $GITHUB_OUTPUT
|
|
||||||
fi
|
|
||||||
|
|
||||||
elif [[ -n "${{ github.event.inputs.package }}" ]]; then
|
|
||||||
# Manual workflow trigger
|
|
||||||
PKG="${{ github.event.inputs.package }}"
|
|
||||||
|
|
||||||
if [[ -n "$REBUILD" ]]; then
|
|
||||||
# Rebuild requested - always proceed
|
|
||||||
echo "packages=$PKG" >> $GITHUB_OUTPUT
|
|
||||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
|
||||||
echo "🔄 Manual rebuild requested: $PKG (ppa$REBUILD)"
|
|
||||||
|
|
||||||
elif [[ "$PKG" == "all" ]]; then
|
|
||||||
# Check each package and build list of those needing updates
|
|
||||||
PACKAGES_TO_UPDATE=()
|
|
||||||
check_dms_git && PACKAGES_TO_UPDATE+=("dms-git")
|
|
||||||
check_stable_package "dms" "dms" && PACKAGES_TO_UPDATE+=("dms")
|
|
||||||
check_stable_package "dms-greeter" "danklinux" && PACKAGES_TO_UPDATE+=("dms-greeter")
|
|
||||||
|
|
||||||
if [[ ${#PACKAGES_TO_UPDATE[@]} -gt 0 ]]; then
|
|
||||||
echo "packages=${PACKAGES_TO_UPDATE[*]}" >> $GITHUB_OUTPUT
|
|
||||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
|
||||||
echo "✓ Packages to update: ${PACKAGES_TO_UPDATE[*]}"
|
|
||||||
else
|
|
||||||
echo "packages=" >> $GITHUB_OUTPUT
|
|
||||||
echo "has_updates=false" >> $GITHUB_OUTPUT
|
|
||||||
echo "✓ All packages up to date"
|
|
||||||
fi
|
|
||||||
|
|
||||||
elif [[ "$PKG" == "dms-git" ]]; then
|
|
||||||
if check_dms_git; then
|
|
||||||
echo "packages=$PKG" >> $GITHUB_OUTPUT
|
|
||||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
|
||||||
else
|
|
||||||
echo "packages=" >> $GITHUB_OUTPUT
|
|
||||||
echo "has_updates=false" >> $GITHUB_OUTPUT
|
|
||||||
fi
|
|
||||||
|
|
||||||
elif [[ "$PKG" == "dms" ]]; then
|
|
||||||
if check_stable_package "dms" "dms"; then
|
|
||||||
echo "packages=$PKG" >> $GITHUB_OUTPUT
|
|
||||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
|
||||||
else
|
|
||||||
echo "packages=" >> $GITHUB_OUTPUT
|
|
||||||
echo "has_updates=false" >> $GITHUB_OUTPUT
|
|
||||||
fi
|
|
||||||
|
|
||||||
elif [[ "$PKG" == "dms-greeter" ]]; then
|
|
||||||
if check_stable_package "dms-greeter" "danklinux"; then
|
|
||||||
echo "packages=$PKG" >> $GITHUB_OUTPUT
|
|
||||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
|
||||||
else
|
|
||||||
echo "packages=" >> $GITHUB_OUTPUT
|
|
||||||
echo "has_updates=false" >> $GITHUB_OUTPUT
|
|
||||||
fi
|
|
||||||
|
|
||||||
else
|
|
||||||
# Unknown package - proceed anyway
|
|
||||||
echo "packages=$PKG" >> $GITHUB_OUTPUT
|
|
||||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
|
||||||
echo "Manual trigger: $PKG"
|
|
||||||
fi
|
|
||||||
else
|
else
|
||||||
# Fallback
|
PACKAGE="${{ github.event.inputs.package }}"
|
||||||
echo "packages=dms" >> $GITHUB_OUTPUT
|
fi
|
||||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
|
||||||
|
REBUILD_RELEASE="${{ github.event.inputs.rebuild_release }}"
|
||||||
|
ARGS=(--package "$PACKAGE" --json)
|
||||||
|
if [[ -n "$REBUILD_RELEASE" ]]; then
|
||||||
|
ARGS+=(--rebuild "$REBUILD_RELEASE")
|
||||||
|
fi
|
||||||
|
|
||||||
|
TARGETS_JSON=$(distro/scripts/ppa-sync-plan.sh "${ARGS[@]}" 2> ppa-audit.log)
|
||||||
|
cat ppa-audit.log
|
||||||
|
|
||||||
|
TARGETS=$(echo "$TARGETS_JSON" | jq -r 'join(" ")')
|
||||||
|
if [[ "$TARGETS_JSON" != "[]" ]]; then
|
||||||
|
echo "has_updates=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "targets=$TARGETS" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "targets_json=$TARGETS_JSON" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Package/series targets: $TARGETS"
|
||||||
|
else
|
||||||
|
echo "has_updates=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "targets=" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "targets_json=[]" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "No package/series uploads needed"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
upload-ppa:
|
upload-ppa:
|
||||||
name: Upload to PPA
|
name: Upload ${{ matrix.target }}
|
||||||
needs: check-updates
|
needs: check-updates
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: needs.check-updates.outputs.has_updates == 'true'
|
if: needs.check-updates.outputs.has_updates == 'true'
|
||||||
|
timeout-minutes: 120
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
target: ${{ fromJson(needs.check-updates.outputs.targets_json) }}
|
||||||
|
concurrency:
|
||||||
|
group: ppa-dms-${{ matrix.target }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -177,7 +110,8 @@ jobs:
|
|||||||
lftp \
|
lftp \
|
||||||
build-essential \
|
build-essential \
|
||||||
fakeroot \
|
fakeroot \
|
||||||
dpkg-dev
|
dpkg-dev \
|
||||||
|
openssh-client
|
||||||
|
|
||||||
- name: Configure GPG
|
- name: Configure GPG
|
||||||
env:
|
env:
|
||||||
@@ -185,106 +119,32 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "$GPG_KEY" | gpg --import
|
echo "$GPG_KEY" | gpg --import
|
||||||
GPG_KEY_ID=$(gpg --list-secret-keys --keyid-format LONG | grep sec | awk '{print $2}' | cut -d'/' -f2)
|
GPG_KEY_ID=$(gpg --list-secret-keys --keyid-format LONG | grep sec | awk '{print $2}' | cut -d'/' -f2)
|
||||||
echo "DEBSIGN_KEYID=$GPG_KEY_ID" >> $GITHUB_ENV
|
echo "DEBSIGN_KEYID=$GPG_KEY_ID" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Determine packages to upload
|
- name: Upload target
|
||||||
id: packages
|
env:
|
||||||
|
TARGET: ${{ matrix.target }}
|
||||||
|
LAUNCHPAD_SSH_PRIVATE_KEY: ${{ secrets.LAUNCHPAD_SSH_PRIVATE_KEY }}
|
||||||
|
LAUNCHPAD_SSH_LOGIN: ${{ secrets.LAUNCHPAD_SSH_LOGIN }}
|
||||||
run: |
|
run: |
|
||||||
# Use packages determined by check-updates job
|
IFS=':' read -r PACKAGE UBUNTU_SERIES PPA_NUM <<< "$TARGET"
|
||||||
echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
if [[ "${{ github.event_name }}" == "schedule" ]]; then
|
case "$PACKAGE" in
|
||||||
echo "Triggered by schedule: uploading git package"
|
dms) PPA_NAME="dms" ;;
|
||||||
elif [[ -n "${{ github.event.inputs.package }}" ]]; then
|
dms-git) PPA_NAME="dms-git" ;;
|
||||||
echo "Manual trigger: ${{ needs.check-updates.outputs.packages }}"
|
dms-greeter) PPA_NAME="danklinux" ;;
|
||||||
fi
|
*) echo "::error::Unknown package $PACKAGE"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
- name: Upload to PPA
|
echo "Uploading $PACKAGE to $PPA_NAME/$UBUNTU_SERIES with ppa$PPA_NUM"
|
||||||
run: |
|
bash distro/scripts/ppa-upload.sh "$PACKAGE" "$PPA_NAME" "$UBUNTU_SERIES" "$PPA_NUM"
|
||||||
PACKAGES="${{ steps.packages.outputs.packages }}"
|
|
||||||
REBUILD_RELEASE="${{ github.event.inputs.rebuild_release }}"
|
|
||||||
|
|
||||||
if [[ -z "$PACKAGES" ]]; then
|
|
||||||
echo "✓ No packages need uploading. All up to date!"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Export REBUILD_RELEASE so ppa-build.sh can use it
|
|
||||||
if [[ -n "$REBUILD_RELEASE" ]]; then
|
|
||||||
export REBUILD_RELEASE
|
|
||||||
echo "✓ Using rebuild release number: ppa$REBUILD_RELEASE"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# PACKAGES can be space-separated list (e.g., "dms-git dms dms-greeter" from "all" check)
|
|
||||||
# Loop through each package and upload
|
|
||||||
for PKG in $PACKAGES; do
|
|
||||||
# Map package to PPA name
|
|
||||||
case "$PKG" in
|
|
||||||
dms)
|
|
||||||
PPA_NAME="dms"
|
|
||||||
;;
|
|
||||||
dms-git)
|
|
||||||
PPA_NAME="dms-git"
|
|
||||||
;;
|
|
||||||
dms-greeter)
|
|
||||||
PPA_NAME="danklinux"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "⚠️ Unknown package: $PKG, skipping"
|
|
||||||
continue
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
||||||
echo "Uploading $PKG to PPA $PPA_NAME..."
|
|
||||||
if [[ -n "$REBUILD_RELEASE" ]]; then
|
|
||||||
echo "🔄 Using rebuild release number: ppa$REBUILD_RELEASE"
|
|
||||||
fi
|
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
||||||
# ppa-upload.sh uploads to questing + resolute when series is omitted
|
|
||||||
if ! bash distro/scripts/ppa-upload.sh "$PKG" "$PPA_NAME" ${REBUILD_RELEASE:+"$REBUILD_RELEASE"}; then
|
|
||||||
echo "::error::Upload failed for $PKG"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Summary
|
- name: Summary
|
||||||
if: always()
|
if: always()
|
||||||
run: |
|
run: |
|
||||||
echo "### PPA Package Upload Summary" >> $GITHUB_STEP_SUMMARY
|
echo "### PPA Package Upload" >> "$GITHUB_STEP_SUMMARY"
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
echo "- **Target:** ${{ matrix.target }}" >> "$GITHUB_STEP_SUMMARY"
|
||||||
PACKAGES="${{ steps.packages.outputs.packages }}"
|
echo "- **DMS PPA:** https://launchpad.net/~avengemedia/+archive/ubuntu/dms/+packages" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
echo "- **DMS-Git PPA:** https://launchpad.net/~avengemedia/+archive/ubuntu/dms-git/+packages" >> "$GITHUB_STEP_SUMMARY"
|
||||||
if [[ -z "$PACKAGES" ]]; then
|
echo "- **DankLinux PPA:** https://launchpad.net/~avengemedia/+archive/ubuntu/danklinux/+packages" >> "$GITHUB_STEP_SUMMARY"
|
||||||
echo "**Status:** ✅ All packages up to date (no uploads needed)" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "All packages are current. Run will complete successfully." >> $GITHUB_STEP_SUMMARY
|
|
||||||
else
|
|
||||||
echo "**Packages Uploaded:**" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
|
||||||
|
|
||||||
for PKG in $PACKAGES; do
|
|
||||||
case "$PKG" in
|
|
||||||
dms)
|
|
||||||
echo "- ✅ **dms** → [View builds](https://launchpad.net/~avengemedia/+archive/ubuntu/dms/+packages)" >> $GITHUB_STEP_SUMMARY
|
|
||||||
;;
|
|
||||||
dms-git)
|
|
||||||
echo "- ✅ **dms-git** → [View builds](https://launchpad.net/~avengemedia/+archive/ubuntu/dms-git/+packages)" >> $GITHUB_STEP_SUMMARY
|
|
||||||
;;
|
|
||||||
dms-greeter)
|
|
||||||
echo "- ✅ **dms-greeter** → [View builds](https://launchpad.net/~avengemedia/+archive/ubuntu/danklinux/+packages)" >> $GITHUB_STEP_SUMMARY
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
|
||||||
|
|
||||||
if [[ -n "${{ github.event.inputs.rebuild_release }}" ]]; then
|
|
||||||
echo "**Rebuild Number:** ppa${{ github.event.inputs.rebuild_release }}" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Builds will appear once Launchpad processes the uploads." >> $GITHUB_STEP_SUMMARY
|
|
||||||
fi
|
|
||||||
|
|||||||
@@ -107,6 +107,9 @@ vim/
|
|||||||
|
|
||||||
bin/
|
bin/
|
||||||
|
|
||||||
|
# Core dumps
|
||||||
|
core.*
|
||||||
|
|
||||||
# direnv
|
# direnv
|
||||||
.envrc
|
.envrc
|
||||||
.direnv/
|
.direnv/
|
||||||
|
|||||||
@@ -20,3 +20,11 @@ repos:
|
|||||||
language: system
|
language: system
|
||||||
files: ^core/.*\.(go|mod|sum)$
|
files: ^core/.*\.(go|mod|sum)$
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
|
- repo: local
|
||||||
|
hooks:
|
||||||
|
- id: no-console-in-qml
|
||||||
|
name: no console.* in QML (use Log service)
|
||||||
|
entry: bash -c 'if grep -nE "console\.(log|error|info|warn|debug)" "$@"; then echo "Use the Log service (log.info/warn/error/debug/fatal) instead of console.*" >&2; exit 1; fi' --
|
||||||
|
language: system
|
||||||
|
files: ^quickshell/.*\.qml$
|
||||||
|
exclude: ^quickshell/(Services/Log\.qml$|dms-plugins/|PLUGINS/)
|
||||||
|
|||||||
@@ -1,26 +1,13 @@
|
|||||||
repos:
|
repos:
|
||||||
- repo: local
|
- repo: https://github.com/golangci/golangci-lint
|
||||||
|
rev: v2.10.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: golangci-lint-fmt
|
- id: golangci-lint-fmt
|
||||||
name: golangci-lint-fmt
|
|
||||||
entry: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.3 fmt
|
|
||||||
language: system
|
|
||||||
require_serial: true
|
require_serial: true
|
||||||
types: [go]
|
|
||||||
pass_filenames: false
|
|
||||||
- id: golangci-lint-full
|
- id: golangci-lint-full
|
||||||
name: golangci-lint-full
|
|
||||||
entry: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.3 run --fix
|
|
||||||
language: system
|
|
||||||
require_serial: true
|
|
||||||
types: [go]
|
|
||||||
pass_filenames: false
|
|
||||||
- id: golangci-lint-config-verify
|
- id: golangci-lint-config-verify
|
||||||
name: golangci-lint-config-verify
|
- repo: local
|
||||||
entry: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.3 config verify
|
hooks:
|
||||||
language: system
|
|
||||||
files: \.golangci\.(?:yml|yaml|toml|json)
|
|
||||||
pass_filenames: false
|
|
||||||
- id: go-test
|
- id: go-test
|
||||||
name: go test
|
name: go test
|
||||||
entry: go test ./...
|
entry: go test ./...
|
||||||
|
|||||||
@@ -26,6 +26,17 @@ var runCmd = &cobra.Command{
|
|||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
daemon, _ := cmd.Flags().GetBool("daemon")
|
daemon, _ := cmd.Flags().GetBool("daemon")
|
||||||
session, _ := cmd.Flags().GetBool("session")
|
session, _ := cmd.Flags().GetBool("session")
|
||||||
|
if v, _ := cmd.Flags().GetString("log-level"); v != "" {
|
||||||
|
if err := os.Setenv("DMS_LOG_LEVEL", v); err != nil {
|
||||||
|
log.Fatalf("Failed to set DMS_LOG_LEVEL: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v, _ := cmd.Flags().GetString("log-file"); v != "" {
|
||||||
|
if err := os.Setenv("DMS_LOG_FILE", v); err != nil {
|
||||||
|
log.Fatalf("Failed to set DMS_LOG_FILE: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.ApplyEnvOverrides()
|
||||||
if daemon {
|
if daemon {
|
||||||
runShellDaemon(session)
|
runShellDaemon(session)
|
||||||
} else {
|
} else {
|
||||||
@@ -526,5 +537,7 @@ func getCommonCommands() []*cobra.Command {
|
|||||||
dlCmd,
|
dlCmd,
|
||||||
randrCmd,
|
randrCmd,
|
||||||
blurCmd,
|
blurCmd,
|
||||||
|
trashCmd,
|
||||||
|
systemCmd,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,9 +28,9 @@ with flags to handle different MIME types or application categories.
|
|||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
dms open https://example.com # Open URL with browser picker
|
dms open https://example.com # Open URL with browser picker
|
||||||
dms open file.pdf --mime application/pdf # Open PDF with compatible apps
|
dms open file.pdf # Open file (MIME auto-detected)
|
||||||
dms open document.odt --category Office # Open with office applications
|
dms open file.pdf --mime application/pdf # Override MIME detection
|
||||||
dms open --mime image/png image.png # Open image with image viewers`,
|
dms open document.odt --category Office # Open with office applications`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
runOpen(args[0])
|
runOpen(args[0])
|
||||||
@@ -47,123 +47,58 @@ func init() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// mimeTypeToCategories maps MIME types to desktop file categories
|
func detectMimeFromPath(path string) string {
|
||||||
func mimeTypeToCategories(mimeType string) []string {
|
ext := filepath.Ext(path)
|
||||||
// Split MIME type to get the main type
|
if ext == "" {
|
||||||
parts := strings.Split(mimeType, "/")
|
return ""
|
||||||
if len(parts) < 1 {
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
return mime.TypeByExtension(ext)
|
||||||
mainType := parts[0]
|
|
||||||
|
|
||||||
switch mainType {
|
|
||||||
case "image":
|
|
||||||
return []string{"Graphics", "Viewer"}
|
|
||||||
case "video":
|
|
||||||
return []string{"Video", "AudioVideo"}
|
|
||||||
case "audio":
|
|
||||||
return []string{"Audio", "AudioVideo"}
|
|
||||||
case "text":
|
|
||||||
if strings.Contains(mimeType, "html") {
|
|
||||||
return []string{"WebBrowser"}
|
|
||||||
}
|
|
||||||
return []string{"TextEditor", "Office"}
|
|
||||||
case "application":
|
|
||||||
if strings.Contains(mimeType, "pdf") {
|
|
||||||
return []string{"Office", "Viewer"}
|
|
||||||
}
|
|
||||||
if strings.Contains(mimeType, "document") || strings.Contains(mimeType, "spreadsheet") ||
|
|
||||||
strings.Contains(mimeType, "presentation") || strings.Contains(mimeType, "msword") ||
|
|
||||||
strings.Contains(mimeType, "ms-excel") || strings.Contains(mimeType, "ms-powerpoint") ||
|
|
||||||
strings.Contains(mimeType, "opendocument") {
|
|
||||||
return []string{"Office"}
|
|
||||||
}
|
|
||||||
if strings.Contains(mimeType, "zip") || strings.Contains(mimeType, "tar") ||
|
|
||||||
strings.Contains(mimeType, "gzip") || strings.Contains(mimeType, "compress") {
|
|
||||||
return []string{"Archiving", "Utility"}
|
|
||||||
}
|
|
||||||
return []string{"Office", "Viewer"}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func runOpen(target string) {
|
func runOpen(target string) {
|
||||||
// Parse file:// URIs to extract the actual file path
|
|
||||||
actualTarget := target
|
actualTarget := target
|
||||||
detectedMimeType := openMimeType
|
detectedMimeType := openMimeType
|
||||||
detectedCategories := openCategories
|
|
||||||
detectedRequestType := openRequestType
|
detectedRequestType := openRequestType
|
||||||
|
|
||||||
log.Infof("Processing target: %s", target)
|
log.Infof("Processing target: %s", target)
|
||||||
|
|
||||||
if parsedURL, err := url.Parse(target); err == nil && parsedURL.Scheme == "file" {
|
switch {
|
||||||
// Extract file path from file:// URI and convert to absolute path
|
case isScheme(target, "file://"):
|
||||||
actualTarget = parsedURL.Path
|
parsedURL, err := url.Parse(target)
|
||||||
if absPath, err := filepath.Abs(actualTarget); err == nil {
|
if err == nil {
|
||||||
actualTarget = absPath
|
actualTarget = parsedURL.Path
|
||||||
|
}
|
||||||
|
if abs, err := filepath.Abs(actualTarget); err == nil {
|
||||||
|
actualTarget = abs
|
||||||
}
|
}
|
||||||
|
|
||||||
if detectedRequestType == "url" || detectedRequestType == "" {
|
if detectedRequestType == "url" || detectedRequestType == "" {
|
||||||
detectedRequestType = "file"
|
detectedRequestType = "file"
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Infof("Detected file:// URI, extracted absolute path: %s", actualTarget)
|
|
||||||
|
|
||||||
// Auto-detect MIME type if not provided
|
|
||||||
if detectedMimeType == "" {
|
if detectedMimeType == "" {
|
||||||
ext := filepath.Ext(actualTarget)
|
detectedMimeType = detectMimeFromPath(actualTarget)
|
||||||
if ext != "" {
|
|
||||||
detectedMimeType = mime.TypeByExtension(ext)
|
|
||||||
log.Infof("Detected MIME type from extension %s: %s", ext, detectedMimeType)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
log.Infof("Detected file:// URI, absolute path: %s", actualTarget)
|
||||||
|
|
||||||
// Auto-detect categories based on MIME type if not provided
|
case isScheme(target, "http://"), isScheme(target, "https://"), isScheme(target, "dms://"):
|
||||||
if len(detectedCategories) == 0 && detectedMimeType != "" {
|
|
||||||
detectedCategories = mimeTypeToCategories(detectedMimeType)
|
|
||||||
log.Infof("Detected categories from MIME type: %v", detectedCategories)
|
|
||||||
}
|
|
||||||
} else if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") {
|
|
||||||
// Handle HTTP(S) URLs
|
|
||||||
if detectedRequestType == "" {
|
if detectedRequestType == "" {
|
||||||
detectedRequestType = "url"
|
detectedRequestType = "url"
|
||||||
}
|
}
|
||||||
log.Infof("Detected HTTP(S) URL")
|
log.Infof("Detected URL: %s", target)
|
||||||
} else if strings.HasPrefix(target, "dms://") {
|
|
||||||
// Handle DMS internal URLs (theme/plugin install, etc.)
|
|
||||||
if detectedRequestType == "" {
|
|
||||||
detectedRequestType = "url"
|
|
||||||
}
|
|
||||||
log.Infof("Detected DMS internal URL")
|
|
||||||
} else if _, err := os.Stat(target); err == nil {
|
|
||||||
// Handle local file paths directly (not file:// URIs)
|
|
||||||
// Convert to absolute path
|
|
||||||
if absPath, err := filepath.Abs(target); err == nil {
|
|
||||||
actualTarget = absPath
|
|
||||||
}
|
|
||||||
|
|
||||||
|
default:
|
||||||
|
if _, err := os.Stat(target); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if abs, err := filepath.Abs(target); err == nil {
|
||||||
|
actualTarget = abs
|
||||||
|
}
|
||||||
if detectedRequestType == "url" || detectedRequestType == "" {
|
if detectedRequestType == "url" || detectedRequestType == "" {
|
||||||
detectedRequestType = "file"
|
detectedRequestType = "file"
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Infof("Detected local file path, converted to absolute: %s", actualTarget)
|
|
||||||
|
|
||||||
// Auto-detect MIME type if not provided
|
|
||||||
if detectedMimeType == "" {
|
if detectedMimeType == "" {
|
||||||
ext := filepath.Ext(actualTarget)
|
detectedMimeType = detectMimeFromPath(actualTarget)
|
||||||
if ext != "" {
|
|
||||||
detectedMimeType = mime.TypeByExtension(ext)
|
|
||||||
log.Infof("Detected MIME type from extension %s: %s", ext, detectedMimeType)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-detect categories based on MIME type if not provided
|
|
||||||
if len(detectedCategories) == 0 && detectedMimeType != "" {
|
|
||||||
detectedCategories = mimeTypeToCategories(detectedMimeType)
|
|
||||||
log.Infof("Detected categories from MIME type: %v", detectedCategories)
|
|
||||||
}
|
}
|
||||||
|
log.Infof("Detected local file path: %s", actualTarget)
|
||||||
}
|
}
|
||||||
|
|
||||||
params := map[string]any{
|
params := map[string]any{
|
||||||
@@ -174,8 +109,8 @@ func runOpen(target string) {
|
|||||||
params["mimeType"] = detectedMimeType
|
params["mimeType"] = detectedMimeType
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(detectedCategories) > 0 {
|
if len(openCategories) > 0 {
|
||||||
params["categories"] = detectedCategories
|
params["categories"] = openCategories
|
||||||
}
|
}
|
||||||
|
|
||||||
if detectedRequestType != "" {
|
if detectedRequestType != "" {
|
||||||
@@ -183,7 +118,7 @@ func runOpen(target string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
method := "apppicker.open"
|
method := "apppicker.open"
|
||||||
if detectedMimeType == "" && len(detectedCategories) == 0 && (strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") || strings.HasPrefix(target, "dms://")) {
|
if detectedMimeType == "" && len(openCategories) == 0 && (isScheme(target, "http://") || isScheme(target, "https://") || isScheme(target, "dms://")) {
|
||||||
method = "browser.open"
|
method = "browser.open"
|
||||||
params["url"] = target
|
params["url"] = target
|
||||||
}
|
}
|
||||||
@@ -203,3 +138,7 @@ func runOpen(target string) {
|
|||||||
|
|
||||||
log.Infof("Request sent successfully")
|
log.Infof("Request sent successfully")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isScheme(target, prefix string) bool {
|
||||||
|
return strings.HasPrefix(target, prefix)
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -11,6 +12,7 @@ import (
|
|||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/greeter"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/greeter"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
@@ -267,6 +269,8 @@ func runSetupDmsConfig(name string) error {
|
|||||||
func runSetup() error {
|
func runSetup() error {
|
||||||
fmt.Println("=== DMS Configuration Setup ===")
|
fmt.Println("=== DMS Configuration Setup ===")
|
||||||
|
|
||||||
|
ensureInputGroup()
|
||||||
|
|
||||||
wm, wmSelected := promptCompositor()
|
wm, wmSelected := promptCompositor()
|
||||||
terminal, terminalSelected := promptTerminal()
|
terminal, terminalSelected := promptTerminal()
|
||||||
useSystemd := promptSystemd()
|
useSystemd := promptSystemd()
|
||||||
@@ -340,6 +344,37 @@ func runSetup() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add user to the input group for the evdev manager for inut state tracking.
|
||||||
|
// Caps Lock OSD and the Caps Lock bar indicator.
|
||||||
|
func ensureInputGroup() {
|
||||||
|
if !utils.HasGroup("input") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
currentUser := os.Getenv("USER")
|
||||||
|
if currentUser == "" {
|
||||||
|
currentUser = os.Getenv("LOGNAME")
|
||||||
|
}
|
||||||
|
if currentUser == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := execGroups(currentUser)
|
||||||
|
if err == nil && strings.Contains(out, "input") {
|
||||||
|
fmt.Printf("✓ %s is already in the input group (Caps Lock OSD enabled)\n", currentUser)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println("Adding user to input group for Caps Lock OSD support...")
|
||||||
|
if err := privesc.Run(context.Background(), "", "usermod", "-aG", "input", currentUser); err != nil {
|
||||||
|
fmt.Printf("⚠ Could not add %s to input group (Caps Lock OSD will be unavailable): %v\n", currentUser, err)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("✓ Added %s to input group (logout/login required to take effect)\n", currentUser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func execGroups(user string) (string, error) {
|
||||||
|
out, err := exec.Command("groups", user).Output()
|
||||||
|
return string(out), err
|
||||||
|
}
|
||||||
|
|
||||||
func promptCompositor() (deps.WindowManager, bool) {
|
func promptCompositor() (deps.WindowManager, bool) {
|
||||||
fmt.Println("Select compositor:")
|
fmt.Println("Select compositor:")
|
||||||
fmt.Println("1) Niri")
|
fmt.Println("1) Niri")
|
||||||
|
|||||||
@@ -0,0 +1,365 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/sysupdate"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var systemCmd = &cobra.Command{
|
||||||
|
Use: "system",
|
||||||
|
Short: "System operations",
|
||||||
|
Long: "System-level operations (updates, etc.). Runs against installed package managers directly; does not require the DMS server.",
|
||||||
|
}
|
||||||
|
|
||||||
|
var systemUpdateCmd = &cobra.Command{
|
||||||
|
Use: "update",
|
||||||
|
Short: "Apply or list system updates",
|
||||||
|
Long: `Apply or list system updates across detected package managers.
|
||||||
|
|
||||||
|
Default behavior is to apply available updates after prompting for confirmation.
|
||||||
|
Use --check to list updates without applying.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
dms system update --check # list available updates
|
||||||
|
dms system update # apply updates (interactive prompt)
|
||||||
|
dms system update --noconfirm # apply updates without prompting
|
||||||
|
dms system update --dry # simulate without changing anything
|
||||||
|
dms system update --no-flatpak --noconfirm # apply system updates only
|
||||||
|
dms system update --interval 3600 # set the server poll interval to 1h`,
|
||||||
|
Run: runSystemUpdate,
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
sysUpdateCheck bool
|
||||||
|
sysUpdateNoConfirm bool
|
||||||
|
sysUpdateDry bool
|
||||||
|
sysUpdateJSON bool
|
||||||
|
sysUpdateNoFlatpak bool
|
||||||
|
sysUpdateNoAUR bool
|
||||||
|
sysUpdateIntervalS int
|
||||||
|
sysUpdateListPmTime = 5 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
systemUpdateCmd.Flags().BoolVar(&sysUpdateCheck, "check", false, "List available updates without applying")
|
||||||
|
systemUpdateCmd.Flags().BoolVarP(&sysUpdateNoConfirm, "noconfirm", "y", false, "Apply updates without prompting")
|
||||||
|
systemUpdateCmd.Flags().BoolVar(&sysUpdateDry, "dry", false, "Simulate the upgrade without applying changes")
|
||||||
|
systemUpdateCmd.Flags().BoolVar(&sysUpdateJSON, "json", false, "Output as JSON (with --check)")
|
||||||
|
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoFlatpak, "no-flatpak", false, "Skip the Flatpak overlay")
|
||||||
|
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoAUR, "no-aur", false, "Skip the AUR (paru/yay only)")
|
||||||
|
systemUpdateCmd.Flags().IntVar(&sysUpdateIntervalS, "interval", -1, "Set the DMS server poll interval in seconds and exit (requires running server)")
|
||||||
|
|
||||||
|
systemCmd.AddCommand(systemUpdateCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSystemUpdate(cmd *cobra.Command, args []string) {
|
||||||
|
switch {
|
||||||
|
case sysUpdateIntervalS >= 0:
|
||||||
|
runSystemUpdateSetInterval(sysUpdateIntervalS)
|
||||||
|
case sysUpdateCheck:
|
||||||
|
runSystemUpdateCheck()
|
||||||
|
default:
|
||||||
|
runSystemUpdateApply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectBackends(ctx context.Context) []sysupdate.Backend {
|
||||||
|
sel := sysupdate.Select(ctx)
|
||||||
|
backends := sel.All()
|
||||||
|
if !sysUpdateNoFlatpak {
|
||||||
|
return backends
|
||||||
|
}
|
||||||
|
out := backends[:0]
|
||||||
|
for _, b := range backends {
|
||||||
|
if b.Repo() == sysupdate.RepoFlatpak {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, b)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSystemUpdateCheck() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), sysUpdateListPmTime)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
backends := selectBackends(ctx)
|
||||||
|
if len(backends) == 0 {
|
||||||
|
log.Fatal("No supported package manager found")
|
||||||
|
}
|
||||||
|
|
||||||
|
stopSpin := startSpinner("Checking for updates… ")
|
||||||
|
allPkgs, firstErr := collectUpdates(ctx, backends)
|
||||||
|
stopSpin()
|
||||||
|
allPkgs = filterUpdateTargets(allPkgs)
|
||||||
|
|
||||||
|
if sysUpdateJSON {
|
||||||
|
out, _ := json.MarshalIndent(map[string]any{
|
||||||
|
"backends": backendResults(backends, allPkgs),
|
||||||
|
"packages": allPkgs,
|
||||||
|
"error": errOrEmpty(firstErr),
|
||||||
|
"count": len(allPkgs),
|
||||||
|
}, "", " ")
|
||||||
|
fmt.Println(string(out))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
printBackends(backends)
|
||||||
|
fmt.Printf("Updates: %d\n", len(allPkgs))
|
||||||
|
if firstErr != nil {
|
||||||
|
fmt.Printf("Error: %v\n", firstErr)
|
||||||
|
}
|
||||||
|
if len(allPkgs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
for _, p := range allPkgs {
|
||||||
|
printPackage(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type backendResult struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Display string `json:"displayName"`
|
||||||
|
Packages []sysupdate.Package `json:"packages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func backendResults(backends []sysupdate.Backend, pkgs []sysupdate.Package) []backendResult {
|
||||||
|
results := make([]backendResult, 0, len(backends))
|
||||||
|
for _, b := range backends {
|
||||||
|
var backendPkgs []sysupdate.Package
|
||||||
|
for _, p := range pkgs {
|
||||||
|
if sysupdate.BackendHasTargets(b, []sysupdate.Package{p}, true, true) {
|
||||||
|
backendPkgs = append(backendPkgs, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
results = append(results, backendResult{ID: b.ID(), Display: b.DisplayName(), Packages: backendPkgs})
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSystemUpdateApply() {
|
||||||
|
checkCtx, checkCancel := context.WithTimeout(context.Background(), sysUpdateListPmTime)
|
||||||
|
defer checkCancel()
|
||||||
|
|
||||||
|
backends := selectBackends(checkCtx)
|
||||||
|
if len(backends) == 0 {
|
||||||
|
log.Fatal("No supported package manager found")
|
||||||
|
}
|
||||||
|
|
||||||
|
stopSpin := startSpinner("Checking for updates…")
|
||||||
|
pkgs, firstErr := collectUpdates(checkCtx, backends)
|
||||||
|
stopSpin()
|
||||||
|
pkgs = filterUpdateTargets(pkgs)
|
||||||
|
if firstErr != nil {
|
||||||
|
fmt.Printf("Warning: %v\n\n", firstErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
printBackends(backends)
|
||||||
|
fmt.Printf("Updates: %d\n", len(pkgs))
|
||||||
|
if len(pkgs) == 0 {
|
||||||
|
fmt.Println("Nothing to upgrade.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
for _, p := range pkgs {
|
||||||
|
printPackage(p)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
if !sysUpdateNoConfirm && !sysUpdateDry {
|
||||||
|
if !promptYesNo("Proceed with upgrade? [Y/n]: ") {
|
||||||
|
fmt.Println("Aborted.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
opts := sysupdate.UpgradeOptions{
|
||||||
|
Targets: pkgs,
|
||||||
|
IncludeFlatpak: !sysUpdateNoFlatpak,
|
||||||
|
IncludeAUR: !sysUpdateNoAUR,
|
||||||
|
DryRun: sysUpdateDry,
|
||||||
|
UseSudo: true,
|
||||||
|
}
|
||||||
|
opts.AttachStdio = sysupdate.UpgradeNeedsPrivilege(backends, pkgs, opts)
|
||||||
|
|
||||||
|
onLine := func(line string) { fmt.Println(line) }
|
||||||
|
ran := false
|
||||||
|
for _, b := range backends {
|
||||||
|
if !sysupdate.BackendHasTargets(b, pkgs, opts.IncludeAUR, opts.IncludeFlatpak) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ran = true
|
||||||
|
fmt.Printf("\n== %s ==\n", b.DisplayName())
|
||||||
|
if err := b.Upgrade(ctx, opts, onLine); err != nil {
|
||||||
|
log.Fatalf("%s upgrade failed: %v", b.ID(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ran {
|
||||||
|
fmt.Println("Nothing to upgrade.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if sysUpdateDry {
|
||||||
|
fmt.Println("\nDry run complete (no changes applied).")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println("\nUpgrade complete.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectUpdates(ctx context.Context, backends []sysupdate.Backend) ([]sysupdate.Package, error) {
|
||||||
|
var all []sysupdate.Package
|
||||||
|
var firstErr error
|
||||||
|
for _, b := range backends {
|
||||||
|
pkgs, err := b.CheckUpdates(ctx)
|
||||||
|
if err != nil && firstErr == nil {
|
||||||
|
firstErr = fmt.Errorf("%s: %w", b.ID(), err)
|
||||||
|
}
|
||||||
|
all = append(all, pkgs...)
|
||||||
|
}
|
||||||
|
return all, firstErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterUpdateTargets(pkgs []sysupdate.Package) []sysupdate.Package {
|
||||||
|
if !sysUpdateNoAUR {
|
||||||
|
return pkgs
|
||||||
|
}
|
||||||
|
out := pkgs[:0]
|
||||||
|
for _, p := range pkgs {
|
||||||
|
if p.Repo == sysupdate.RepoAUR {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSystemUpdateSetInterval(seconds int) {
|
||||||
|
resp, err := sendServerRequest(models.Request{
|
||||||
|
ID: 1,
|
||||||
|
Method: "sysupdate.setInterval",
|
||||||
|
Params: map[string]any{"seconds": float64(seconds)},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed: %v (is dms server running?)", err)
|
||||||
|
}
|
||||||
|
if resp.Error != "" {
|
||||||
|
log.Fatalf("Error: %s", resp.Error)
|
||||||
|
}
|
||||||
|
fmt.Printf("Interval set to %d seconds.\n", seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
func promptYesNo(prompt string) bool {
|
||||||
|
if !stdinIsTTY() {
|
||||||
|
log.Fatal("Refusing to apply updates non-interactively. Re-run with --noconfirm or --check.")
|
||||||
|
}
|
||||||
|
fmt.Print(prompt)
|
||||||
|
reader := bufio.NewReader(os.Stdin)
|
||||||
|
line, err := reader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch strings.ToLower(strings.TrimSpace(line)) {
|
||||||
|
case "n", "no":
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func printBackends(backends []sysupdate.Backend) {
|
||||||
|
if len(backends) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(backends))
|
||||||
|
for _, b := range backends {
|
||||||
|
names = append(names, b.DisplayName())
|
||||||
|
}
|
||||||
|
fmt.Printf("Backends: %s\n", strings.Join(names, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
func stdinIsTTY() bool {
|
||||||
|
fi, err := os.Stdin.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return (fi.Mode() & os.ModeCharDevice) != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func stdoutIsTTY() bool {
|
||||||
|
fi, err := os.Stdout.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return (fi.Mode() & os.ModeCharDevice) != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// startSpinner prints an animated spinner to stdout for progress indication
|
||||||
|
func startSpinner(msg string) func() {
|
||||||
|
if !stdoutIsTTY() {
|
||||||
|
return func() {}
|
||||||
|
}
|
||||||
|
frames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
for i := 0; ; i++ {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
fmt.Print("\r\033[K")
|
||||||
|
return
|
||||||
|
case <-time.After(80 * time.Millisecond):
|
||||||
|
fmt.Printf("\r%s %s", frames[i%len(frames)], msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return func() { close(done) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
styleRepo = lipgloss.NewStyle().Foreground(lipgloss.Color("244")).Bold(false)
|
||||||
|
styleName = lipgloss.NewStyle().Foreground(lipgloss.Color("39")).Bold(true)
|
||||||
|
styleFrom = lipgloss.NewStyle().Foreground(lipgloss.Color("243"))
|
||||||
|
styleArrow = lipgloss.NewStyle().Foreground(lipgloss.Color("244"))
|
||||||
|
styleTo = lipgloss.NewStyle().Foreground(lipgloss.Color("76")).Bold(true)
|
||||||
|
)
|
||||||
|
|
||||||
|
func printPackage(p sysupdate.Package) {
|
||||||
|
if !stdoutIsTTY() {
|
||||||
|
fmt.Printf(" [%s] %s %s -> %s\n", p.Repo, p.Name, defaultIfEmpty(p.FromVersion, "?"), defaultIfEmpty(p.ToVersion, "?"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf(" %s %s %s %s %s\n",
|
||||||
|
styleRepo.Render("["+string(p.Repo)+"]"),
|
||||||
|
styleName.Render(p.Name),
|
||||||
|
styleFrom.Render(defaultIfEmpty(p.FromVersion, "?")),
|
||||||
|
styleArrow.Render("->"),
|
||||||
|
styleTo.Render(defaultIfEmpty(p.ToVersion, "?")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func errOrEmpty(err error) string {
|
||||||
|
if err == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultIfEmpty(s, def string) string {
|
||||||
|
if s == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/trash"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var trashCmd = &cobra.Command{
|
||||||
|
Use: "trash",
|
||||||
|
Short: "Manage the user's trash (XDG Trash spec 1.0)",
|
||||||
|
}
|
||||||
|
|
||||||
|
var trashPutCmd = &cobra.Command{
|
||||||
|
Use: "put <path...>",
|
||||||
|
Short: "Move files or directories into the trash",
|
||||||
|
Args: cobra.MinimumNArgs(1),
|
||||||
|
Run: runTrashPut,
|
||||||
|
}
|
||||||
|
|
||||||
|
var trashListCmd = &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List trashed items across all known trash directories",
|
||||||
|
Run: runTrashList,
|
||||||
|
}
|
||||||
|
|
||||||
|
var trashCountCmd = &cobra.Command{
|
||||||
|
Use: "count",
|
||||||
|
Short: "Print the total number of trashed items",
|
||||||
|
Run: runTrashCount,
|
||||||
|
}
|
||||||
|
|
||||||
|
var trashEmptyCmd = &cobra.Command{
|
||||||
|
Use: "empty",
|
||||||
|
Short: "Permanently delete every trashed item",
|
||||||
|
Run: runTrashEmpty,
|
||||||
|
}
|
||||||
|
|
||||||
|
var trashRestoreCmd = &cobra.Command{
|
||||||
|
Use: "restore <name>",
|
||||||
|
Short: "Restore a trashed item to its original location",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
Run: runTrashRestore,
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
trashJSONOutput bool
|
||||||
|
trashRestoreDir string
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
trashListCmd.Flags().BoolVar(&trashJSONOutput, "json", false, "Output as JSON")
|
||||||
|
trashRestoreCmd.Flags().StringVar(&trashRestoreDir, "trash-dir", "", "Trash directory containing the item (default: home trash)")
|
||||||
|
trashCmd.AddCommand(trashPutCmd, trashListCmd, trashCountCmd, trashEmptyCmd, trashRestoreCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runTrashPut(cmd *cobra.Command, args []string) {
|
||||||
|
var failed int
|
||||||
|
for _, p := range args {
|
||||||
|
if _, err := trash.Put(p); err != nil {
|
||||||
|
log.Errorf("trash %s: %v", p, err)
|
||||||
|
failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Println(p)
|
||||||
|
}
|
||||||
|
if failed > 0 {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runTrashList(cmd *cobra.Command, args []string) {
|
||||||
|
entries, err := trash.List()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("list trash: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if trashJSONOutput {
|
||||||
|
if entries == nil {
|
||||||
|
entries = []trash.Entry{}
|
||||||
|
}
|
||||||
|
out, _ := json.MarshalIndent(entries, "", " ")
|
||||||
|
fmt.Println(string(out))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(entries) == 0 {
|
||||||
|
fmt.Println("Trash is empty")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
marker := "F"
|
||||||
|
if e.IsDir {
|
||||||
|
marker = "D"
|
||||||
|
}
|
||||||
|
fmt.Printf("%s %s %s %s\n", marker, e.DeletionDate, e.Name, e.OriginalPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runTrashCount(cmd *cobra.Command, args []string) {
|
||||||
|
n, err := trash.Count()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("count trash: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Println(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runTrashEmpty(cmd *cobra.Command, args []string) {
|
||||||
|
if err := trash.Empty(); err != nil {
|
||||||
|
log.Fatalf("empty trash: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runTrashRestore(cmd *cobra.Command, args []string) {
|
||||||
|
if err := trash.Restore(args[0], trashRestoreDir); err != nil {
|
||||||
|
log.Fatalf("restore: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,8 @@ func init() {
|
|||||||
runCmd.Flags().BoolP("daemon", "d", false, "Run in daemon mode")
|
runCmd.Flags().BoolP("daemon", "d", false, "Run in daemon mode")
|
||||||
runCmd.Flags().Bool("daemon-child", false, "Internal flag for daemon child process")
|
runCmd.Flags().Bool("daemon-child", false, "Internal flag for daemon child process")
|
||||||
runCmd.Flags().Bool("session", false, "Session managed (like as a systemd unit)")
|
runCmd.Flags().Bool("session", false, "Session managed (like as a systemd unit)")
|
||||||
|
runCmd.Flags().String("log-level", "", "Log level: debug, info, warn, error, fatal (overrides DMS_LOG_LEVEL)")
|
||||||
|
runCmd.Flags().String("log-file", "", "Append logs to this file in addition to stderr (overrides DMS_LOG_FILE)")
|
||||||
runCmd.Flags().MarkHidden("daemon-child")
|
runCmd.Flags().MarkHidden("daemon-child")
|
||||||
|
|
||||||
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd)
|
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd)
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ func init() {
|
|||||||
runCmd.Flags().BoolP("daemon", "d", false, "Run in daemon mode")
|
runCmd.Flags().BoolP("daemon", "d", false, "Run in daemon mode")
|
||||||
runCmd.Flags().Bool("daemon-child", false, "Internal flag for daemon child process")
|
runCmd.Flags().Bool("daemon-child", false, "Internal flag for daemon child process")
|
||||||
runCmd.Flags().Bool("session", false, "Session managed (like as a systemd unit)")
|
runCmd.Flags().Bool("session", false, "Session managed (like as a systemd unit)")
|
||||||
|
runCmd.Flags().String("log-level", "", "Log level: debug, info, warn, error, fatal (overrides DMS_LOG_LEVEL)")
|
||||||
|
runCmd.Flags().String("log-file", "", "Append logs to this file in addition to stderr (overrides DMS_LOG_FILE)")
|
||||||
runCmd.Flags().MarkHidden("daemon-child")
|
runCmd.Flags().MarkHidden("daemon-child")
|
||||||
|
|
||||||
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd)
|
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd)
|
||||||
|
|||||||
+14
-3
@@ -80,6 +80,16 @@ func getRuntimeDir() string {
|
|||||||
return os.TempDir()
|
return os.TempDir()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func appendLogEnv(env []string) []string {
|
||||||
|
if v := os.Getenv("DMS_LOG_LEVEL"); v != "" {
|
||||||
|
env = append(env, "DMS_LOG_LEVEL="+v)
|
||||||
|
}
|
||||||
|
if v := os.Getenv("DMS_LOG_FILE"); v != "" {
|
||||||
|
env = append(env, "DMS_LOG_FILE="+v)
|
||||||
|
}
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
func hasSystemdRun() bool {
|
func hasSystemdRun() bool {
|
||||||
_, err := exec.LookPath("systemd-run")
|
_, err := exec.LookPath("systemd-run")
|
||||||
return err == nil
|
return err == nil
|
||||||
@@ -192,9 +202,6 @@ func runShellInteractive(session bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ! TODO - remove when QS 0.3 is up and we can use the pragma
|
|
||||||
cmd.Env = append(cmd.Env, "QS_APP_ID=com.danklinux.dms")
|
|
||||||
|
|
||||||
if isSessionManaged && hasSystemdRun() {
|
if isSessionManaged && hasSystemdRun() {
|
||||||
cmd.Env = append(cmd.Env, "DMS_DEFAULT_LAUNCH_PREFIX=systemd-run --user --scope")
|
cmd.Env = append(cmd.Env, "DMS_DEFAULT_LAUNCH_PREFIX=systemd-run --user --scope")
|
||||||
}
|
}
|
||||||
@@ -216,6 +223,8 @@ func runShellInteractive(session bool) {
|
|||||||
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland;xcb")
|
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland;xcb")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cmd.Env = appendLogEnv(cmd.Env)
|
||||||
|
|
||||||
cmd.Stdin = os.Stdin
|
cmd.Stdin = os.Stdin
|
||||||
cmd.Stdout = os.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
@@ -459,6 +468,8 @@ func runShellDaemon(session bool) {
|
|||||||
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland;xcb")
|
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland;xcb")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cmd.Env = appendLogEnv(cmd.Env)
|
||||||
|
|
||||||
devNull, err := os.OpenFile("/dev/null", os.O_RDWR, 0)
|
devNull, err := os.OpenFile("/dev/null", os.O_RDWR, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Error opening /dev/null: %v", err)
|
log.Fatalf("Error opening /dev/null: %v", err)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ func isReadOnlyCommand(args []string) bool {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
switch arg {
|
switch arg {
|
||||||
case "completion", "help", "__complete":
|
case "completion", "help", "__complete", "system":
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
|
|||||||
+45
-29
@@ -1,84 +1,100 @@
|
|||||||
module github.com/AvengeMedia/DankMaterialShell/core
|
module github.com/AvengeMedia/DankMaterialShell/core
|
||||||
|
|
||||||
go 1.26.0
|
go 1.26.1
|
||||||
|
|
||||||
toolchain go1.26.1
|
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/Wifx/gonetworkmanager/v2 v2.2.0
|
github.com/Wifx/gonetworkmanager/v2 v2.2.0
|
||||||
github.com/alecthomas/chroma/v2 v2.23.1
|
github.com/alecthomas/chroma/v2 v2.24.1
|
||||||
github.com/charmbracelet/bubbles v1.0.0
|
github.com/charmbracelet/bubbles v1.0.0
|
||||||
github.com/charmbracelet/bubbletea v1.3.10
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
github.com/charmbracelet/lipgloss v1.1.0
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
github.com/charmbracelet/log v0.4.2
|
github.com/charmbracelet/log v1.0.0
|
||||||
github.com/fsnotify/fsnotify v1.9.0
|
github.com/fsnotify/fsnotify v1.10.1
|
||||||
github.com/godbus/dbus/v5 v5.2.2
|
github.com/godbus/dbus/v5 v5.2.2
|
||||||
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83
|
github.com/holoplot/go-evdev v0.0.0-20260504100651-66d1748fe847
|
||||||
github.com/pilebones/go-udev v0.9.1
|
github.com/pilebones/go-udev v0.9.1
|
||||||
github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6
|
github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/yeqown/go-qrcode/v2 v2.2.5
|
github.com/yeqown/go-qrcode/v2 v2.2.5
|
||||||
github.com/yeqown/go-qrcode/writer/standard v1.3.0
|
github.com/yeqown/go-qrcode/writer/standard v1.3.0
|
||||||
github.com/yuin/goldmark v1.7.16
|
github.com/yuin/goldmark v1.8.2
|
||||||
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
|
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
|
||||||
go.etcd.io/bbolt v1.4.3
|
go.etcd.io/bbolt v1.4.3
|
||||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a
|
go4.org/mem v0.0.0-20240501181205-ae6ca9944745
|
||||||
golang.org/x/image v0.36.0
|
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f
|
||||||
|
golang.org/x/image v0.39.0
|
||||||
|
tailscale.com v1.96.5
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
filippo.io/edwards25519 v1.2.0 // indirect
|
||||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||||
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
github.com/ProtonMail/go-crypto v1.4.1 // indirect
|
||||||
github.com/clipperhouse/displaywidth v0.10.0 // indirect
|
github.com/akutz/memconn v0.1.0 // indirect
|
||||||
|
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||||
github.com/cloudflare/circl v1.6.3 // indirect
|
github.com/cloudflare/circl v1.6.3 // indirect
|
||||||
|
github.com/coder/websocket v1.8.14 // indirect
|
||||||
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
|
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
|
||||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
github.com/dblohm7/wingoes v0.0.0-20250822163801-6d8e6105c62d // indirect
|
||||||
|
github.com/dlclark/regexp2 v1.12.0 // indirect
|
||||||
github.com/emirpasic/gods v1.18.1 // indirect
|
github.com/emirpasic/gods v1.18.1 // indirect
|
||||||
github.com/fogleman/gg v1.3.0 // indirect
|
github.com/fogleman/gg v1.3.0 // indirect
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
|
||||||
github.com/go-git/gcfg/v2 v2.0.2 // indirect
|
github.com/go-git/gcfg/v2 v2.0.2 // indirect
|
||||||
github.com/go-git/go-billy/v6 v6.0.0-20260209124918-37866f83c2d3 // indirect
|
github.com/go-git/go-billy/v6 v6.0.0-20260504142752-cb8e9d337266 // indirect
|
||||||
|
github.com/go-json-experiment/json v0.0.0-20260430182902-b6187a392ed4 // indirect
|
||||||
github.com/go-logfmt/logfmt v0.6.1 // indirect
|
github.com/go-logfmt/logfmt v0.6.1 // indirect
|
||||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
github.com/google/go-cmp v0.7.0 // indirect
|
||||||
|
github.com/hdevalence/ed25519consensus v0.2.0 // indirect
|
||||||
|
github.com/jsimonetti/rtnetlink v1.4.2 // indirect
|
||||||
github.com/kevinburke/ssh_config v1.6.0 // indirect
|
github.com/kevinburke/ssh_config v1.6.0 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
github.com/pjbgf/sha1cd v0.5.0 // indirect
|
github.com/mdlayher/netlink v1.11.1 // indirect
|
||||||
|
github.com/mdlayher/socket v0.6.0 // indirect
|
||||||
|
github.com/mitchellh/go-ps v1.0.0 // indirect
|
||||||
|
github.com/pjbgf/sha1cd v0.6.0 // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/sergi/go-diff v1.4.0 // indirect
|
github.com/sergi/go-diff v1.4.0 // indirect
|
||||||
github.com/stretchr/objx v0.5.3 // indirect
|
github.com/stretchr/objx v0.5.3 // indirect
|
||||||
|
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect
|
||||||
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
github.com/yeqown/reedsolomon v1.0.0 // indirect
|
github.com/yeqown/reedsolomon v1.0.0 // indirect
|
||||||
golang.org/x/crypto v0.48.0 // indirect
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
|
||||||
golang.org/x/net v0.50.0 // indirect
|
golang.org/x/crypto v0.50.0 // indirect
|
||||||
|
golang.org/x/net v0.53.0 // indirect
|
||||||
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
|
golang.zx2c4.com/wireguard/windows v1.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/atotto/clipboard v0.1.4 // indirect
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
github.com/charmbracelet/colorprofile v0.4.2 // indirect
|
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||||
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
||||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
github.com/charmbracelet/x/ansi v0.11.7 // indirect
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
github.com/go-git/go-git/v6 v6.0.0-20260216160506-e6a3f881772f
|
github.com/go-git/go-git/v6 v6.0.0-alpha.2
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0
|
github.com/lucasb-eyer/go-colorful v1.4.0
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.22
|
||||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
github.com/mattn/go-runewidth v0.0.23 // indirect
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
github.com/muesli/termenv v0.16.0 // indirect
|
github.com/muesli/termenv v0.16.0
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/spf13/afero v1.15.0
|
github.com/spf13/afero v1.15.0
|
||||||
github.com/spf13/pflag v1.0.10 // indirect
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
golang.org/x/sys v0.41.0
|
golang.org/x/sys v0.43.0
|
||||||
golang.org/x/text v0.34.0
|
golang.org/x/text v0.36.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+103
-55
@@ -1,14 +1,18 @@
|
|||||||
|
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||||
|
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
|
github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM=
|
||||||
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
|
github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
|
||||||
github.com/Wifx/gonetworkmanager/v2 v2.2.0 h1:kPstgsQtY8CmDOOFZd81ytM9Gi3f6ImzPCKF7nNhQ2U=
|
github.com/Wifx/gonetworkmanager/v2 v2.2.0 h1:kPstgsQtY8CmDOOFZd81ytM9Gi3f6ImzPCKF7nNhQ2U=
|
||||||
github.com/Wifx/gonetworkmanager/v2 v2.2.0/go.mod h1:fMDb//SHsKWxyDUAwXvCqurV3npbIyyaQWenGpZ/uXg=
|
github.com/Wifx/gonetworkmanager/v2 v2.2.0/go.mod h1:fMDb//SHsKWxyDUAwXvCqurV3npbIyyaQWenGpZ/uXg=
|
||||||
|
github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A=
|
||||||
|
github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw=
|
||||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||||
github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
|
github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
|
||||||
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
|
github.com/alecthomas/chroma/v2 v2.24.1 h1:m5ffpfZbIb++k8AqFEKy9uVgY12xIQtBsQlc6DfZJQM=
|
||||||
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
|
github.com/alecthomas/chroma/v2 v2.24.1/go.mod h1:l+ohZ9xRXIbGe7cIW+YZgOGbvuVLjMps/FYN/CwuabI=
|
||||||
github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
|
github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
|
||||||
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
|
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
|
||||||
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||||
@@ -24,54 +28,71 @@ github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5f
|
|||||||
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||||
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
|
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||||
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
|
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||||
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
|
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
|
||||||
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
||||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||||
github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
|
github.com/charmbracelet/log v1.0.0 h1:HVVVMmfOorfj3BA9i8X8UL69Hoz9lI0PYwXfJvOdRc4=
|
||||||
github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
|
github.com/charmbracelet/log v1.0.0/go.mod h1:uYgY3SmLpwJWxmlrPwXvzVYujxis1vAKRV/0VQB7yWA=
|
||||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
|
||||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||||
github.com/clipperhouse/displaywidth v0.10.0 h1:GhBG8WuerxjFQQYeuZAeVTuyxuX+UraiZGD4HJQ3Y8g=
|
github.com/cilium/ebpf v0.16.0 h1:+BiEnHL6Z7lXnlGUsXQPPAE7+kenAd4ES8MQ5min0Ok=
|
||||||
github.com/clipperhouse/displaywidth v0.10.0/go.mod h1:XqJajYsaiEwkxOj4bowCTMcT1SgvHo9flfF3jQasdbs=
|
github.com/cilium/ebpf v0.16.0/go.mod h1:L7u2Blt2jMM/vLAVgjxluxtBKlz3/GWjB0dMOEngfwE=
|
||||||
|
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||||
|
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||||
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||||
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||||
|
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||||
|
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
|
github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc=
|
||||||
|
github.com/creachadair/taskgroup v0.13.2/go.mod h1:i3V1Zx7H8RjwljUEeUWYT30Lmb9poewSb2XI1yTwD0g=
|
||||||
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
|
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
|
||||||
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
|
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dblohm7/wingoes v0.0.0-20250822163801-6d8e6105c62d h1:QRKpU+9ZBDs62LyBfwhZkJdB5DJX2Sm3p4kUh7l1aA0=
|
||||||
|
github.com/dblohm7/wingoes v0.0.0-20250822163801-6d8e6105c62d/go.mod h1:SUxUaAK/0UG5lYyZR1L1nC4AaYYvSSYTWQSH3FPcxKU=
|
||||||
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
|
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
|
||||||
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
||||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||||
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||||
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
|
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
|
||||||
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
|
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
|
||||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
|
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||||
|
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||||
|
github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo=
|
||||||
|
github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
|
||||||
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||||
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||||
github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo=
|
github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo=
|
||||||
github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs=
|
github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs=
|
||||||
github.com/go-git/go-billy/v6 v6.0.0-20260209124918-37866f83c2d3 h1:UU7oARtwQ5g85aFiCSwIUA6PBmAshYj0sytl/5CCBgs=
|
github.com/go-git/go-billy/v6 v6.0.0-20260504142752-cb8e9d337266 h1:wH21vHuv323v9x78JNFNJ6P7HEAsdwr9yq2k9/o4zEE=
|
||||||
github.com/go-git/go-billy/v6 v6.0.0-20260209124918-37866f83c2d3/go.mod h1:ZW9JC5gionMP1kv5uiaOaV23q0FFmNrVOV8VW+y/acc=
|
github.com/go-git/go-billy/v6 v6.0.0-20260504142752-cb8e9d337266/go.mod h1:CdBVp7CXl9l3sOyNEog46cP1Pvx/hjCe9AD0mtaIUYU=
|
||||||
github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20260122163445-0622d7459a67 h1:3hutPZF+/FBjR/9MdsLJ7e1mlt9pwHgwxMW7CrbmWII=
|
github.com/go-git/go-git-fixtures/v6 v6.0.0-20260405195209-b16dd39735e0 h1:XoTsdvaghuVfIr7HpNTmFDLu2nz3I2iGqyn6Uk6MkJc=
|
||||||
github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20260122163445-0622d7459a67/go.mod h1:xKt0pNHST9tYHvbiLxSY27CQWFwgIxBJuDrOE0JvbZw=
|
github.com/go-git/go-git-fixtures/v6 v6.0.0-20260405195209-b16dd39735e0/go.mod h1:1Lr7/vYEYyl6Ir9Ku0tKrCIRreM5zovv0Jdx2MPSM4s=
|
||||||
github.com/go-git/go-git/v6 v6.0.0-20260216160506-e6a3f881772f h1:TBkCJv9YwPOuXq1OG0r01bcxRrvs15Hp/DtZuPt4H6s=
|
github.com/go-git/go-git/v6 v6.0.0-alpha.2 h1:T3loNtDuAixNzXtlQxZhnYiYpaQ3CA4vn9RssAniEeI=
|
||||||
github.com/go-git/go-git/v6 v6.0.0-20260216160506-e6a3f881772f/go.mod h1:B88nWzfnhTlIikoJ4d84Nc9noKS5mJoA7SgDdkt0aPU=
|
github.com/go-git/go-git/v6 v6.0.0-alpha.2/go.mod h1:oCD3i19CTz7gBpeb11ZZqL91WzqbMq9avn5KpUYy/Ak=
|
||||||
|
github.com/go-json-experiment/json v0.0.0-20260430182902-b6187a392ed4 h1:2WmHkJINIjgXXYDGik8d3oJvFA3DAwPy00csDJ3vo+o=
|
||||||
|
github.com/go-json-experiment/json v0.0.0-20260430182902-b6187a392ed4/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
|
||||||
github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
|
github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
|
||||||
github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
|
github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
|
||||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
@@ -84,12 +105,16 @@ github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUv
|
|||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU=
|
||||||
|
github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo=
|
||||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||||
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83 h1:B+A58zGFuDrvEZpPN+yS6swJA0nzqgZvDzgl/OPyefU=
|
github.com/holoplot/go-evdev v0.0.0-20260504100651-66d1748fe847 h1:1rQ5UQXFm02DXEtsIpotfA32WJ9KceS6t8w5K8QtFqc=
|
||||||
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83/go.mod h1:iHAf8OIncO2gcQ8XOjS7CMJ2aPbX2Bs0wl5pZyanEqk=
|
github.com/holoplot/go-evdev v0.0.0-20260504100651-66d1748fe847/go.mod h1:iHAf8OIncO2gcQ8XOjS7CMJ2aPbX2Bs0wl5pZyanEqk=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/jsimonetti/rtnetlink v1.4.2 h1:Df9w9TZ3npHTyDn0Ev9e1uzmN2odmXd0QX+J5GTEn90=
|
||||||
|
github.com/jsimonetti/rtnetlink v1.4.2/go.mod h1:92s6LJdE+1iOrw+F2/RO7LYI2Qd8pPpFNNUYW06gcoM=
|
||||||
github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY=
|
github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY=
|
||||||
github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
|
github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
@@ -101,14 +126,20 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
|||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||||
github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75 h1:P8UmIzZMYDR+NGImiFvErt6VWfIRPuGM+vyjiEdkmIw=
|
github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75 h1:P8UmIzZMYDR+NGImiFvErt6VWfIRPuGM+vyjiEdkmIw=
|
||||||
github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
|
||||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
|
github.com/mdlayher/netlink v1.11.1 h1:T136gDS6Gkt+hLncaBwKdW5GpEC8Z0ykqimOebVoal0=
|
||||||
|
github.com/mdlayher/netlink v1.11.1/go.mod h1:ao4LjamyK4Uq9L8+fQzqFYpAncbeCdwbvd9Edv/pYnc=
|
||||||
|
github.com/mdlayher/socket v0.6.0 h1:ScZPaAGyO1icQnbFrhPM8mnXyMu9qukC1K4ZoM2IQKU=
|
||||||
|
github.com/mdlayher/socket v0.6.0/go.mod h1:q7vozUAnxSqnjHc12Fik5yUKIzfZ8ITCfMkhOtE9z18=
|
||||||
|
github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc=
|
||||||
|
github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg=
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
@@ -117,12 +148,13 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc
|
|||||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||||
github.com/pilebones/go-udev v0.9.1 h1:uN72M1C1fgzhsVmBGEM8w9RD1JY4iVsPZpr+Z6rb3O8=
|
github.com/pilebones/go-udev v0.9.1 h1:uN72M1C1fgzhsVmBGEM8w9RD1JY4iVsPZpr+Z6rb3O8=
|
||||||
github.com/pilebones/go-udev v0.9.1/go.mod h1:Bgcl07crebF3JSeS4+nuaRvhWFdCeFoBhXXeAp93XNo=
|
github.com/pilebones/go-udev v0.9.1/go.mod h1:Bgcl07crebF3JSeS4+nuaRvhWFdCeFoBhXXeAp93XNo=
|
||||||
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
|
github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
|
||||||
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
|
github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
@@ -146,6 +178,12 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P
|
|||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4=
|
||||||
|
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg=
|
||||||
|
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da h1:jVRUZPRs9sqyKlYHHzHjAqKN+6e/Vog6NpHYeNPJqOw=
|
||||||
|
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
|
||||||
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
github.com/yeqown/go-qrcode/v2 v2.2.5 h1:HCOe2bSjkhZyYoyyNaXNzh4DJZll6inVJQQw+8228Zk=
|
github.com/yeqown/go-qrcode/v2 v2.2.5 h1:HCOe2bSjkhZyYoyyNaXNzh4DJZll6inVJQQw+8228Zk=
|
||||||
@@ -155,37 +193,47 @@ github.com/yeqown/go-qrcode/writer/standard v1.3.0/go.mod h1:O4MbzsotGCvy8upYPCR
|
|||||||
github.com/yeqown/reedsolomon v1.0.0 h1:x1h/Ej/uJnNu8jaX7GLHBWmZKCAWjEJTetkqaabr4B0=
|
github.com/yeqown/reedsolomon v1.0.0 h1:x1h/Ej/uJnNu8jaX7GLHBWmZKCAWjEJTetkqaabr4B0=
|
||||||
github.com/yeqown/reedsolomon v1.0.0/go.mod h1:P76zpcn2TCuL0ul1Fso373qHRc69LKwAw/Iy6g1WiiM=
|
github.com/yeqown/reedsolomon v1.0.0/go.mod h1:P76zpcn2TCuL0ul1Fso373qHRc69LKwAw/Iy6g1WiiM=
|
||||||
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
|
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||||
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||||
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
|
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
|
||||||
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
|
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
|
||||||
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
||||||
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek=
|
||||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
|
||||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
|
||||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
|
||||||
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
|
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||||
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
|
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
|
||||||
|
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||||
|
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||||
|
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||||
|
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||||
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
||||||
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
||||||
|
golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH8ysFIbw8=
|
||||||
|
golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
tailscale.com v1.96.5 h1:gNkfA/KSZAl6jCH9cj8urq00HRWItDDTtGsyATI89jA=
|
||||||
|
tailscale.com v1.96.5/go.mod h1:/3lnZBYb2UEwnN0MNu2SDXUtT06AGd5k0s+OWx3WmcY=
|
||||||
|
|||||||
@@ -158,5 +158,8 @@ bind = , Print, exec, dms screenshot
|
|||||||
bind = CTRL, Print, exec, dms screenshot full
|
bind = CTRL, Print, exec, dms screenshot full
|
||||||
bind = ALT, Print, exec, dms screenshot window
|
bind = ALT, Print, exec, dms screenshot window
|
||||||
|
|
||||||
|
# === Display Profiles ===
|
||||||
|
bind = SUPER, P, exec, dms ipc outputs cycleProfile
|
||||||
|
|
||||||
# === System Controls ===
|
# === System Controls ===
|
||||||
bind = SUPER SHIFT, P, dpms, toggle
|
bind = SUPER SHIFT, P, dpms, toggle
|
||||||
|
|||||||
@@ -107,10 +107,6 @@ windowrule = pin on, match:class ^(steam)$, match:title ^(notificationtoasts)
|
|||||||
windowrule = float on, match:class ^(firefox)$, match:title ^(Picture-in-Picture)$
|
windowrule = float on, match:class ^(firefox)$, match:title ^(Picture-in-Picture)$
|
||||||
windowrule = float on, match:class ^(zoom)$
|
windowrule = float on, match:class ^(zoom)$
|
||||||
|
|
||||||
# DMS windows floating by default
|
|
||||||
# ! Hyprland doesn't size these windows correctly so disabling by default here
|
|
||||||
# windowrule = float on, match:class ^(org.quickshell)$
|
|
||||||
|
|
||||||
layerrule = no_anim on, match:namespace ^(quickshell)$
|
layerrule = no_anim on, match:namespace ^(quickshell)$
|
||||||
layerrule = no_anim on, match:namespace ^dms:.*
|
layerrule = no_anim on, match:namespace ^dms:.*
|
||||||
|
|
||||||
|
|||||||
@@ -215,6 +215,11 @@ binds {
|
|||||||
Print { screenshot; }
|
Print { screenshot; }
|
||||||
Ctrl+Print { screenshot-screen; }
|
Ctrl+Print { screenshot-screen; }
|
||||||
Alt+Print { screenshot-window; }
|
Alt+Print { screenshot-window; }
|
||||||
|
// === Display Profiles ===
|
||||||
|
Mod+P hotkey-overlay-title="Cycle Display Profile" {
|
||||||
|
spawn "dms" "ipc" "outputs" "cycleProfile";
|
||||||
|
}
|
||||||
|
|
||||||
// === System Controls ===
|
// === System Controls ===
|
||||||
Mod+Escape allow-inhibiting=false { toggle-keyboard-shortcuts-inhibit; }
|
Mod+Escape allow-inhibiting=false { toggle-keyboard-shortcuts-inhibit; }
|
||||||
Mod+Shift+P { power-off-monitors; }
|
Mod+Shift+P { power-off-monitors; }
|
||||||
|
|||||||
@@ -250,12 +250,6 @@ window-rule {
|
|||||||
match app-id="zoom"
|
match app-id="zoom"
|
||||||
open-floating true
|
open-floating true
|
||||||
}
|
}
|
||||||
// Open dms windows as floating by default
|
|
||||||
window-rule {
|
|
||||||
match app-id=r#"org.quickshell$"#
|
|
||||||
match app-id=r#"com.danklinux.dms$"#
|
|
||||||
open-floating true
|
|
||||||
}
|
|
||||||
debug {
|
debug {
|
||||||
honor-xdg-activation-with-invalid-serial
|
honor-xdg-activation-with-invalid-serial
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package desktop
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Entry struct {
|
||||||
|
ID string
|
||||||
|
Path string
|
||||||
|
Name string
|
||||||
|
Exec string
|
||||||
|
Icon string
|
||||||
|
Categories []string
|
||||||
|
MimeTypes []string
|
||||||
|
NoDisplay bool
|
||||||
|
Hidden bool
|
||||||
|
Terminal bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type cachedEntry struct {
|
||||||
|
entry *Entry
|
||||||
|
modTime time.Time
|
||||||
|
size int64
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
entryCache = make(map[string]cachedEntry)
|
||||||
|
entryCacheMu sync.Mutex
|
||||||
|
|
||||||
|
listingCache []*Entry
|
||||||
|
listingExpires time.Time
|
||||||
|
listingCacheMu sync.Mutex
|
||||||
|
)
|
||||||
|
|
||||||
|
const listingTTL = 5 * time.Second
|
||||||
|
|
||||||
|
func applicationDirs() []string {
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
var dirs []string
|
||||||
|
|
||||||
|
add := func(path string) {
|
||||||
|
if path == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
abs, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
abs = path
|
||||||
|
}
|
||||||
|
if seen[abs] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[abs] = true
|
||||||
|
dirs = append(dirs, abs)
|
||||||
|
}
|
||||||
|
|
||||||
|
add(filepath.Join(utils.XDGDataHome(), "applications"))
|
||||||
|
|
||||||
|
if env := os.Getenv("XDG_DATA_DIRS"); env != "" {
|
||||||
|
for d := range strings.SplitSeq(env, ":") {
|
||||||
|
add(filepath.Join(strings.TrimSpace(d), "applications"))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
add("/usr/local/share/applications")
|
||||||
|
add("/usr/share/applications")
|
||||||
|
}
|
||||||
|
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
if home != "" {
|
||||||
|
add(filepath.Join(home, ".local", "share", "flatpak", "exports", "share", "applications"))
|
||||||
|
}
|
||||||
|
add("/var/lib/flatpak/exports/share/applications")
|
||||||
|
add("/var/lib/snapd/desktop/applications")
|
||||||
|
|
||||||
|
return dirs
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseEntry(path string, id string) (*Entry, error) {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
entryCacheMu.Lock()
|
||||||
|
if c, ok := entryCache[path]; ok && c.modTime.Equal(info.ModTime()) && c.size == info.Size() {
|
||||||
|
entryCacheMu.Unlock()
|
||||||
|
return c.entry, nil
|
||||||
|
}
|
||||||
|
entryCacheMu.Unlock()
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
groups := parseGroups(data)
|
||||||
|
g, ok := groups["Desktop Entry"]
|
||||||
|
if !ok {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := &Entry{
|
||||||
|
ID: id,
|
||||||
|
Path: path,
|
||||||
|
Name: g.keys["Name"],
|
||||||
|
Exec: g.keys["Exec"],
|
||||||
|
Icon: g.keys["Icon"],
|
||||||
|
Categories: splitList(g.keys["Categories"]),
|
||||||
|
MimeTypes: splitList(g.keys["MimeType"]),
|
||||||
|
NoDisplay: parseBool(g.keys["NoDisplay"]),
|
||||||
|
Hidden: parseBool(g.keys["Hidden"]),
|
||||||
|
Terminal: parseBool(g.keys["Terminal"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
if t := g.keys["Type"]; t != "" && t != "Application" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
entryCacheMu.Lock()
|
||||||
|
entryCache[path] = cachedEntry{entry: entry, modTime: info.ModTime(), size: info.Size()}
|
||||||
|
entryCacheMu.Unlock()
|
||||||
|
|
||||||
|
return entry, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func relativeID(root, path string) string {
|
||||||
|
rel, err := filepath.Rel(root, path)
|
||||||
|
if err != nil {
|
||||||
|
return filepath.Base(path)
|
||||||
|
}
|
||||||
|
return strings.ReplaceAll(rel, string(filepath.Separator), "-")
|
||||||
|
}
|
||||||
|
|
||||||
|
func AllEntries() []*Entry {
|
||||||
|
listingCacheMu.Lock()
|
||||||
|
if time.Now().Before(listingExpires) && listingCache != nil {
|
||||||
|
out := listingCache
|
||||||
|
listingCacheMu.Unlock()
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
listingCacheMu.Unlock()
|
||||||
|
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
var entries []*Entry
|
||||||
|
|
||||||
|
for _, dir := range applicationDirs() {
|
||||||
|
_ = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil || d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(path, ".desktop") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
id := relativeID(dir, path)
|
||||||
|
if seen[id] {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seen[id] = true
|
||||||
|
|
||||||
|
entry, err := parseEntry(path, id)
|
||||||
|
if err != nil || entry == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
entries = append(entries, entry)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
listingCacheMu.Lock()
|
||||||
|
listingCache = entries
|
||||||
|
listingExpires = time.Now().Add(listingTTL)
|
||||||
|
listingCacheMu.Unlock()
|
||||||
|
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
func EntryByID(id string) *Entry {
|
||||||
|
if !strings.HasSuffix(id, ".desktop") {
|
||||||
|
id += ".desktop"
|
||||||
|
}
|
||||||
|
for _, entry := range AllEntries() {
|
||||||
|
if entry.ID == id {
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func InvalidateCache() {
|
||||||
|
entryCacheMu.Lock()
|
||||||
|
entryCache = make(map[string]cachedEntry)
|
||||||
|
entryCacheMu.Unlock()
|
||||||
|
|
||||||
|
listingCacheMu.Lock()
|
||||||
|
listingCache = nil
|
||||||
|
listingExpires = time.Time{}
|
||||||
|
listingCacheMu.Unlock()
|
||||||
|
}
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
package desktop
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
aliasMap map[string]string
|
||||||
|
subclassMap map[string][]string
|
||||||
|
aliasLoaded time.Time
|
||||||
|
aliasReloadMu sync.Mutex
|
||||||
|
|
||||||
|
mimeCacheMap map[string][]string
|
||||||
|
mimeCacheLoaded time.Time
|
||||||
|
mimeCacheReloadMu sync.Mutex
|
||||||
|
)
|
||||||
|
|
||||||
|
const aliasTTL = 60 * time.Second
|
||||||
|
const mimeCacheTTL = 10 * time.Second
|
||||||
|
|
||||||
|
func mimeDataDirs() []string {
|
||||||
|
var dirs []string
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
|
||||||
|
add := func(p string) {
|
||||||
|
if p == "" || seen[p] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[p] = true
|
||||||
|
dirs = append(dirs, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
add(filepath.Join(utils.XDGDataHome(), "mime"))
|
||||||
|
|
||||||
|
if env := os.Getenv("XDG_DATA_DIRS"); env != "" {
|
||||||
|
for d := range strings.SplitSeq(env, ":") {
|
||||||
|
add(filepath.Join(strings.TrimSpace(d), "mime"))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
add("/usr/local/share/mime")
|
||||||
|
add("/usr/share/mime")
|
||||||
|
}
|
||||||
|
|
||||||
|
return dirs
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadAliasTables() {
|
||||||
|
aliases := make(map[string]string)
|
||||||
|
subclasses := make(map[string][]string)
|
||||||
|
|
||||||
|
for _, dir := range mimeDataDirs() {
|
||||||
|
readKV(filepath.Join(dir, "aliases"), func(k, v string) {
|
||||||
|
if _, ok := aliases[k]; !ok {
|
||||||
|
aliases[k] = v
|
||||||
|
}
|
||||||
|
})
|
||||||
|
readKV(filepath.Join(dir, "subclasses"), func(k, v string) {
|
||||||
|
subclasses[k] = append(subclasses[k], v)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
aliasMap = aliases
|
||||||
|
subclassMap = subclasses
|
||||||
|
aliasLoaded = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
func readKV(path string, fn func(k, v string)) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scanner := bufio.NewScanner(bytes.NewReader(data))
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" || line[0] == '#' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sp := strings.IndexByte(line, ' ')
|
||||||
|
if sp <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fn(strings.TrimSpace(line[:sp]), strings.TrimSpace(line[sp+1:]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureAliasTables() {
|
||||||
|
aliasReloadMu.Lock()
|
||||||
|
defer aliasReloadMu.Unlock()
|
||||||
|
|
||||||
|
if aliasMap == nil || time.Since(aliasLoaded) > aliasTTL {
|
||||||
|
loadAliasTables()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadMimeCache() {
|
||||||
|
merged := make(map[string][]string)
|
||||||
|
seen := make(map[string]map[string]bool)
|
||||||
|
|
||||||
|
for _, dir := range applicationDirs() {
|
||||||
|
data, err := os.ReadFile(filepath.Join(dir, "mimeinfo.cache"))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
groups := parseGroups(data)
|
||||||
|
g := groups["MIME Cache"]
|
||||||
|
if g == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for mime, val := range g.keys {
|
||||||
|
ids := splitList(val)
|
||||||
|
if len(ids) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if seen[mime] == nil {
|
||||||
|
seen[mime] = make(map[string]bool)
|
||||||
|
}
|
||||||
|
for _, id := range ids {
|
||||||
|
if seen[mime][id] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[mime][id] = true
|
||||||
|
merged[mime] = append(merged[mime], id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mimeCacheMap = merged
|
||||||
|
mimeCacheLoaded = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureMimeCache() {
|
||||||
|
mimeCacheReloadMu.Lock()
|
||||||
|
defer mimeCacheReloadMu.Unlock()
|
||||||
|
|
||||||
|
if mimeCacheMap == nil || time.Since(mimeCacheLoaded) > mimeCacheTTL {
|
||||||
|
loadMimeCache()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cacheAppsForMime(mimeType string) []string {
|
||||||
|
ensureMimeCache()
|
||||||
|
return mimeCacheMap[mimeType]
|
||||||
|
}
|
||||||
|
|
||||||
|
func StripMimeParams(mimeType string) string {
|
||||||
|
if semi := strings.IndexByte(mimeType, ';'); semi >= 0 {
|
||||||
|
mimeType = mimeType[:semi]
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(mimeType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalMime(mimeType string) string {
|
||||||
|
ensureAliasTables()
|
||||||
|
mimeType = StripMimeParams(mimeType)
|
||||||
|
if target, ok := aliasMap[mimeType]; ok {
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
return mimeType
|
||||||
|
}
|
||||||
|
|
||||||
|
func mimeChain(mimeType string) []string {
|
||||||
|
ensureAliasTables()
|
||||||
|
|
||||||
|
root := canonicalMime(mimeType)
|
||||||
|
visited := map[string]bool{root: true}
|
||||||
|
chain := []string{root}
|
||||||
|
|
||||||
|
queue := []string{root}
|
||||||
|
for len(queue) > 0 {
|
||||||
|
cur := queue[0]
|
||||||
|
queue = queue[1:]
|
||||||
|
for _, parent := range subclassMap[cur] {
|
||||||
|
if visited[parent] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
visited[parent] = true
|
||||||
|
chain = append(chain, parent)
|
||||||
|
queue = append(queue, parent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return chain
|
||||||
|
}
|
||||||
|
|
||||||
|
func entrySupportsMime(entry *Entry, chain []string) bool {
|
||||||
|
for _, m := range entry.MimeTypes {
|
||||||
|
canonical := canonicalMime(m)
|
||||||
|
if slices.Contains(chain, canonical) || slices.Contains(chain, m) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetDefault(mimeType string) string {
|
||||||
|
merged := mergedAssociations()
|
||||||
|
chain := mimeChain(mimeType)
|
||||||
|
|
||||||
|
for _, m := range chain {
|
||||||
|
if id, ok := merged.Defaults[m]; ok {
|
||||||
|
if !slices.Contains(merged.Removed[m], id) {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range chain {
|
||||||
|
for _, id := range merged.Added[m] {
|
||||||
|
if !slices.Contains(merged.Removed[m], id) {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range chain {
|
||||||
|
for _, id := range cacheAppsForMime(m) {
|
||||||
|
if !slices.Contains(merged.Removed[m], id) {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range chain {
|
||||||
|
for _, entry := range AllEntries() {
|
||||||
|
if entry.Hidden || entry.NoDisplay {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if slices.Contains(merged.Removed[m], entry.ID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if entrySupportsMime(entry, []string{m}) {
|
||||||
|
return entry.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetDefault(mimeType, desktopID string) error {
|
||||||
|
return setDefaultAssociation(mimeType, desktopID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetDefaults(mimeTypes []string, desktopID string) error {
|
||||||
|
return setDefaultAssociations(mimeTypes, desktopID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func AppsForMime(mimeType string) []string {
|
||||||
|
merged := mergedAssociations()
|
||||||
|
chain := mimeChain(mimeType)
|
||||||
|
removed := make(map[string]bool)
|
||||||
|
for _, m := range chain {
|
||||||
|
for _, id := range merged.Removed[m] {
|
||||||
|
removed[id] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
var out []string
|
||||||
|
|
||||||
|
add := func(id string) {
|
||||||
|
if id == "" || removed[id] || seen[id] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[id] = true
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range chain {
|
||||||
|
if id := merged.Defaults[m]; id != "" {
|
||||||
|
add(id)
|
||||||
|
}
|
||||||
|
for _, id := range merged.Added[m] {
|
||||||
|
add(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range chain {
|
||||||
|
for _, id := range cacheAppsForMime(m) {
|
||||||
|
add(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range AllEntries() {
|
||||||
|
if entry.Hidden {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if entrySupportsMime(entry, chain) {
|
||||||
|
add(entry.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func QueryDefaults(mimeTypes []string) map[string]string {
|
||||||
|
out := make(map[string]string, len(mimeTypes))
|
||||||
|
for _, m := range mimeTypes {
|
||||||
|
out[m] = GetDefault(m)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
package desktop
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupFakeXDG(t *testing.T) (configHome, dataHome string) {
|
||||||
|
t.Helper()
|
||||||
|
tmp := t.TempDir()
|
||||||
|
configHome = filepath.Join(tmp, "config")
|
||||||
|
dataHome = filepath.Join(tmp, "data")
|
||||||
|
if err := os.MkdirAll(filepath.Join(dataHome, "applications"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(configHome, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", configHome)
|
||||||
|
t.Setenv("XDG_DATA_HOME", dataHome)
|
||||||
|
t.Setenv("XDG_DATA_DIRS", dataHome)
|
||||||
|
t.Setenv("XDG_CONFIG_DIRS", configHome)
|
||||||
|
InvalidateCache()
|
||||||
|
|
||||||
|
mimeCacheReloadMu.Lock()
|
||||||
|
mimeCacheMap = nil
|
||||||
|
mimeCacheReloadMu.Unlock()
|
||||||
|
aliasReloadMu.Lock()
|
||||||
|
aliasMap = nil
|
||||||
|
subclassMap = nil
|
||||||
|
aliasReloadMu.Unlock()
|
||||||
|
|
||||||
|
return configHome, dataHome
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFile(t *testing.T, path, content string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseDesktopEntry(t *testing.T) {
|
||||||
|
_, dataHome := setupFakeXDG(t)
|
||||||
|
writeFile(t, filepath.Join(dataHome, "applications", "test.desktop"), `[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=Test
|
||||||
|
Exec=test %f
|
||||||
|
Icon=test
|
||||||
|
MimeType=application/pdf;image/png;
|
||||||
|
Categories=Office;Viewer;
|
||||||
|
NoDisplay=false
|
||||||
|
`)
|
||||||
|
|
||||||
|
entry := EntryByID("test.desktop")
|
||||||
|
if entry == nil {
|
||||||
|
t.Fatal("entry not found")
|
||||||
|
}
|
||||||
|
if entry.Name != "Test" {
|
||||||
|
t.Errorf("Name = %q", entry.Name)
|
||||||
|
}
|
||||||
|
if len(entry.MimeTypes) != 2 || entry.MimeTypes[0] != "application/pdf" {
|
||||||
|
t.Errorf("MimeTypes = %v", entry.MimeTypes)
|
||||||
|
}
|
||||||
|
if len(entry.Categories) != 2 || entry.Categories[1] != "Viewer" {
|
||||||
|
t.Errorf("Categories = %v", entry.Categories)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetGetDefault(t *testing.T) {
|
||||||
|
configHome, dataHome := setupFakeXDG(t)
|
||||||
|
writeFile(t, filepath.Join(dataHome, "applications", "foo.desktop"), `[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=Foo
|
||||||
|
MimeType=application/pdf;
|
||||||
|
`)
|
||||||
|
writeFile(t, filepath.Join(dataHome, "applications", "bar.desktop"), `[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=Bar
|
||||||
|
MimeType=application/pdf;
|
||||||
|
`)
|
||||||
|
|
||||||
|
if err := SetDefault("application/pdf", "bar.desktop"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := GetDefault("application/pdf"); got != "bar.desktop" {
|
||||||
|
t.Errorf("GetDefault = %q want bar.desktop", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(filepath.Join(configHome, "mimeapps.list"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !contains(string(data), "application/pdf=bar.desktop") {
|
||||||
|
t.Errorf("mimeapps.list missing default:\n%s", string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetDefaultBypassesMimeSupportCheck(t *testing.T) {
|
||||||
|
configHome, dataHome := setupFakeXDG(t)
|
||||||
|
writeFile(t, filepath.Join(dataHome, "applications", "dms-open.desktop"), `[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=DMS
|
||||||
|
MimeType=x-scheme-handler/http;
|
||||||
|
`)
|
||||||
|
|
||||||
|
if err := SetDefault("application/pdf", "dms-open.desktop"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := GetDefault("application/pdf"); got != "dms-open.desktop" {
|
||||||
|
t.Errorf("GetDefault = %q, want dms-open.desktop (native impl must not enforce MimeType= check)", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := os.ReadFile(filepath.Join(configHome, "mimeapps.list"))
|
||||||
|
if !contains(string(data), "application/pdf=dms-open.desktop") {
|
||||||
|
t.Errorf("mimeapps.list missing override:\n%s", string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAliasResolution(t *testing.T) {
|
||||||
|
_, dataHome := setupFakeXDG(t)
|
||||||
|
writeFile(t, filepath.Join(dataHome, "mime", "aliases"), "text/javascript application/javascript\n")
|
||||||
|
writeFile(t, filepath.Join(dataHome, "applications", "editor.desktop"), `[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=Editor
|
||||||
|
MimeType=application/javascript;
|
||||||
|
`)
|
||||||
|
writeFile(t, filepath.Join(dataHome, "applications", "mimeinfo.cache"), `[MIME Cache]
|
||||||
|
application/javascript=editor.desktop;
|
||||||
|
`)
|
||||||
|
|
||||||
|
if got := GetDefault("text/javascript"); got != "editor.desktop" {
|
||||||
|
t.Errorf("GetDefault(text/javascript) = %q want editor.desktop (alias resolution)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetDefaultsBatch(t *testing.T) {
|
||||||
|
configHome, dataHome := setupFakeXDG(t)
|
||||||
|
writeFile(t, filepath.Join(dataHome, "applications", "dms-open.desktop"), `[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=DMS
|
||||||
|
MimeType=x-scheme-handler/http;
|
||||||
|
`)
|
||||||
|
|
||||||
|
mimes := []string{
|
||||||
|
"text/plain", "text/x-csrc", "text/x-python",
|
||||||
|
"text/x-shellscript", "application/json",
|
||||||
|
}
|
||||||
|
if err := SetDefaults(mimes, "dms-open.desktop"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(filepath.Join(configHome, "mimeapps.list"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, m := range mimes {
|
||||||
|
if !contains(string(data), m+"=dms-open.desktop") {
|
||||||
|
t.Errorf("missing %s default in mimeapps.list:\n%s", m, string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConcurrentSetDefaultNoCorruption(t *testing.T) {
|
||||||
|
configHome, dataHome := setupFakeXDG(t)
|
||||||
|
writeFile(t, filepath.Join(dataHome, "applications", "app.desktop"), `[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=App
|
||||||
|
`)
|
||||||
|
|
||||||
|
mimes := []string{
|
||||||
|
"a/1", "a/2", "a/3", "a/4", "a/5", "a/6", "a/7",
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for _, m := range mimes {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(m string) {
|
||||||
|
defer wg.Done()
|
||||||
|
if err := SetDefault(m, "app.desktop"); err != nil {
|
||||||
|
t.Errorf("SetDefault(%s) failed: %v", m, err)
|
||||||
|
}
|
||||||
|
}(m)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
data, err := os.ReadFile(filepath.Join(configHome, "mimeapps.list"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, m := range mimes {
|
||||||
|
if !contains(string(data), m+"=app.desktop") {
|
||||||
|
t.Errorf("lost write for %s — concurrent writes corrupted file:\n%s", m, string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMimeCacheOrdering(t *testing.T) {
|
||||||
|
_, dataHome := setupFakeXDG(t)
|
||||||
|
writeFile(t, filepath.Join(dataHome, "applications", "a.desktop"), `[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=A
|
||||||
|
MimeType=image/png;
|
||||||
|
`)
|
||||||
|
writeFile(t, filepath.Join(dataHome, "applications", "b.desktop"), `[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=B
|
||||||
|
MimeType=image/png;
|
||||||
|
`)
|
||||||
|
writeFile(t, filepath.Join(dataHome, "applications", "mimeinfo.cache"), `[MIME Cache]
|
||||||
|
image/png=b.desktop;a.desktop;
|
||||||
|
`)
|
||||||
|
|
||||||
|
if got := GetDefault("image/png"); got != "b.desktop" {
|
||||||
|
t.Errorf("GetDefault should follow mimeinfo.cache order: got %q want b.desktop", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(haystack, needle string) bool {
|
||||||
|
for i := 0; i+len(needle) <= len(haystack); i++ {
|
||||||
|
if haystack[i:i+len(needle)] == needle {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
package desktop
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
var mimeappsWriteMu sync.Mutex
|
||||||
|
|
||||||
|
const (
|
||||||
|
groupDefaults = "Default Applications"
|
||||||
|
groupAdded = "Added Associations"
|
||||||
|
groupRemoved = "Removed Associations"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MimeAssociations struct {
|
||||||
|
Defaults map[string]string
|
||||||
|
Added map[string][]string
|
||||||
|
Removed map[string][]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAssociations() *MimeAssociations {
|
||||||
|
return &MimeAssociations{
|
||||||
|
Defaults: make(map[string]string),
|
||||||
|
Added: make(map[string][]string),
|
||||||
|
Removed: make(map[string][]string),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mimeappsSearchPaths() []string {
|
||||||
|
var paths []string
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
|
||||||
|
add := func(p string) {
|
||||||
|
if p == "" || seen[p] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[p] = true
|
||||||
|
paths = append(paths, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
add(filepath.Join(utils.XDGConfigHome(), "mimeapps.list"))
|
||||||
|
|
||||||
|
if env := os.Getenv("XDG_CONFIG_DIRS"); env != "" {
|
||||||
|
for d := range strings.SplitSeq(env, ":") {
|
||||||
|
add(filepath.Join(strings.TrimSpace(d), "mimeapps.list"))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
add("/etc/xdg/mimeapps.list")
|
||||||
|
}
|
||||||
|
|
||||||
|
add(filepath.Join(utils.XDGDataHome(), "applications", "mimeapps.list"))
|
||||||
|
|
||||||
|
if env := os.Getenv("XDG_DATA_DIRS"); env != "" {
|
||||||
|
for d := range strings.SplitSeq(env, ":") {
|
||||||
|
add(filepath.Join(strings.TrimSpace(d), "applications", "mimeapps.list"))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
add("/usr/local/share/applications/mimeapps.list")
|
||||||
|
add("/usr/share/applications/mimeapps.list")
|
||||||
|
}
|
||||||
|
|
||||||
|
return paths
|
||||||
|
}
|
||||||
|
|
||||||
|
func mimeappsWritePath() string {
|
||||||
|
return filepath.Join(utils.XDGConfigHome(), "mimeapps.list")
|
||||||
|
}
|
||||||
|
|
||||||
|
func readAssociations(path string) (*MimeAssociations, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
groups := parseGroups(data)
|
||||||
|
assoc := newAssociations()
|
||||||
|
|
||||||
|
if g := groups[groupDefaults]; g != nil {
|
||||||
|
for mime, val := range g.keys {
|
||||||
|
parts := splitList(val)
|
||||||
|
if len(parts) > 0 {
|
||||||
|
assoc.Defaults[mime] = parts[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if g := groups[groupAdded]; g != nil {
|
||||||
|
for mime, val := range g.keys {
|
||||||
|
assoc.Added[mime] = splitList(val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if g := groups[groupRemoved]; g != nil {
|
||||||
|
for mime, val := range g.keys {
|
||||||
|
assoc.Removed[mime] = splitList(val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return assoc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergedAssociations() *MimeAssociations {
|
||||||
|
merged := newAssociations()
|
||||||
|
|
||||||
|
for _, path := range mimeappsSearchPaths() {
|
||||||
|
assoc, err := readAssociations(path)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for mime, app := range assoc.Defaults {
|
||||||
|
if _, ok := merged.Defaults[mime]; !ok {
|
||||||
|
merged.Defaults[mime] = app
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for mime, apps := range assoc.Added {
|
||||||
|
merged.Added[mime] = append(merged.Added[mime], apps...)
|
||||||
|
}
|
||||||
|
for mime, apps := range assoc.Removed {
|
||||||
|
merged.Removed[mime] = append(merged.Removed[mime], apps...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeUserMimeapps(update func(*MimeAssociations)) error {
|
||||||
|
mimeappsWriteMu.Lock()
|
||||||
|
defer mimeappsWriteMu.Unlock()
|
||||||
|
|
||||||
|
path := mimeappsWritePath()
|
||||||
|
|
||||||
|
assoc, err := readAssociations(path)
|
||||||
|
if err != nil {
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
assoc = newAssociations()
|
||||||
|
}
|
||||||
|
|
||||||
|
update(assoc)
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
w := bufio.NewWriter(&buf)
|
||||||
|
|
||||||
|
writeSection := func(name string, entries map[string]string) {
|
||||||
|
fmt.Fprintf(w, "[%s]\n", name)
|
||||||
|
keys := make([]string, 0, len(entries))
|
||||||
|
for k := range entries {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
for _, k := range keys {
|
||||||
|
fmt.Fprintf(w, "%s=%s\n", k, entries[k])
|
||||||
|
}
|
||||||
|
fmt.Fprintln(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
flatten := func(m map[string][]string) map[string]string {
|
||||||
|
out := make(map[string]string, len(m))
|
||||||
|
for k, list := range m {
|
||||||
|
out[k] = strings.Join(list, ";") + ";"
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
writeSection(groupDefaults, assoc.Defaults)
|
||||||
|
writeSection(groupAdded, flatten(assoc.Added))
|
||||||
|
writeSection(groupRemoved, flatten(assoc.Removed))
|
||||||
|
|
||||||
|
if err := w.Flush(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, buf.Bytes(), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setDefaultAssociation(mimeType, desktopID string) error {
|
||||||
|
return setDefaultAssociations([]string{mimeType}, desktopID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setDefaultAssociations(mimeTypes []string, desktopID string) error {
|
||||||
|
if !strings.HasSuffix(desktopID, ".desktop") {
|
||||||
|
desktopID += ".desktop"
|
||||||
|
}
|
||||||
|
return writeUserMimeapps(func(assoc *MimeAssociations) {
|
||||||
|
for _, mimeType := range mimeTypes {
|
||||||
|
if mimeType == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
assoc.Defaults[mimeType] = desktopID
|
||||||
|
existing := assoc.Added[mimeType]
|
||||||
|
if !slices.Contains(existing, desktopID) {
|
||||||
|
assoc.Added[mimeType] = append(existing, desktopID)
|
||||||
|
}
|
||||||
|
removed, ok := assoc.Removed[mimeType]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered := removed[:0]
|
||||||
|
for _, id := range removed {
|
||||||
|
if id != desktopID {
|
||||||
|
filtered = append(filtered, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case len(filtered) == 0:
|
||||||
|
delete(assoc.Removed, mimeType)
|
||||||
|
default:
|
||||||
|
assoc.Removed[mimeType] = filtered
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package desktop
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type group struct {
|
||||||
|
keys map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseGroups(data []byte) map[string]*group {
|
||||||
|
groups := make(map[string]*group)
|
||||||
|
var current *group
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(bytes.NewReader(data))
|
||||||
|
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" || line[0] == '#' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if line[0] == '[' && strings.HasSuffix(line, "]") {
|
||||||
|
name := line[1 : len(line)-1]
|
||||||
|
g, ok := groups[name]
|
||||||
|
if !ok {
|
||||||
|
g = &group{keys: make(map[string]string)}
|
||||||
|
groups[name] = g
|
||||||
|
}
|
||||||
|
current = g
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if current == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
eq := strings.IndexByte(line, '=')
|
||||||
|
if eq <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
key := strings.TrimSpace(line[:eq])
|
||||||
|
if bracket := strings.IndexByte(key, '['); bracket > 0 {
|
||||||
|
key = key[:bracket]
|
||||||
|
}
|
||||||
|
if _, ok := current.keys[key]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
current.keys[key] = strings.TrimSpace(line[eq+1:])
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitList(value string) []string {
|
||||||
|
if value == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(value, ";")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseBool(value string) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||||
|
case "true", "1", "yes":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -208,8 +208,7 @@ func (a *ArchDistribution) getQuickshellMapping(variant deps.PackageVariant) Pac
|
|||||||
if forceQuickshellGit || variant == deps.VariantGit {
|
if forceQuickshellGit || variant == deps.VariantGit {
|
||||||
return PackageMapping{Name: "quickshell-git", Repository: RepoTypeAUR}
|
return PackageMapping{Name: "quickshell-git", Repository: RepoTypeAUR}
|
||||||
}
|
}
|
||||||
// ! TODO - for now we're only forcing quickshell-git on ARCH, as other distros use DL repos which pin a newer quickshell
|
return PackageMapping{Name: "quickshell", Repository: RepoTypeSystem}
|
||||||
return PackageMapping{Name: "quickshell-git", Repository: RepoTypeAUR}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *ArchDistribution) getHyprlandMapping(_ deps.PackageVariant) PackageMapping {
|
func (a *ArchDistribution) getHyprlandMapping(_ deps.PackageVariant) PackageMapping {
|
||||||
@@ -332,6 +331,12 @@ func (a *ArchDistribution) InstallPackages(ctx context.Context, dependencies []d
|
|||||||
aurPkgs = slices.DeleteFunc(aurPkgs, func(p string) bool { return p == "quickshell-git" })
|
aurPkgs = slices.DeleteFunc(aurPkgs, func(p string) bool { return p == "quickshell-git" })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if slices.Contains(systemPkgs, "quickshell") && a.packageInstalled("quickshell-git") {
|
||||||
|
if err := a.removeQuickshellGit(ctx, sudoPassword, progressChan); err != nil {
|
||||||
|
return fmt.Errorf("failed to remove quickshell-git: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Phase 3: System Packages
|
// Phase 3: System Packages
|
||||||
if len(systemPkgs) > 0 {
|
if len(systemPkgs) > 0 {
|
||||||
progressChan <- InstallProgressMsg{
|
progressChan <- InstallProgressMsg{
|
||||||
@@ -449,6 +454,20 @@ func (a *ArchDistribution) categorizePackages(dependencies []deps.Dependency, wm
|
|||||||
return systemPkgs, aurPkgs, manualPkgs, variantMap
|
return systemPkgs, aurPkgs, manualPkgs, variantMap
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *ArchDistribution) removeQuickshellGit(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||||
|
progressChan <- InstallProgressMsg{
|
||||||
|
Phase: PhaseSystemPackages,
|
||||||
|
Progress: 0.33,
|
||||||
|
Step: "Removing quickshell-git...",
|
||||||
|
IsComplete: false,
|
||||||
|
NeedsSudo: true,
|
||||||
|
CommandInfo: "sudo pacman -Rdd --noconfirm quickshell-git",
|
||||||
|
LogOutput: "Removing quickshell-git so stable quickshell can be installed",
|
||||||
|
}
|
||||||
|
cmd := privesc.ExecCommand(ctx, sudoPassword, "pacman -Rdd --noconfirm quickshell-git")
|
||||||
|
return a.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.33, 0.35)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *ArchDistribution) preinstallQuickshellGit(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
func (a *ArchDistribution) preinstallQuickshellGit(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||||
if a.packageInstalled("quickshell-git") {
|
if a.packageInstalled("quickshell-git") {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ func (b *BaseDistribution) detectQuickshell() deps.Dependency {
|
|||||||
}
|
}
|
||||||
|
|
||||||
versionStr := string(output)
|
versionStr := string(output)
|
||||||
versionRegex := regexp.MustCompile(`quickshell (\d+\.\d+\.\d+)`)
|
versionRegex := regexp.MustCompile(`(?i)quickshell (\d+\.\d+\.\d+)`)
|
||||||
matches := versionRegex.FindStringSubmatch(versionStr)
|
matches := versionRegex.FindStringSubmatch(versionStr)
|
||||||
|
|
||||||
if len(matches) < 2 {
|
if len(matches) < 2 {
|
||||||
|
|||||||
@@ -116,6 +116,20 @@ func (g *GentooDistribution) detectXDGPortal() deps.Dependency {
|
|||||||
return g.detectPackage("xdg-desktop-portal-gtk", "Desktop integration portal for GTK", g.packageInstalled("sys-apps/xdg-desktop-portal-gtk"))
|
return g.detectPackage("xdg-desktop-portal-gtk", "Desktop integration portal for GTK", g.packageInstalled("sys-apps/xdg-desktop-portal-gtk"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (g *GentooDistribution) detectDMS() deps.Dependency {
|
||||||
|
dep := deps.Dependency{
|
||||||
|
Name: "dms (DankMaterialShell)",
|
||||||
|
Status: deps.StatusMissing,
|
||||||
|
Description: "Desktop Management System configuration",
|
||||||
|
Required: true,
|
||||||
|
CanToggle: false,
|
||||||
|
}
|
||||||
|
if g.packageInstalled("gui-apps/dankmaterialshell") {
|
||||||
|
dep.Status = deps.StatusInstalled
|
||||||
|
}
|
||||||
|
return dep
|
||||||
|
}
|
||||||
|
|
||||||
func (g *GentooDistribution) detectXwaylandSatellite() deps.Dependency {
|
func (g *GentooDistribution) detectXwaylandSatellite() deps.Dependency {
|
||||||
return g.detectPackage("xwayland-satellite", "Xwayland support", g.packageInstalled("gui-apps/xwayland-satellite"))
|
return g.detectPackage("xwayland-satellite", "Xwayland support", g.packageInstalled("gui-apps/xwayland-satellite"))
|
||||||
}
|
}
|
||||||
@@ -150,8 +164,8 @@ func (g *GentooDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
|
|||||||
|
|
||||||
"quickshell": g.getQuickshellMapping(variants["quickshell"]),
|
"quickshell": g.getQuickshellMapping(variants["quickshell"]),
|
||||||
"matugen": {Name: "x11-misc/matugen", Repository: RepoTypeGURU, AcceptKeywords: archKeyword},
|
"matugen": {Name: "x11-misc/matugen", Repository: RepoTypeGURU, AcceptKeywords: archKeyword},
|
||||||
"dms (DankMaterialShell)": g.getDmsMapping(variants["dms (DankMaterialShell)"]),
|
"dms (DankMaterialShell)": g.getDmsMapping(),
|
||||||
"dgop": {Name: "dgop", Repository: RepoTypeManual, BuildFunc: "installDgop"},
|
"dgop": {Name: "gui-apps/dgop", Repository: RepoTypeGURU, AcceptKeywords: archKeyword},
|
||||||
}
|
}
|
||||||
|
|
||||||
switch wm {
|
switch wm {
|
||||||
@@ -171,8 +185,8 @@ func (g *GentooDistribution) getQuickshellMapping(_ deps.PackageVariant) Package
|
|||||||
return PackageMapping{Name: "gui-apps/quickshell", Repository: RepoTypeGURU, UseFlags: "breakpad jemalloc sockets wayland layer-shell session-lock toplevel-management screencopy X pipewire tray mpris pam hyprland hyprland-global-shortcuts hyprland-focus-grab i3 i3-ipc bluetooth", AcceptKeywords: "**"}
|
return PackageMapping{Name: "gui-apps/quickshell", Repository: RepoTypeGURU, UseFlags: "breakpad jemalloc sockets wayland layer-shell session-lock toplevel-management screencopy X pipewire tray mpris pam hyprland hyprland-global-shortcuts hyprland-focus-grab i3 i3-ipc bluetooth", AcceptKeywords: "**"}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *GentooDistribution) getDmsMapping(_ deps.PackageVariant) PackageMapping {
|
func (g *GentooDistribution) getDmsMapping() PackageMapping {
|
||||||
return PackageMapping{Name: "dms", Repository: RepoTypeManual, BuildFunc: "installDankMaterialShell"}
|
return PackageMapping{Name: "gui-apps/dankmaterialshell", Repository: RepoTypeGURU, AcceptKeywords: g.getArchKeyword()}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *GentooDistribution) getHyprlandMapping(_ deps.PackageVariant) PackageMapping {
|
func (g *GentooDistribution) getHyprlandMapping(_ deps.PackageVariant) PackageMapping {
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ func GetOSInfo() (*OSInfo, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
key := parts[0]
|
key := parts[0]
|
||||||
value := strings.Trim(parts[1], "\"")
|
value := strings.Trim(parts[1], "\"'")
|
||||||
|
|
||||||
switch key {
|
switch key {
|
||||||
case "ID":
|
case "ID":
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
package log
|
package log
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
cblog "github.com/charmbracelet/log"
|
cblog "github.com/charmbracelet/log"
|
||||||
|
"github.com/mattn/go-isatty"
|
||||||
|
"github.com/muesli/termenv"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Logger embeds the Charm Logger and adds Printf/Fatalf
|
// Logger embeds the Charm Logger and adds Printf/Fatalf
|
||||||
@@ -21,8 +25,26 @@ func (l *Logger) Fatalf(format string, v ...any) { l.Logger.Fatalf(format, v...)
|
|||||||
var (
|
var (
|
||||||
logger *Logger
|
logger *Logger
|
||||||
initLogger sync.Once
|
initLogger sync.Once
|
||||||
|
|
||||||
|
logMu sync.Mutex
|
||||||
|
logFile *os.File
|
||||||
|
logStderr io.Writer = os.Stderr
|
||||||
|
|
||||||
|
ansiRe = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ansiStripWriter strips ANSI escape sequences before forwarding to w. Used
|
||||||
|
// for the file sink so colored stderr stays colored while the file stays plain.
|
||||||
|
type ansiStripWriter struct{ w io.Writer }
|
||||||
|
|
||||||
|
func (a *ansiStripWriter) Write(p []byte) (int, error) {
|
||||||
|
stripped := ansiRe.ReplaceAll(p, nil)
|
||||||
|
if _, err := a.w.Write(stripped); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
func parseLogLevel(level string) cblog.Level {
|
func parseLogLevel(level string) cblog.Level {
|
||||||
switch strings.ToLower(level) {
|
switch strings.ToLower(level) {
|
||||||
case "debug":
|
case "debug":
|
||||||
@@ -86,7 +108,7 @@ func GetLogger() *Logger {
|
|||||||
SetString(" DEBUG").
|
SetString(" DEBUG").
|
||||||
Foreground(lipgloss.Color("4"))
|
Foreground(lipgloss.Color("4"))
|
||||||
|
|
||||||
base := cblog.New(os.Stderr)
|
base := cblog.New(logStderr)
|
||||||
base.SetStyles(styles)
|
base.SetStyles(styles)
|
||||||
base.SetReportTimestamp(false)
|
base.SetReportTimestamp(false)
|
||||||
|
|
||||||
@@ -98,10 +120,85 @@ func GetLogger() *Logger {
|
|||||||
base.SetPrefix(" go")
|
base.SetPrefix(" go")
|
||||||
|
|
||||||
logger = &Logger{base}
|
logger = &Logger{base}
|
||||||
|
|
||||||
|
if path := os.Getenv("DMS_LOG_FILE"); path != "" {
|
||||||
|
_ = SetLogFile(path)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
return logger
|
return logger
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetLevel updates the active log level. Accepts the same strings as
|
||||||
|
// DMS_LOG_LEVEL. Unknown values default to info.
|
||||||
|
func SetLevel(level string) {
|
||||||
|
GetLogger().SetLevel(parseLogLevel(level))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLogFile makes the logger append to path in addition to stderr. Passing an
|
||||||
|
// empty string detaches the file sink. Atomic per-line writes (≤PIPE_BUF) on
|
||||||
|
// O_APPEND keep concurrent Go and QML writers from corrupting each other.
|
||||||
|
//
|
||||||
|
// Color handling: charmbracelet/log auto-detects color support from its
|
||||||
|
// io.Writer, and io.MultiWriter doesn't pass that through, so we force the ANSI
|
||||||
|
// profile when stderr is a TTY and route the file through ansiStripWriter so
|
||||||
|
// the file stays plain while stderr keeps its colors.
|
||||||
|
func SetLogFile(path string) error {
|
||||||
|
logMu.Lock()
|
||||||
|
defer logMu.Unlock()
|
||||||
|
|
||||||
|
if logFile != nil {
|
||||||
|
logFile.Close()
|
||||||
|
logFile = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
l := GetLogger()
|
||||||
|
if path == "" {
|
||||||
|
l.SetOutput(logStderr)
|
||||||
|
applyColorProfile(l, logStderr)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
logFile = f
|
||||||
|
out := io.MultiWriter(logStderr, &ansiStripWriter{w: f})
|
||||||
|
l.SetOutput(out)
|
||||||
|
applyColorProfile(l, logStderr)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyColorProfile forces the renderer's color profile to match what stderr
|
||||||
|
// would produce on its own, undoing the auto-downgrade triggered by wrapping
|
||||||
|
// stderr in a non-TTY writer (e.g. io.MultiWriter).
|
||||||
|
func applyColorProfile(l *Logger, stderr io.Writer) {
|
||||||
|
f, ok := stderr.(*os.File)
|
||||||
|
if !ok {
|
||||||
|
l.SetColorProfile(termenv.Ascii)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if isatty.IsTerminal(f.Fd()) {
|
||||||
|
l.SetColorProfile(termenv.ANSI)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l.SetColorProfile(termenv.Ascii)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyEnvOverrides re-reads DMS_LOG_LEVEL and DMS_LOG_FILE and reconfigures
|
||||||
|
// the singleton. Safe to call after CLI flags have rewritten the environment.
|
||||||
|
func ApplyEnvOverrides() {
|
||||||
|
GetLogger()
|
||||||
|
if level := os.Getenv("DMS_LOG_LEVEL"); level != "" {
|
||||||
|
SetLevel(level)
|
||||||
|
}
|
||||||
|
if path := os.Getenv("DMS_LOG_FILE"); path != "" {
|
||||||
|
if err := SetLogFile(path); err != nil {
|
||||||
|
Warnf("Failed to open log file %q: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// * Convenience wrappers
|
// * Convenience wrappers
|
||||||
|
|
||||||
func Debug(msg any, keyvals ...any) { GetLogger().Debug(msg, keyvals...) }
|
func Debug(msg any, keyvals ...any) { GetLogger().Debug(msg, keyvals...) }
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ var templateRegistry = []TemplateDef{
|
|||||||
{ID: "pywalfox", Commands: []string{"pywalfox"}, ConfigFile: "pywalfox.toml"},
|
{ID: "pywalfox", Commands: []string{"pywalfox"}, ConfigFile: "pywalfox.toml"},
|
||||||
{ID: "zenbrowser", Commands: []string{"zen", "zen-browser", "zen-beta", "zen-twilight"}, Flatpaks: []string{"app.zen_browser.zen"}, ConfigFile: "zenbrowser.toml"},
|
{ID: "zenbrowser", Commands: []string{"zen", "zen-browser", "zen-beta", "zen-twilight"}, Flatpaks: []string{"app.zen_browser.zen"}, ConfigFile: "zenbrowser.toml"},
|
||||||
{ID: "vesktop", Commands: []string{"vesktop"}, Flatpaks: []string{"dev.vencord.Vesktop"}, ConfigFile: "vesktop.toml"},
|
{ID: "vesktop", Commands: []string{"vesktop"}, Flatpaks: []string{"dev.vencord.Vesktop"}, ConfigFile: "vesktop.toml"},
|
||||||
|
{ID: "vencord", Commands: []string{"discord", "Discord", "discord-canary", "DiscordCanary"}, Flatpaks: []string{"com.discordapp.Discord", "com.discordapp.DiscordCanary"}, ConfigFile: "vencord.toml"},
|
||||||
{ID: "equibop", Commands: []string{"equibop"}, ConfigFile: "equibop.toml"},
|
{ID: "equibop", Commands: []string{"equibop"}, ConfigFile: "equibop.toml"},
|
||||||
{ID: "ghostty", Commands: []string{"ghostty"}, ConfigFile: "ghostty.toml", Kind: TemplateKindTerminal},
|
{ID: "ghostty", Commands: []string{"ghostty"}, ConfigFile: "ghostty.toml", Kind: TemplateKindTerminal},
|
||||||
{ID: "kitty", Commands: []string{"kitty"}, ConfigFile: "kitty.toml", Kind: TemplateKindTerminal},
|
{ID: "kitty", Commands: []string{"kitty"}, ConfigFile: "kitty.toml", Kind: TemplateKindTerminal},
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,422 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<protocol name="ext_workspace_v1">
|
|
||||||
<copyright>
|
|
||||||
Copyright © 2019 Christopher Billington
|
|
||||||
Copyright © 2020 Ilia Bozhinov
|
|
||||||
Copyright © 2022 Victoria Brekenfeld
|
|
||||||
|
|
||||||
Permission to use, copy, modify, distribute, and sell this
|
|
||||||
software and its documentation for any purpose is hereby granted
|
|
||||||
without fee, provided that the above copyright notice appear in
|
|
||||||
all copies and that both that copyright notice and this permission
|
|
||||||
notice appear in supporting documentation, and that the name of
|
|
||||||
the copyright holders not be used in advertising or publicity
|
|
||||||
pertaining to distribution of the software without specific,
|
|
||||||
written prior permission. The copyright holders make no
|
|
||||||
representations about the suitability of this software for any
|
|
||||||
purpose. It is provided "as is" without express or implied
|
|
||||||
warranty.
|
|
||||||
|
|
||||||
THE COPYRIGHT HOLDERS DISCLAIM ALL WARRANTIES WITH REGARD TO THIS
|
|
||||||
SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
|
|
||||||
FITNESS, IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
|
||||||
SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
||||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
|
|
||||||
AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
|
|
||||||
ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
|
|
||||||
THIS SOFTWARE.
|
|
||||||
</copyright>
|
|
||||||
|
|
||||||
<interface name="ext_workspace_manager_v1" version="1">
|
|
||||||
<description summary="list and control workspaces">
|
|
||||||
Workspaces, also called virtual desktops, are groups of surfaces. A
|
|
||||||
compositor with a concept of workspaces may only show some such groups of
|
|
||||||
surfaces (those of 'active' workspaces) at a time. 'Activating' a
|
|
||||||
workspace is a request for the compositor to display that workspace's
|
|
||||||
surfaces as normal, whereas the compositor may hide or otherwise
|
|
||||||
de-emphasise surfaces that are associated only with 'inactive' workspaces.
|
|
||||||
Workspaces are grouped by which sets of outputs they correspond to, and
|
|
||||||
may contain surfaces only from those outputs. In this way, it is possible
|
|
||||||
for each output to have its own set of workspaces, or for all outputs (or
|
|
||||||
any other arbitrary grouping) to share workspaces. Compositors may
|
|
||||||
optionally conceptually arrange each group of workspaces in an
|
|
||||||
N-dimensional grid.
|
|
||||||
|
|
||||||
The purpose of this protocol is to enable the creation of taskbars and
|
|
||||||
docks by providing them with a list of workspaces and their properties,
|
|
||||||
and allowing them to activate and deactivate workspaces.
|
|
||||||
|
|
||||||
After a client binds the ext_workspace_manager_v1, each workspace will be
|
|
||||||
sent via the workspace event.
|
|
||||||
</description>
|
|
||||||
|
|
||||||
<event name="workspace_group">
|
|
||||||
<description summary="a workspace group has been created">
|
|
||||||
This event is emitted whenever a new workspace group has been created.
|
|
||||||
|
|
||||||
All initial details of the workspace group (outputs) will be
|
|
||||||
sent immediately after this event via the corresponding events in
|
|
||||||
ext_workspace_group_handle_v1 and ext_workspace_handle_v1.
|
|
||||||
</description>
|
|
||||||
<arg name="workspace_group" type="new_id" interface="ext_workspace_group_handle_v1"/>
|
|
||||||
</event>
|
|
||||||
|
|
||||||
<event name="workspace">
|
|
||||||
<description summary="workspace has been created">
|
|
||||||
This event is emitted whenever a new workspace has been created.
|
|
||||||
|
|
||||||
All initial details of the workspace (name, coordinates, state) will
|
|
||||||
be sent immediately after this event via the corresponding events in
|
|
||||||
ext_workspace_handle_v1.
|
|
||||||
|
|
||||||
Workspaces start off unassigned to any workspace group.
|
|
||||||
</description>
|
|
||||||
<arg name="workspace" type="new_id" interface="ext_workspace_handle_v1"/>
|
|
||||||
</event>
|
|
||||||
|
|
||||||
<request name="commit">
|
|
||||||
<description summary="all requests about the workspaces have been sent">
|
|
||||||
The client must send this request after it has finished sending other
|
|
||||||
requests. The compositor must process a series of requests preceding a
|
|
||||||
commit request atomically.
|
|
||||||
|
|
||||||
This allows changes to the workspace properties to be seen as atomic,
|
|
||||||
even if they happen via multiple events, and even if they involve
|
|
||||||
multiple ext_workspace_handle_v1 objects, for example, deactivating one
|
|
||||||
workspace and activating another.
|
|
||||||
</description>
|
|
||||||
</request>
|
|
||||||
|
|
||||||
<event name="done">
|
|
||||||
<description summary="all information about the workspaces and workspace groups has been sent">
|
|
||||||
This event is sent after all changes in all workspaces and workspace groups have been
|
|
||||||
sent.
|
|
||||||
|
|
||||||
This allows changes to one or more ext_workspace_group_handle_v1
|
|
||||||
properties and ext_workspace_handle_v1 properties
|
|
||||||
to be seen as atomic, even if they happen via multiple events.
|
|
||||||
In particular, an output moving from one workspace group to
|
|
||||||
another sends an output_enter event and an output_leave event to the two
|
|
||||||
ext_workspace_group_handle_v1 objects in question. The compositor sends
|
|
||||||
the done event only after updating the output information in both
|
|
||||||
workspace groups.
|
|
||||||
</description>
|
|
||||||
</event>
|
|
||||||
|
|
||||||
<event name="finished" type="destructor">
|
|
||||||
<description summary="the compositor has finished with the workspace_manager">
|
|
||||||
This event indicates that the compositor is done sending events to the
|
|
||||||
ext_workspace_manager_v1. The server will destroy the object
|
|
||||||
immediately after sending this request.
|
|
||||||
</description>
|
|
||||||
</event>
|
|
||||||
|
|
||||||
<request name="stop">
|
|
||||||
<description summary="stop sending events">
|
|
||||||
Indicates the client no longer wishes to receive events for new
|
|
||||||
workspace groups. However the compositor may emit further workspace
|
|
||||||
events, until the finished event is emitted. The compositor is expected
|
|
||||||
to send the finished event eventually once the stop request has been processed.
|
|
||||||
|
|
||||||
The client must not send any requests after this one, doing so will raise a wl_display
|
|
||||||
invalid_object error.
|
|
||||||
</description>
|
|
||||||
</request>
|
|
||||||
|
|
||||||
</interface>
|
|
||||||
|
|
||||||
<interface name="ext_workspace_group_handle_v1" version="1">
|
|
||||||
<description summary="a workspace group assigned to a set of outputs">
|
|
||||||
A ext_workspace_group_handle_v1 object represents a workspace group
|
|
||||||
that is assigned a set of outputs and contains a number of workspaces.
|
|
||||||
|
|
||||||
The set of outputs assigned to the workspace group is conveyed to the client via
|
|
||||||
output_enter and output_leave events, and its workspaces are conveyed with
|
|
||||||
workspace events.
|
|
||||||
|
|
||||||
For example, a compositor which has a set of workspaces for each output may
|
|
||||||
advertise a workspace group (and its workspaces) per output, whereas a compositor
|
|
||||||
where a workspace spans all outputs may advertise a single workspace group for all
|
|
||||||
outputs.
|
|
||||||
</description>
|
|
||||||
|
|
||||||
<enum name="group_capabilities" bitfield="true">
|
|
||||||
<entry name="create_workspace" value="1" summary="create_workspace request is available"/>
|
|
||||||
</enum>
|
|
||||||
|
|
||||||
<event name="capabilities">
|
|
||||||
<description summary="compositor capabilities">
|
|
||||||
This event advertises the capabilities supported by the compositor. If
|
|
||||||
a capability isn't supported, clients should hide or disable the UI
|
|
||||||
elements that expose this functionality. For instance, if the
|
|
||||||
compositor doesn't advertise support for creating workspaces, a button
|
|
||||||
triggering the create_workspace request should not be displayed.
|
|
||||||
|
|
||||||
The compositor will ignore requests it doesn't support. For instance,
|
|
||||||
a compositor which doesn't advertise support for creating workspaces will ignore
|
|
||||||
create_workspace requests.
|
|
||||||
|
|
||||||
Compositors must send this event once after creation of an
|
|
||||||
ext_workspace_group_handle_v1. When the capabilities change, compositors
|
|
||||||
must send this event again.
|
|
||||||
</description>
|
|
||||||
<arg name="capabilities" type="uint" summary="capabilities" enum="group_capabilities"/>
|
|
||||||
</event>
|
|
||||||
|
|
||||||
<event name="output_enter">
|
|
||||||
<description summary="output assigned to workspace group">
|
|
||||||
This event is emitted whenever an output is assigned to the workspace
|
|
||||||
group or a new `wl_output` object is bound by the client, which was already
|
|
||||||
assigned to this workspace_group.
|
|
||||||
</description>
|
|
||||||
<arg name="output" type="object" interface="wl_output"/>
|
|
||||||
</event>
|
|
||||||
|
|
||||||
<event name="output_leave">
|
|
||||||
<description summary="output removed from workspace group">
|
|
||||||
This event is emitted whenever an output is removed from the workspace
|
|
||||||
group.
|
|
||||||
</description>
|
|
||||||
<arg name="output" type="object" interface="wl_output"/>
|
|
||||||
</event>
|
|
||||||
|
|
||||||
<event name="workspace_enter">
|
|
||||||
<description summary="workspace added to workspace group">
|
|
||||||
This event is emitted whenever a workspace is assigned to this group.
|
|
||||||
A workspace may only ever be assigned to a single group at a single point
|
|
||||||
in time, but can be re-assigned during it's lifetime.
|
|
||||||
</description>
|
|
||||||
<arg name="workspace" type="object" interface="ext_workspace_handle_v1"/>
|
|
||||||
</event>
|
|
||||||
|
|
||||||
<event name="workspace_leave">
|
|
||||||
<description summary="workspace removed from workspace group">
|
|
||||||
This event is emitted whenever a workspace is removed from this group.
|
|
||||||
</description>
|
|
||||||
<arg name="workspace" type="object" interface="ext_workspace_handle_v1"/>
|
|
||||||
</event>
|
|
||||||
|
|
||||||
<event name="removed">
|
|
||||||
<description summary="this workspace group has been removed">
|
|
||||||
This event is send when the group associated with the ext_workspace_group_handle_v1
|
|
||||||
has been removed. After sending this request the compositor will immediately consider
|
|
||||||
the object inert. Any requests will be ignored except the destroy request.
|
|
||||||
It is guaranteed there won't be any more events referencing this
|
|
||||||
ext_workspace_group_handle_v1.
|
|
||||||
|
|
||||||
The compositor must remove all workspaces belonging to a workspace group
|
|
||||||
via a workspace_leave event before removing the workspace group.
|
|
||||||
</description>
|
|
||||||
</event>
|
|
||||||
|
|
||||||
<request name="create_workspace">
|
|
||||||
<description summary="create a new workspace">
|
|
||||||
Request that the compositor create a new workspace with the given name
|
|
||||||
and assign it to this group.
|
|
||||||
|
|
||||||
There is no guarantee that the compositor will create a new workspace,
|
|
||||||
or that the created workspace will have the provided name.
|
|
||||||
</description>
|
|
||||||
<arg name="workspace" type="string"/>
|
|
||||||
</request>
|
|
||||||
|
|
||||||
<request name="destroy" type="destructor">
|
|
||||||
<description summary="destroy the ext_workspace_group_handle_v1 object">
|
|
||||||
Destroys the ext_workspace_group_handle_v1 object.
|
|
||||||
|
|
||||||
This request should be send either when the client does not want to
|
|
||||||
use the workspace group object any more or after the removed event to finalize
|
|
||||||
the destruction of the object.
|
|
||||||
</description>
|
|
||||||
</request>
|
|
||||||
</interface>
|
|
||||||
|
|
||||||
<interface name="ext_workspace_handle_v1" version="1">
|
|
||||||
<description summary="a workspace handing a group of surfaces">
|
|
||||||
A ext_workspace_handle_v1 object represents a workspace that handles a
|
|
||||||
group of surfaces.
|
|
||||||
|
|
||||||
Each workspace has:
|
|
||||||
- a name, conveyed to the client with the name event
|
|
||||||
- potentially an id conveyed with the id event
|
|
||||||
- a list of states, conveyed to the client with the state event
|
|
||||||
- and optionally a set of coordinates, conveyed to the client with the
|
|
||||||
coordinates event
|
|
||||||
|
|
||||||
The client may request that the compositor activate or deactivate the workspace.
|
|
||||||
|
|
||||||
Each workspace can belong to only a single workspace group.
|
|
||||||
Depending on the compositor policy, there might be workspaces with
|
|
||||||
the same name in different workspace groups, but these workspaces are still
|
|
||||||
separate (e.g. one of them might be active while the other is not).
|
|
||||||
</description>
|
|
||||||
|
|
||||||
<event name="id">
|
|
||||||
<description summary="workspace id">
|
|
||||||
If this event is emitted, it will be send immediately after the
|
|
||||||
ext_workspace_handle_v1 is created or when an id is assigned to
|
|
||||||
a workspace (at most once during it's lifetime).
|
|
||||||
|
|
||||||
An id will never change during the lifetime of the `ext_workspace_handle_v1`
|
|
||||||
and is guaranteed to be unique during it's lifetime.
|
|
||||||
|
|
||||||
Ids are not human-readable and shouldn't be displayed, use `name` for that purpose.
|
|
||||||
|
|
||||||
Compositors are expected to only send ids for workspaces likely stable across multiple
|
|
||||||
sessions and can be used by clients to store preferences for workspaces. Workspaces without
|
|
||||||
ids should be considered temporary and any data associated with them should be deleted once
|
|
||||||
the respective object is lost.
|
|
||||||
</description>
|
|
||||||
<arg name="id" type="string"/>
|
|
||||||
</event>
|
|
||||||
|
|
||||||
<event name="name">
|
|
||||||
<description summary="workspace name changed">
|
|
||||||
This event is emitted immediately after the ext_workspace_handle_v1 is
|
|
||||||
created and whenever the name of the workspace changes.
|
|
||||||
|
|
||||||
A name is meant to be human-readable and can be displayed to a user.
|
|
||||||
Unlike the id it is neither stable nor unique.
|
|
||||||
</description>
|
|
||||||
<arg name="name" type="string"/>
|
|
||||||
</event>
|
|
||||||
|
|
||||||
<event name="coordinates">
|
|
||||||
<description summary="workspace coordinates changed">
|
|
||||||
This event is used to organize workspaces into an N-dimensional grid
|
|
||||||
within a workspace group, and if supported, is emitted immediately after
|
|
||||||
the ext_workspace_handle_v1 is created and whenever the coordinates of
|
|
||||||
the workspace change. Compositors may not send this event if they do not
|
|
||||||
conceptually arrange workspaces in this way. If compositors simply
|
|
||||||
number workspaces, without any geometric interpretation, they may send
|
|
||||||
1D coordinates, which clients should not interpret as implying any
|
|
||||||
geometry. Sending an empty array means that the compositor no longer
|
|
||||||
orders the workspace geometrically.
|
|
||||||
|
|
||||||
Coordinates have an arbitrary number of dimensions N with an uint32
|
|
||||||
position along each dimension. By convention if N > 1, the first
|
|
||||||
dimension is X, the second Y, the third Z, and so on. The compositor may
|
|
||||||
chose to utilize these events for a more novel workspace layout
|
|
||||||
convention, however. No guarantee is made about the grid being filled or
|
|
||||||
bounded; there may be a workspace at coordinate 1 and another at
|
|
||||||
coordinate 1000 and none in between. Within a workspace group, however,
|
|
||||||
workspaces must have unique coordinates of equal dimensionality.
|
|
||||||
</description>
|
|
||||||
<arg name="coordinates" type="array"/>
|
|
||||||
</event>
|
|
||||||
|
|
||||||
<enum name="state" bitfield="true">
|
|
||||||
<description summary="types of states on the workspace">
|
|
||||||
The different states that a workspace can have.
|
|
||||||
</description>
|
|
||||||
|
|
||||||
<entry name="active" value="1" summary="the workspace is active"/>
|
|
||||||
<entry name="urgent" value="2" summary="the workspace requests attention"/>
|
|
||||||
<entry name="hidden" value="4">
|
|
||||||
<description summary="the workspace is not visible">
|
|
||||||
The workspace is not visible in its workspace group, and clients
|
|
||||||
attempting to visualize the compositor workspace state should not
|
|
||||||
display such workspaces.
|
|
||||||
</description>
|
|
||||||
</entry>
|
|
||||||
</enum>
|
|
||||||
|
|
||||||
<event name="state">
|
|
||||||
<description summary="the state of the workspace changed">
|
|
||||||
This event is emitted immediately after the ext_workspace_handle_v1 is
|
|
||||||
created and each time the workspace state changes, either because of a
|
|
||||||
compositor action or because of a request in this protocol.
|
|
||||||
|
|
||||||
Missing states convey the opposite meaning, e.g. an unset active bit
|
|
||||||
means the workspace is currently inactive.
|
|
||||||
</description>
|
|
||||||
<arg name="state" type="uint" enum="state"/>
|
|
||||||
</event>
|
|
||||||
|
|
||||||
<enum name="workspace_capabilities" bitfield="true">
|
|
||||||
<entry name="activate" value="1" summary="activate request is available"/>
|
|
||||||
<entry name="deactivate" value="2" summary="deactivate request is available"/>
|
|
||||||
<entry name="remove" value="4" summary="remove request is available"/>
|
|
||||||
<entry name="assign" value="8" summary="assign request is available"/>
|
|
||||||
</enum>
|
|
||||||
|
|
||||||
<event name="capabilities">
|
|
||||||
<description summary="compositor capabilities">
|
|
||||||
This event advertises the capabilities supported by the compositor. If
|
|
||||||
a capability isn't supported, clients should hide or disable the UI
|
|
||||||
elements that expose this functionality. For instance, if the
|
|
||||||
compositor doesn't advertise support for removing workspaces, a button
|
|
||||||
triggering the remove request should not be displayed.
|
|
||||||
|
|
||||||
The compositor will ignore requests it doesn't support. For instance,
|
|
||||||
a compositor which doesn't advertise support for remove will ignore
|
|
||||||
remove requests.
|
|
||||||
|
|
||||||
Compositors must send this event once after creation of an
|
|
||||||
ext_workspace_handle_v1 . When the capabilities change, compositors
|
|
||||||
must send this event again.
|
|
||||||
</description>
|
|
||||||
<arg name="capabilities" type="uint" summary="capabilities" enum="workspace_capabilities"/>
|
|
||||||
</event>
|
|
||||||
|
|
||||||
<event name="removed">
|
|
||||||
<description summary="this workspace has been removed">
|
|
||||||
This event is send when the workspace associated with the ext_workspace_handle_v1
|
|
||||||
has been removed. After sending this request, the compositor will immediately consider
|
|
||||||
the object inert. Any requests will be ignored except the destroy request.
|
|
||||||
|
|
||||||
It is guaranteed there won't be any more events referencing this
|
|
||||||
ext_workspace_handle_v1.
|
|
||||||
|
|
||||||
The compositor must only remove a workspaces not currently belonging to any
|
|
||||||
workspace_group.
|
|
||||||
</description>
|
|
||||||
</event>
|
|
||||||
|
|
||||||
<request name="destroy" type="destructor">
|
|
||||||
<description summary="destroy the ext_workspace_handle_v1 object">
|
|
||||||
Destroys the ext_workspace_handle_v1 object.
|
|
||||||
|
|
||||||
This request should be made either when the client does not want to
|
|
||||||
use the workspace object any more or after the remove event to finalize
|
|
||||||
the destruction of the object.
|
|
||||||
</description>
|
|
||||||
</request>
|
|
||||||
|
|
||||||
<request name="activate">
|
|
||||||
<description summary="activate the workspace">
|
|
||||||
Request that this workspace be activated.
|
|
||||||
|
|
||||||
There is no guarantee the workspace will be actually activated, and
|
|
||||||
behaviour may be compositor-dependent. For example, activating a
|
|
||||||
workspace may or may not deactivate all other workspaces in the same
|
|
||||||
group.
|
|
||||||
</description>
|
|
||||||
</request>
|
|
||||||
|
|
||||||
<request name="deactivate">
|
|
||||||
<description summary="deactivate the workspace">
|
|
||||||
Request that this workspace be deactivated.
|
|
||||||
|
|
||||||
There is no guarantee the workspace will be actually deactivated.
|
|
||||||
</description>
|
|
||||||
</request>
|
|
||||||
|
|
||||||
<request name="assign">
|
|
||||||
<description summary="assign workspace to group">
|
|
||||||
Requests that this workspace is assigned to the given workspace group.
|
|
||||||
|
|
||||||
There is no guarantee the workspace will be assigned.
|
|
||||||
</description>
|
|
||||||
<arg name="workspace_group" type="object" interface="ext_workspace_group_handle_v1"/>
|
|
||||||
</request>
|
|
||||||
|
|
||||||
<request name="remove">
|
|
||||||
<description summary="remove the workspace">
|
|
||||||
Request that this workspace be removed.
|
|
||||||
|
|
||||||
There is no guarantee the workspace will be actually removed.
|
|
||||||
</description>
|
|
||||||
</request>
|
|
||||||
</interface>
|
|
||||||
</protocol>
|
|
||||||
@@ -9,10 +9,8 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -105,7 +103,7 @@ func GetOutputDir() string {
|
|||||||
return dir
|
return dir
|
||||||
}
|
}
|
||||||
|
|
||||||
if xdgPics := getXDGPicturesDir(); xdgPics != "" {
|
if xdgPics := utils.XDGPicturesDir(); xdgPics != "" {
|
||||||
screenshotDir := filepath.Join(xdgPics, "Screenshots")
|
screenshotDir := filepath.Join(xdgPics, "Screenshots")
|
||||||
if err := os.MkdirAll(screenshotDir, 0o755); err == nil {
|
if err := os.MkdirAll(screenshotDir, 0o755); err == nil {
|
||||||
return screenshotDir
|
return screenshotDir
|
||||||
@@ -113,42 +111,12 @@ func GetOutputDir() string {
|
|||||||
return xdgPics
|
return xdgPics
|
||||||
}
|
}
|
||||||
|
|
||||||
if home := os.Getenv("HOME"); home != "" {
|
if home, err := os.UserHomeDir(); err == nil {
|
||||||
return home
|
return home
|
||||||
}
|
}
|
||||||
return "."
|
return "."
|
||||||
}
|
}
|
||||||
|
|
||||||
func getXDGPicturesDir() string {
|
|
||||||
userConfigDir, err := os.UserConfigDir()
|
|
||||||
if err != nil {
|
|
||||||
log.Error("failed to get user config dir", "err", err)
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
userDirsFile := filepath.Join(userConfigDir, "user-dirs.dirs")
|
|
||||||
data, err := os.ReadFile(userDirsFile)
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, line := range strings.Split(string(data), "\n") {
|
|
||||||
if len(line) == 0 || line[0] == '#' {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const prefix = "XDG_PICTURES_DIR="
|
|
||||||
if !strings.HasPrefix(line, prefix) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
path := strings.Trim(line[len(prefix):], "\"")
|
|
||||||
expanded, err := utils.ExpandPath(path)
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return expanded
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func WriteToFile(buf *ShmBuffer, path string, format Format, quality int) error {
|
func WriteToFile(buf *ShmBuffer, path string, format Format, quality int) error {
|
||||||
return WriteToFileWithFormat(buf, path, format, quality, uint32(FormatARGB8888))
|
return WriteToFileWithFormat(buf, path, format, quality, uint32(FormatARGB8888))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,12 +64,13 @@ func SendNotification(result NotifyResult) {
|
|||||||
|
|
||||||
summary := "Screenshot captured"
|
summary := "Screenshot captured"
|
||||||
body := ""
|
body := ""
|
||||||
if result.Clipboard && result.FilePath != "" {
|
switch {
|
||||||
body = fmt.Sprintf("Copied to clipboard\n%s", filepath.Base(result.FilePath))
|
case result.FilePath != "" && result.Clipboard:
|
||||||
} else if result.Clipboard {
|
body = fmt.Sprintf("%s\nCopied to clipboard", filepath.Base(result.FilePath))
|
||||||
body = "Copied to clipboard"
|
case result.FilePath != "":
|
||||||
} else if result.FilePath != "" {
|
|
||||||
body = filepath.Base(result.FilePath)
|
body = filepath.Base(result.FilePath)
|
||||||
|
case result.Clipboard:
|
||||||
|
body = "Copied to clipboard"
|
||||||
}
|
}
|
||||||
|
|
||||||
obj := conn.Object(notifyDest, notifyPath)
|
obj := conn.Object(notifyDest, notifyPath)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package apppicker
|
|||||||
import (
|
import (
|
||||||
"net"
|
"net"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/desktop"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||||
)
|
)
|
||||||
@@ -32,7 +33,7 @@ func handleOpen(conn net.Conn, req models.Request, manager *Manager) {
|
|||||||
event := OpenEvent{
|
event := OpenEvent{
|
||||||
Target: target,
|
Target: target,
|
||||||
RequestType: models.GetOr(req, "requestType", "url"),
|
RequestType: models.GetOr(req, "requestType", "url"),
|
||||||
MimeType: models.GetOr(req, "mimeType", ""),
|
MimeType: desktop.StripMimeParams(models.GetOr(req, "mimeType", "")),
|
||||||
}
|
}
|
||||||
|
|
||||||
if categories, ok := models.Get[[]any](req, "categories"); ok {
|
if categories, ok := models.Get[[]any](req, "categories"); ok {
|
||||||
|
|||||||
@@ -391,7 +391,7 @@ func (m *Manager) Close() {
|
|||||||
|
|
||||||
func InitializeManager() (*Manager, error) {
|
func InitializeManager() (*Manager, error) {
|
||||||
if os.Getuid() != 0 && !hasInputGroupAccess() {
|
if os.Getuid() != 0 && !hasInputGroupAccess() {
|
||||||
return nil, fmt.Errorf("insufficient permissions to access input devices")
|
return nil, fmt.Errorf("insufficient permissions to access input devices. Add your user to the 'input' group: `sudo usermod -a -G input $USER` or run `dms setup`")
|
||||||
}
|
}
|
||||||
|
|
||||||
return NewManager()
|
return NewManager()
|
||||||
|
|||||||
@@ -1,134 +0,0 @@
|
|||||||
package extworkspace
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
type SuccessResult struct {
|
|
||||||
Success bool `json:"success"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
|
|
||||||
if manager == nil {
|
|
||||||
models.RespondError(conn, req.ID, "extworkspace manager not initialized")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
switch req.Method {
|
|
||||||
case "extworkspace.getState":
|
|
||||||
handleGetState(conn, req, manager)
|
|
||||||
case "extworkspace.activateWorkspace":
|
|
||||||
handleActivateWorkspace(conn, req, manager)
|
|
||||||
case "extworkspace.deactivateWorkspace":
|
|
||||||
handleDeactivateWorkspace(conn, req, manager)
|
|
||||||
case "extworkspace.removeWorkspace":
|
|
||||||
handleRemoveWorkspace(conn, req, manager)
|
|
||||||
case "extworkspace.createWorkspace":
|
|
||||||
handleCreateWorkspace(conn, req, manager)
|
|
||||||
case "extworkspace.subscribe":
|
|
||||||
handleSubscribe(conn, req, manager)
|
|
||||||
default:
|
|
||||||
models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleGetState(conn net.Conn, req models.Request, manager *Manager) {
|
|
||||||
state := manager.GetState()
|
|
||||||
models.Respond(conn, req.ID, state)
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleActivateWorkspace(conn net.Conn, req models.Request, manager *Manager) {
|
|
||||||
groupID := models.GetOr(req, "groupID", "")
|
|
||||||
workspaceID, ok := models.Get[string](req, "workspaceID")
|
|
||||||
if !ok {
|
|
||||||
models.RespondError(conn, req.ID, "missing or invalid 'workspaceID' parameter")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := manager.ActivateWorkspace(groupID, workspaceID); err != nil {
|
|
||||||
models.RespondError(conn, req.ID, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "workspace activated"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleDeactivateWorkspace(conn net.Conn, req models.Request, manager *Manager) {
|
|
||||||
groupID := models.GetOr(req, "groupID", "")
|
|
||||||
workspaceID, ok := models.Get[string](req, "workspaceID")
|
|
||||||
if !ok {
|
|
||||||
models.RespondError(conn, req.ID, "missing or invalid 'workspaceID' parameter")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := manager.DeactivateWorkspace(groupID, workspaceID); err != nil {
|
|
||||||
models.RespondError(conn, req.ID, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "workspace deactivated"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleRemoveWorkspace(conn net.Conn, req models.Request, manager *Manager) {
|
|
||||||
groupID := models.GetOr(req, "groupID", "")
|
|
||||||
workspaceID, ok := models.Get[string](req, "workspaceID")
|
|
||||||
if !ok {
|
|
||||||
models.RespondError(conn, req.ID, "missing or invalid 'workspaceID' parameter")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := manager.RemoveWorkspace(groupID, workspaceID); err != nil {
|
|
||||||
models.RespondError(conn, req.ID, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "workspace removed"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleCreateWorkspace(conn net.Conn, req models.Request, manager *Manager) {
|
|
||||||
groupID, ok := models.Get[string](req, "groupID")
|
|
||||||
if !ok {
|
|
||||||
models.RespondError(conn, req.ID, "missing or invalid 'groupID' parameter")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
workspaceName, ok := models.Get[string](req, "name")
|
|
||||||
if !ok {
|
|
||||||
models.RespondError(conn, req.ID, "missing or invalid 'name' parameter")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := manager.CreateWorkspace(groupID, workspaceName); err != nil {
|
|
||||||
models.RespondError(conn, req.ID, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "workspace create requested"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleSubscribe(conn net.Conn, req models.Request, manager *Manager) {
|
|
||||||
clientID := fmt.Sprintf("client-%p", conn)
|
|
||||||
stateChan := manager.Subscribe(clientID)
|
|
||||||
defer manager.Unsubscribe(clientID)
|
|
||||||
|
|
||||||
initialState := manager.GetState()
|
|
||||||
if err := json.NewEncoder(conn).Encode(models.Response[State]{
|
|
||||||
ID: req.ID,
|
|
||||||
Result: &initialState,
|
|
||||||
}); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for state := range stateChan {
|
|
||||||
if err := json.NewEncoder(conn).Encode(models.Response[State]{
|
|
||||||
Result: &state,
|
|
||||||
}); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,598 +0,0 @@
|
|||||||
package extworkspace
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_workspace"
|
|
||||||
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
|
||||||
)
|
|
||||||
|
|
||||||
func CheckCapability() bool {
|
|
||||||
display, err := wlclient.Connect("")
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
defer display.Destroy()
|
|
||||||
|
|
||||||
registry, err := display.GetRegistry()
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
defer registry.Destroy()
|
|
||||||
|
|
||||||
found := false
|
|
||||||
|
|
||||||
registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
|
|
||||||
if e.Interface == ext_workspace.ExtWorkspaceManagerV1InterfaceName {
|
|
||||||
found = true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Roundtrip to ensure all registry events are processed
|
|
||||||
if err := display.Roundtrip(); err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return found
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewManager(display wlclient.WaylandDisplay) (*Manager, error) {
|
|
||||||
m := &Manager{
|
|
||||||
display: display,
|
|
||||||
ctx: display.Context(),
|
|
||||||
cmdq: make(chan cmd, 128),
|
|
||||||
stopChan: make(chan struct{}),
|
|
||||||
|
|
||||||
dirty: make(chan struct{}, 1),
|
|
||||||
}
|
|
||||||
|
|
||||||
m.wg.Add(1)
|
|
||||||
go m.waylandActor()
|
|
||||||
|
|
||||||
if err := m.setupRegistry(); err != nil {
|
|
||||||
close(m.stopChan)
|
|
||||||
m.wg.Wait()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
m.updateState()
|
|
||||||
|
|
||||||
m.notifierWg.Add(1)
|
|
||||||
go m.notifier()
|
|
||||||
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) post(fn func()) {
|
|
||||||
select {
|
|
||||||
case m.cmdq <- cmd{fn: fn}:
|
|
||||||
default:
|
|
||||||
log.Warn("ExtWorkspace actor command queue full, dropping command")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) waylandActor() {
|
|
||||||
defer m.wg.Done()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-m.stopChan:
|
|
||||||
return
|
|
||||||
case c := <-m.cmdq:
|
|
||||||
c.fn()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) setupRegistry() error {
|
|
||||||
log.Info("ExtWorkspace: starting registry setup")
|
|
||||||
|
|
||||||
registry, err := m.display.GetRegistry()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get registry: %w", err)
|
|
||||||
}
|
|
||||||
m.registry = registry
|
|
||||||
|
|
||||||
registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
|
|
||||||
if e.Interface == "wl_output" {
|
|
||||||
output := wlclient.NewOutput(m.ctx)
|
|
||||||
if err := registry.Bind(e.Name, e.Interface, 4, output); err == nil {
|
|
||||||
outputID := output.ID()
|
|
||||||
|
|
||||||
output.SetNameHandler(func(ev wlclient.OutputNameEvent) {
|
|
||||||
m.outputNames.Store(outputID, ev.Name)
|
|
||||||
log.Debugf("ExtWorkspace: Output %d (%s) name received", outputID, ev.Name)
|
|
||||||
m.post(func() {
|
|
||||||
m.updateState()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if e.Interface == ext_workspace.ExtWorkspaceManagerV1InterfaceName {
|
|
||||||
log.Infof("ExtWorkspace: found %s", ext_workspace.ExtWorkspaceManagerV1InterfaceName)
|
|
||||||
manager := ext_workspace.NewExtWorkspaceManagerV1(m.ctx)
|
|
||||||
version := e.Version
|
|
||||||
if version > 1 {
|
|
||||||
version = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
manager.SetWorkspaceGroupHandler(func(e ext_workspace.ExtWorkspaceManagerV1WorkspaceGroupEvent) {
|
|
||||||
m.handleWorkspaceGroup(e)
|
|
||||||
})
|
|
||||||
|
|
||||||
manager.SetWorkspaceHandler(func(e ext_workspace.ExtWorkspaceManagerV1WorkspaceEvent) {
|
|
||||||
m.handleWorkspace(e)
|
|
||||||
})
|
|
||||||
|
|
||||||
manager.SetDoneHandler(func(e ext_workspace.ExtWorkspaceManagerV1DoneEvent) {
|
|
||||||
log.Debug("ExtWorkspace: done event received")
|
|
||||||
m.post(func() {
|
|
||||||
m.updateState()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
manager.SetFinishedHandler(func(e ext_workspace.ExtWorkspaceManagerV1FinishedEvent) {
|
|
||||||
log.Info("ExtWorkspace: finished event received")
|
|
||||||
})
|
|
||||||
|
|
||||||
if err := registry.Bind(e.Name, e.Interface, version, manager); err == nil {
|
|
||||||
m.manager = manager
|
|
||||||
log.Info("ExtWorkspace: manager bound successfully")
|
|
||||||
} else {
|
|
||||||
log.Errorf("ExtWorkspace: failed to bind manager: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
log.Info("ExtWorkspace: registry setup complete (events will be processed async)")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) handleWorkspaceGroup(e ext_workspace.ExtWorkspaceManagerV1WorkspaceGroupEvent) {
|
|
||||||
handle := e.WorkspaceGroup
|
|
||||||
groupID := handle.ID()
|
|
||||||
|
|
||||||
log.Debugf("ExtWorkspace: New workspace group (id=%d)", groupID)
|
|
||||||
|
|
||||||
group := &workspaceGroupState{
|
|
||||||
id: groupID,
|
|
||||||
handle: handle,
|
|
||||||
outputIDs: make(map[uint32]bool),
|
|
||||||
workspaceIDs: make([]uint32, 0),
|
|
||||||
}
|
|
||||||
|
|
||||||
m.groups.Store(groupID, group)
|
|
||||||
|
|
||||||
handle.SetCapabilitiesHandler(func(e ext_workspace.ExtWorkspaceGroupHandleV1CapabilitiesEvent) {
|
|
||||||
log.Debugf("ExtWorkspace: Group %d capabilities: %d", groupID, e.Capabilities)
|
|
||||||
})
|
|
||||||
|
|
||||||
handle.SetOutputEnterHandler(func(e ext_workspace.ExtWorkspaceGroupHandleV1OutputEnterEvent) {
|
|
||||||
outputID := e.Output.ID()
|
|
||||||
log.Debugf("ExtWorkspace: Group %d output enter (output=%d)", groupID, outputID)
|
|
||||||
|
|
||||||
m.post(func() {
|
|
||||||
group.outputIDs[outputID] = true
|
|
||||||
m.updateState()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
handle.SetOutputLeaveHandler(func(e ext_workspace.ExtWorkspaceGroupHandleV1OutputLeaveEvent) {
|
|
||||||
outputID := e.Output.ID()
|
|
||||||
log.Debugf("ExtWorkspace: Group %d output leave (output=%d)", groupID, outputID)
|
|
||||||
m.post(func() {
|
|
||||||
delete(group.outputIDs, outputID)
|
|
||||||
m.updateState()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
handle.SetWorkspaceEnterHandler(func(e ext_workspace.ExtWorkspaceGroupHandleV1WorkspaceEnterEvent) {
|
|
||||||
workspaceID := e.Workspace.ID()
|
|
||||||
log.Debugf("ExtWorkspace: Group %d workspace enter (workspace=%d)", groupID, workspaceID)
|
|
||||||
|
|
||||||
m.post(func() {
|
|
||||||
if ws, ok := m.workspaces.Load(workspaceID); ok {
|
|
||||||
ws.groupID = groupID
|
|
||||||
}
|
|
||||||
|
|
||||||
group.workspaceIDs = append(group.workspaceIDs, workspaceID)
|
|
||||||
m.updateState()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
handle.SetWorkspaceLeaveHandler(func(e ext_workspace.ExtWorkspaceGroupHandleV1WorkspaceLeaveEvent) {
|
|
||||||
workspaceID := e.Workspace.ID()
|
|
||||||
log.Debugf("ExtWorkspace: Group %d workspace leave (workspace=%d)", groupID, workspaceID)
|
|
||||||
|
|
||||||
m.post(func() {
|
|
||||||
if ws, ok := m.workspaces.Load(workspaceID); ok {
|
|
||||||
ws.groupID = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
for i, id := range group.workspaceIDs {
|
|
||||||
if id == workspaceID {
|
|
||||||
group.workspaceIDs = append(group.workspaceIDs[:i], group.workspaceIDs[i+1:]...)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
m.updateState()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
handle.SetRemovedHandler(func(e ext_workspace.ExtWorkspaceGroupHandleV1RemovedEvent) {
|
|
||||||
log.Debugf("ExtWorkspace: Group %d removed", groupID)
|
|
||||||
|
|
||||||
m.post(func() {
|
|
||||||
group.removed = true
|
|
||||||
|
|
||||||
m.groups.Delete(groupID)
|
|
||||||
|
|
||||||
m.wlMutex.Lock()
|
|
||||||
handle.Destroy()
|
|
||||||
m.wlMutex.Unlock()
|
|
||||||
|
|
||||||
m.updateState()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) handleWorkspace(e ext_workspace.ExtWorkspaceManagerV1WorkspaceEvent) {
|
|
||||||
handle := e.Workspace
|
|
||||||
workspaceID := handle.ID()
|
|
||||||
|
|
||||||
log.Debugf("ExtWorkspace: New workspace (proxy_id=%d)", workspaceID)
|
|
||||||
|
|
||||||
ws := &workspaceState{
|
|
||||||
id: workspaceID,
|
|
||||||
handle: handle,
|
|
||||||
coordinates: make([]uint32, 0),
|
|
||||||
}
|
|
||||||
|
|
||||||
m.workspaces.Store(workspaceID, ws)
|
|
||||||
|
|
||||||
handle.SetIdHandler(func(e ext_workspace.ExtWorkspaceHandleV1IdEvent) {
|
|
||||||
log.Debugf("ExtWorkspace: Workspace %d id: %s", workspaceID, e.Id)
|
|
||||||
m.post(func() {
|
|
||||||
ws.workspaceID = e.Id
|
|
||||||
m.updateState()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
handle.SetNameHandler(func(e ext_workspace.ExtWorkspaceHandleV1NameEvent) {
|
|
||||||
log.Debugf("ExtWorkspace: Workspace %d name: %s", workspaceID, e.Name)
|
|
||||||
m.post(func() {
|
|
||||||
ws.name = e.Name
|
|
||||||
m.updateState()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
handle.SetCoordinatesHandler(func(e ext_workspace.ExtWorkspaceHandleV1CoordinatesEvent) {
|
|
||||||
coords := make([]uint32, 0)
|
|
||||||
for i := 0; i < len(e.Coordinates); i += 4 {
|
|
||||||
if i+4 <= len(e.Coordinates) {
|
|
||||||
val := uint32(e.Coordinates[i]) |
|
|
||||||
uint32(e.Coordinates[i+1])<<8 |
|
|
||||||
uint32(e.Coordinates[i+2])<<16 |
|
|
||||||
uint32(e.Coordinates[i+3])<<24
|
|
||||||
coords = append(coords, val)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
log.Debugf("ExtWorkspace: Workspace %d coordinates: %v", workspaceID, coords)
|
|
||||||
m.post(func() {
|
|
||||||
ws.coordinates = coords
|
|
||||||
m.updateState()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
handle.SetStateHandler(func(e ext_workspace.ExtWorkspaceHandleV1StateEvent) {
|
|
||||||
log.Debugf("ExtWorkspace: Workspace %d state: %d", workspaceID, e.State)
|
|
||||||
m.post(func() {
|
|
||||||
ws.state = e.State
|
|
||||||
m.updateState()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
handle.SetCapabilitiesHandler(func(e ext_workspace.ExtWorkspaceHandleV1CapabilitiesEvent) {
|
|
||||||
log.Debugf("ExtWorkspace: Workspace %d capabilities: %d", workspaceID, e.Capabilities)
|
|
||||||
})
|
|
||||||
|
|
||||||
handle.SetRemovedHandler(func(e ext_workspace.ExtWorkspaceHandleV1RemovedEvent) {
|
|
||||||
log.Debugf("ExtWorkspace: Workspace %d removed", workspaceID)
|
|
||||||
|
|
||||||
m.post(func() {
|
|
||||||
ws.removed = true
|
|
||||||
|
|
||||||
m.workspaces.Delete(workspaceID)
|
|
||||||
|
|
||||||
m.wlMutex.Lock()
|
|
||||||
handle.Destroy()
|
|
||||||
m.wlMutex.Unlock()
|
|
||||||
|
|
||||||
m.updateState()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) updateState() {
|
|
||||||
groups := make([]*WorkspaceGroup, 0)
|
|
||||||
|
|
||||||
m.groups.Range(func(key uint32, group *workspaceGroupState) bool {
|
|
||||||
if group.removed {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
outputs := make([]string, 0)
|
|
||||||
for outputID := range group.outputIDs {
|
|
||||||
if name, ok := m.outputNames.Load(outputID); ok && name != "" {
|
|
||||||
outputs = append(outputs, name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
workspaces := make([]*Workspace, 0)
|
|
||||||
for _, wsID := range group.workspaceIDs {
|
|
||||||
ws, exists := m.workspaces.Load(wsID)
|
|
||||||
if !exists {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if ws.removed {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
workspace := &Workspace{
|
|
||||||
ID: ws.workspaceID,
|
|
||||||
Name: ws.name,
|
|
||||||
Coordinates: ws.coordinates,
|
|
||||||
State: ws.state,
|
|
||||||
Active: ws.state&uint32(ext_workspace.ExtWorkspaceHandleV1StateActive) != 0,
|
|
||||||
Urgent: ws.state&uint32(ext_workspace.ExtWorkspaceHandleV1StateUrgent) != 0,
|
|
||||||
Hidden: ws.state&uint32(ext_workspace.ExtWorkspaceHandleV1StateHidden) != 0,
|
|
||||||
}
|
|
||||||
workspaces = append(workspaces, workspace)
|
|
||||||
}
|
|
||||||
|
|
||||||
groupState := &WorkspaceGroup{
|
|
||||||
ID: fmt.Sprintf("group-%d", group.id),
|
|
||||||
Outputs: outputs,
|
|
||||||
Workspaces: workspaces,
|
|
||||||
}
|
|
||||||
groups = append(groups, groupState)
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
newState := State{
|
|
||||||
Groups: groups,
|
|
||||||
}
|
|
||||||
|
|
||||||
m.stateMutex.Lock()
|
|
||||||
m.state = &newState
|
|
||||||
m.stateMutex.Unlock()
|
|
||||||
|
|
||||||
m.notifySubscribers()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) notifier() {
|
|
||||||
defer m.notifierWg.Done()
|
|
||||||
const minGap = 100 * time.Millisecond
|
|
||||||
timer := time.NewTimer(minGap)
|
|
||||||
timer.Stop()
|
|
||||||
var pending bool
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-m.stopChan:
|
|
||||||
timer.Stop()
|
|
||||||
return
|
|
||||||
case <-m.dirty:
|
|
||||||
if pending {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
pending = true
|
|
||||||
timer.Reset(minGap)
|
|
||||||
case <-timer.C:
|
|
||||||
if !pending {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
currentState := m.GetState()
|
|
||||||
|
|
||||||
if m.lastNotified != nil && !stateChanged(m.lastNotified, ¤tState) {
|
|
||||||
pending = false
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
m.subscribers.Range(func(key string, ch chan State) bool {
|
|
||||||
select {
|
|
||||||
case ch <- currentState:
|
|
||||||
default:
|
|
||||||
log.Warn("ExtWorkspace: subscriber channel full, dropping update")
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
stateCopy := currentState
|
|
||||||
m.lastNotified = &stateCopy
|
|
||||||
pending = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) ActivateWorkspace(groupID, workspaceID string) error {
|
|
||||||
errChan := make(chan error, 1)
|
|
||||||
|
|
||||||
m.post(func() {
|
|
||||||
var targetGroupID uint32
|
|
||||||
if groupID != "" {
|
|
||||||
var parsedID uint32
|
|
||||||
if _, err := fmt.Sscanf(groupID, "group-%d", &parsedID); err == nil {
|
|
||||||
targetGroupID = parsedID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var found bool
|
|
||||||
m.workspaces.Range(func(key uint32, ws *workspaceState) bool {
|
|
||||||
if targetGroupID != 0 && ws.groupID != targetGroupID {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if ws.workspaceID == workspaceID || ws.name == workspaceID {
|
|
||||||
m.wlMutex.Lock()
|
|
||||||
err := ws.handle.Activate()
|
|
||||||
if err == nil {
|
|
||||||
err = m.manager.Commit()
|
|
||||||
}
|
|
||||||
m.wlMutex.Unlock()
|
|
||||||
errChan <- err
|
|
||||||
found = true
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
if !found {
|
|
||||||
errChan <- fmt.Errorf("workspace not found: %s in group %s", workspaceID, groupID)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return <-errChan
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) DeactivateWorkspace(groupID, workspaceID string) error {
|
|
||||||
errChan := make(chan error, 1)
|
|
||||||
|
|
||||||
m.post(func() {
|
|
||||||
var targetGroupID uint32
|
|
||||||
if groupID != "" {
|
|
||||||
var parsedID uint32
|
|
||||||
if _, err := fmt.Sscanf(groupID, "group-%d", &parsedID); err == nil {
|
|
||||||
targetGroupID = parsedID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var found bool
|
|
||||||
m.workspaces.Range(func(key uint32, ws *workspaceState) bool {
|
|
||||||
if targetGroupID != 0 && ws.groupID != targetGroupID {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if ws.workspaceID == workspaceID || ws.name == workspaceID {
|
|
||||||
m.wlMutex.Lock()
|
|
||||||
err := ws.handle.Deactivate()
|
|
||||||
if err == nil {
|
|
||||||
err = m.manager.Commit()
|
|
||||||
}
|
|
||||||
m.wlMutex.Unlock()
|
|
||||||
errChan <- err
|
|
||||||
found = true
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
if !found {
|
|
||||||
errChan <- fmt.Errorf("workspace not found: %s in group %s", workspaceID, groupID)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return <-errChan
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) RemoveWorkspace(groupID, workspaceID string) error {
|
|
||||||
errChan := make(chan error, 1)
|
|
||||||
|
|
||||||
m.post(func() {
|
|
||||||
var targetGroupID uint32
|
|
||||||
if groupID != "" {
|
|
||||||
var parsedID uint32
|
|
||||||
if _, err := fmt.Sscanf(groupID, "group-%d", &parsedID); err == nil {
|
|
||||||
targetGroupID = parsedID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var found bool
|
|
||||||
m.workspaces.Range(func(key uint32, ws *workspaceState) bool {
|
|
||||||
if targetGroupID != 0 && ws.groupID != targetGroupID {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if ws.workspaceID == workspaceID || ws.name == workspaceID {
|
|
||||||
m.wlMutex.Lock()
|
|
||||||
err := ws.handle.Remove()
|
|
||||||
if err == nil {
|
|
||||||
err = m.manager.Commit()
|
|
||||||
}
|
|
||||||
m.wlMutex.Unlock()
|
|
||||||
errChan <- err
|
|
||||||
found = true
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
if !found {
|
|
||||||
errChan <- fmt.Errorf("workspace not found: %s in group %s", workspaceID, groupID)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return <-errChan
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) CreateWorkspace(groupID, workspaceName string) error {
|
|
||||||
errChan := make(chan error, 1)
|
|
||||||
|
|
||||||
m.post(func() {
|
|
||||||
var found bool
|
|
||||||
m.groups.Range(func(key uint32, group *workspaceGroupState) bool {
|
|
||||||
if fmt.Sprintf("group-%d", group.id) == groupID {
|
|
||||||
m.wlMutex.Lock()
|
|
||||||
err := group.handle.CreateWorkspace(workspaceName)
|
|
||||||
if err == nil {
|
|
||||||
err = m.manager.Commit()
|
|
||||||
}
|
|
||||||
m.wlMutex.Unlock()
|
|
||||||
errChan <- err
|
|
||||||
found = true
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
if !found {
|
|
||||||
errChan <- fmt.Errorf("workspace group not found: %s", groupID)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return <-errChan
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) Close() {
|
|
||||||
close(m.stopChan)
|
|
||||||
m.wg.Wait()
|
|
||||||
m.notifierWg.Wait()
|
|
||||||
|
|
||||||
m.subscribers.Range(func(key string, ch chan State) bool {
|
|
||||||
close(ch)
|
|
||||||
m.subscribers.Delete(key)
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
m.workspaces.Range(func(key uint32, ws *workspaceState) bool {
|
|
||||||
if ws.handle != nil {
|
|
||||||
ws.handle.Destroy()
|
|
||||||
}
|
|
||||||
m.workspaces.Delete(key)
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
m.groups.Range(func(key uint32, group *workspaceGroupState) bool {
|
|
||||||
if group.handle != nil {
|
|
||||||
group.handle.Destroy()
|
|
||||||
}
|
|
||||||
m.groups.Delete(key)
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
if m.manager != nil {
|
|
||||||
m.manager.Stop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,392 +0,0 @@
|
|||||||
package extworkspace
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
|
|
||||||
mocks_wlclient "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/wlclient"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestStateChanged_BothNil(t *testing.T) {
|
|
||||||
assert.True(t, stateChanged(nil, nil))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStateChanged_OneNil(t *testing.T) {
|
|
||||||
s := &State{Groups: []*WorkspaceGroup{}}
|
|
||||||
assert.True(t, stateChanged(s, nil))
|
|
||||||
assert.True(t, stateChanged(nil, s))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStateChanged_GroupCountDiffers(t *testing.T) {
|
|
||||||
a := &State{Groups: []*WorkspaceGroup{{ID: "group-1"}}}
|
|
||||||
b := &State{Groups: []*WorkspaceGroup{}}
|
|
||||||
assert.True(t, stateChanged(a, b))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStateChanged_GroupIDDiffers(t *testing.T) {
|
|
||||||
a := &State{Groups: []*WorkspaceGroup{{ID: "group-1", Outputs: []string{}, Workspaces: []*Workspace{}}}}
|
|
||||||
b := &State{Groups: []*WorkspaceGroup{{ID: "group-2", Outputs: []string{}, Workspaces: []*Workspace{}}}}
|
|
||||||
assert.True(t, stateChanged(a, b))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStateChanged_OutputCountDiffers(t *testing.T) {
|
|
||||||
a := &State{Groups: []*WorkspaceGroup{{ID: "group-1", Outputs: []string{"eDP-1"}, Workspaces: []*Workspace{}}}}
|
|
||||||
b := &State{Groups: []*WorkspaceGroup{{ID: "group-1", Outputs: []string{}, Workspaces: []*Workspace{}}}}
|
|
||||||
assert.True(t, stateChanged(a, b))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStateChanged_OutputNameDiffers(t *testing.T) {
|
|
||||||
a := &State{Groups: []*WorkspaceGroup{{ID: "group-1", Outputs: []string{"eDP-1"}, Workspaces: []*Workspace{}}}}
|
|
||||||
b := &State{Groups: []*WorkspaceGroup{{ID: "group-1", Outputs: []string{"HDMI-A-1"}, Workspaces: []*Workspace{}}}}
|
|
||||||
assert.True(t, stateChanged(a, b))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStateChanged_WorkspaceCountDiffers(t *testing.T) {
|
|
||||||
a := &State{Groups: []*WorkspaceGroup{{
|
|
||||||
ID: "group-1",
|
|
||||||
Outputs: []string{},
|
|
||||||
Workspaces: []*Workspace{{ID: "1", Name: "ws1"}},
|
|
||||||
}}}
|
|
||||||
b := &State{Groups: []*WorkspaceGroup{{
|
|
||||||
ID: "group-1",
|
|
||||||
Outputs: []string{},
|
|
||||||
Workspaces: []*Workspace{},
|
|
||||||
}}}
|
|
||||||
assert.True(t, stateChanged(a, b))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStateChanged_WorkspaceFieldsDiffer(t *testing.T) {
|
|
||||||
a := &State{Groups: []*WorkspaceGroup{{
|
|
||||||
ID: "group-1",
|
|
||||||
Outputs: []string{},
|
|
||||||
Workspaces: []*Workspace{{
|
|
||||||
ID: "1", Name: "ws1", State: 0, Active: false, Urgent: false, Hidden: false,
|
|
||||||
}},
|
|
||||||
}}}
|
|
||||||
b := &State{Groups: []*WorkspaceGroup{{
|
|
||||||
ID: "group-1",
|
|
||||||
Outputs: []string{},
|
|
||||||
Workspaces: []*Workspace{{
|
|
||||||
ID: "2", Name: "ws1", State: 0, Active: false, Urgent: false, Hidden: false,
|
|
||||||
}},
|
|
||||||
}}}
|
|
||||||
assert.True(t, stateChanged(a, b))
|
|
||||||
|
|
||||||
b.Groups[0].Workspaces[0].ID = "1"
|
|
||||||
b.Groups[0].Workspaces[0].Name = "ws2"
|
|
||||||
assert.True(t, stateChanged(a, b))
|
|
||||||
|
|
||||||
b.Groups[0].Workspaces[0].Name = "ws1"
|
|
||||||
b.Groups[0].Workspaces[0].State = 1
|
|
||||||
assert.True(t, stateChanged(a, b))
|
|
||||||
|
|
||||||
b.Groups[0].Workspaces[0].State = 0
|
|
||||||
b.Groups[0].Workspaces[0].Active = true
|
|
||||||
assert.True(t, stateChanged(a, b))
|
|
||||||
|
|
||||||
b.Groups[0].Workspaces[0].Active = false
|
|
||||||
b.Groups[0].Workspaces[0].Urgent = true
|
|
||||||
assert.True(t, stateChanged(a, b))
|
|
||||||
|
|
||||||
b.Groups[0].Workspaces[0].Urgent = false
|
|
||||||
b.Groups[0].Workspaces[0].Hidden = true
|
|
||||||
assert.True(t, stateChanged(a, b))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStateChanged_WorkspaceCoordinatesDiffer(t *testing.T) {
|
|
||||||
a := &State{Groups: []*WorkspaceGroup{{
|
|
||||||
ID: "group-1",
|
|
||||||
Outputs: []string{},
|
|
||||||
Workspaces: []*Workspace{{
|
|
||||||
ID: "1", Name: "ws1", Coordinates: []uint32{0, 0},
|
|
||||||
}},
|
|
||||||
}}}
|
|
||||||
b := &State{Groups: []*WorkspaceGroup{{
|
|
||||||
ID: "group-1",
|
|
||||||
Outputs: []string{},
|
|
||||||
Workspaces: []*Workspace{{
|
|
||||||
ID: "1", Name: "ws1", Coordinates: []uint32{1, 0},
|
|
||||||
}},
|
|
||||||
}}}
|
|
||||||
assert.True(t, stateChanged(a, b))
|
|
||||||
|
|
||||||
b.Groups[0].Workspaces[0].Coordinates = []uint32{0}
|
|
||||||
assert.True(t, stateChanged(a, b))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStateChanged_Equal(t *testing.T) {
|
|
||||||
a := &State{Groups: []*WorkspaceGroup{{
|
|
||||||
ID: "group-1",
|
|
||||||
Outputs: []string{"eDP-1", "HDMI-A-1"},
|
|
||||||
Workspaces: []*Workspace{
|
|
||||||
{ID: "1", Name: "ws1", Coordinates: []uint32{0, 0}, State: 1, Active: true},
|
|
||||||
{ID: "2", Name: "ws2", Coordinates: []uint32{1, 0}, State: 0, Active: false},
|
|
||||||
},
|
|
||||||
}}}
|
|
||||||
b := &State{Groups: []*WorkspaceGroup{{
|
|
||||||
ID: "group-1",
|
|
||||||
Outputs: []string{"eDP-1", "HDMI-A-1"},
|
|
||||||
Workspaces: []*Workspace{
|
|
||||||
{ID: "1", Name: "ws1", Coordinates: []uint32{0, 0}, State: 1, Active: true},
|
|
||||||
{ID: "2", Name: "ws2", Coordinates: []uint32{1, 0}, State: 0, Active: false},
|
|
||||||
},
|
|
||||||
}}}
|
|
||||||
assert.False(t, stateChanged(a, b))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestManager_ConcurrentGetState(t *testing.T) {
|
|
||||||
m := &Manager{
|
|
||||||
state: &State{
|
|
||||||
Groups: []*WorkspaceGroup{{ID: "group-1", Outputs: []string{"eDP-1"}}},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
const goroutines = 50
|
|
||||||
const iterations = 100
|
|
||||||
|
|
||||||
for i := 0; i < goroutines/2; i++ {
|
|
||||||
wg.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
for j := 0; j < iterations; j++ {
|
|
||||||
s := m.GetState()
|
|
||||||
_ = s.Groups
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := 0; i < goroutines/2; i++ {
|
|
||||||
wg.Add(1)
|
|
||||||
go func(i int) {
|
|
||||||
defer wg.Done()
|
|
||||||
for j := 0; j < iterations; j++ {
|
|
||||||
m.stateMutex.Lock()
|
|
||||||
m.state = &State{
|
|
||||||
Groups: []*WorkspaceGroup{{ID: "group-1", Outputs: []string{"eDP-1"}}},
|
|
||||||
}
|
|
||||||
m.stateMutex.Unlock()
|
|
||||||
}
|
|
||||||
}(i)
|
|
||||||
}
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestManager_ConcurrentSubscriberAccess(t *testing.T) {
|
|
||||||
m := &Manager{
|
|
||||||
stopChan: make(chan struct{}),
|
|
||||||
dirty: make(chan struct{}, 1),
|
|
||||||
}
|
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
const goroutines = 20
|
|
||||||
|
|
||||||
for i := 0; i < goroutines; i++ {
|
|
||||||
wg.Add(1)
|
|
||||||
go func(id int) {
|
|
||||||
defer wg.Done()
|
|
||||||
subID := string(rune('a' + id))
|
|
||||||
ch := m.Subscribe(subID)
|
|
||||||
assert.NotNil(t, ch)
|
|
||||||
time.Sleep(time.Millisecond)
|
|
||||||
m.Unsubscribe(subID)
|
|
||||||
}(i)
|
|
||||||
}
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestManager_SyncmapGroupsConcurrentAccess(t *testing.T) {
|
|
||||||
m := &Manager{}
|
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
const goroutines = 30
|
|
||||||
const iterations = 50
|
|
||||||
|
|
||||||
for i := 0; i < goroutines; i++ {
|
|
||||||
wg.Add(1)
|
|
||||||
go func(id int) {
|
|
||||||
defer wg.Done()
|
|
||||||
key := uint32(id)
|
|
||||||
|
|
||||||
for j := 0; j < iterations; j++ {
|
|
||||||
state := &workspaceGroupState{
|
|
||||||
id: key,
|
|
||||||
outputIDs: map[uint32]bool{1: true},
|
|
||||||
workspaceIDs: []uint32{uint32(j)},
|
|
||||||
}
|
|
||||||
m.groups.Store(key, state)
|
|
||||||
|
|
||||||
if loaded, ok := m.groups.Load(key); ok {
|
|
||||||
assert.Equal(t, key, loaded.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.groups.Range(func(k uint32, v *workspaceGroupState) bool {
|
|
||||||
_ = v.id
|
|
||||||
_ = v.outputIDs
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
m.groups.Delete(key)
|
|
||||||
}(i)
|
|
||||||
}
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestManager_SyncmapWorkspacesConcurrentAccess(t *testing.T) {
|
|
||||||
m := &Manager{}
|
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
const goroutines = 30
|
|
||||||
const iterations = 50
|
|
||||||
|
|
||||||
for i := 0; i < goroutines; i++ {
|
|
||||||
wg.Add(1)
|
|
||||||
go func(id int) {
|
|
||||||
defer wg.Done()
|
|
||||||
key := uint32(id)
|
|
||||||
|
|
||||||
for j := 0; j < iterations; j++ {
|
|
||||||
state := &workspaceState{
|
|
||||||
id: key,
|
|
||||||
workspaceID: "ws-1",
|
|
||||||
name: "workspace",
|
|
||||||
state: uint32(j % 4),
|
|
||||||
coordinates: []uint32{uint32(j), 0},
|
|
||||||
}
|
|
||||||
m.workspaces.Store(key, state)
|
|
||||||
|
|
||||||
if loaded, ok := m.workspaces.Load(key); ok {
|
|
||||||
assert.Equal(t, key, loaded.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.workspaces.Range(func(k uint32, v *workspaceState) bool {
|
|
||||||
_ = v.name
|
|
||||||
_ = v.state
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
m.workspaces.Delete(key)
|
|
||||||
}(i)
|
|
||||||
}
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestManager_SyncmapOutputNamesConcurrentAccess(t *testing.T) {
|
|
||||||
m := &Manager{}
|
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
const goroutines = 30
|
|
||||||
const iterations = 50
|
|
||||||
|
|
||||||
for i := 0; i < goroutines; i++ {
|
|
||||||
wg.Add(1)
|
|
||||||
go func(id int) {
|
|
||||||
defer wg.Done()
|
|
||||||
key := uint32(id)
|
|
||||||
|
|
||||||
for j := 0; j < iterations; j++ {
|
|
||||||
m.outputNames.Store(key, "eDP-1")
|
|
||||||
|
|
||||||
if loaded, ok := m.outputNames.Load(key); ok {
|
|
||||||
assert.NotEmpty(t, loaded)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.outputNames.Range(func(k uint32, v string) bool {
|
|
||||||
_ = v
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
m.outputNames.Delete(key)
|
|
||||||
}(i)
|
|
||||||
}
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestManager_NotifySubscribersNonBlocking(t *testing.T) {
|
|
||||||
m := &Manager{
|
|
||||||
dirty: make(chan struct{}, 1),
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := 0; i < 10; i++ {
|
|
||||||
m.notifySubscribers()
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Len(t, m.dirty, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestManager_PostQueueFull(t *testing.T) {
|
|
||||||
m := &Manager{
|
|
||||||
cmdq: make(chan cmd, 2),
|
|
||||||
stopChan: make(chan struct{}),
|
|
||||||
}
|
|
||||||
|
|
||||||
m.post(func() {})
|
|
||||||
m.post(func() {})
|
|
||||||
m.post(func() {})
|
|
||||||
m.post(func() {})
|
|
||||||
|
|
||||||
assert.Len(t, m.cmdq, 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestManager_GetStateNilState(t *testing.T) {
|
|
||||||
m := &Manager{}
|
|
||||||
|
|
||||||
s := m.GetState()
|
|
||||||
assert.NotNil(t, s.Groups)
|
|
||||||
assert.Empty(t, s.Groups)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWorkspace_Fields(t *testing.T) {
|
|
||||||
ws := Workspace{
|
|
||||||
ID: "ws-1",
|
|
||||||
Name: "workspace 1",
|
|
||||||
Coordinates: []uint32{0, 0},
|
|
||||||
State: 1,
|
|
||||||
Active: true,
|
|
||||||
Urgent: false,
|
|
||||||
Hidden: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, "ws-1", ws.ID)
|
|
||||||
assert.Equal(t, "workspace 1", ws.Name)
|
|
||||||
assert.True(t, ws.Active)
|
|
||||||
assert.False(t, ws.Urgent)
|
|
||||||
assert.False(t, ws.Hidden)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWorkspaceGroup_Fields(t *testing.T) {
|
|
||||||
group := WorkspaceGroup{
|
|
||||||
ID: "group-1",
|
|
||||||
Outputs: []string{"eDP-1", "HDMI-A-1"},
|
|
||||||
Workspaces: []*Workspace{
|
|
||||||
{ID: "ws-1", Name: "workspace 1"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, "group-1", group.ID)
|
|
||||||
assert.Len(t, group.Outputs, 2)
|
|
||||||
assert.Len(t, group.Workspaces, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewManager_GetRegistryError(t *testing.T) {
|
|
||||||
mockDisplay := mocks_wlclient.NewMockWaylandDisplay(t)
|
|
||||||
|
|
||||||
mockDisplay.EXPECT().Context().Return(nil)
|
|
||||||
mockDisplay.EXPECT().GetRegistry().Return(nil, errors.New("failed to get registry"))
|
|
||||||
|
|
||||||
_, err := NewManager(mockDisplay)
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "failed to get registry")
|
|
||||||
}
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
package extworkspace
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_workspace"
|
|
||||||
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Workspace struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Coordinates []uint32 `json:"coordinates"`
|
|
||||||
State uint32 `json:"state"`
|
|
||||||
Active bool `json:"active"`
|
|
||||||
Urgent bool `json:"urgent"`
|
|
||||||
Hidden bool `json:"hidden"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type WorkspaceGroup struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Outputs []string `json:"outputs"`
|
|
||||||
Workspaces []*Workspace `json:"workspaces"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type State struct {
|
|
||||||
Groups []*WorkspaceGroup `json:"groups"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type cmd struct {
|
|
||||||
fn func()
|
|
||||||
}
|
|
||||||
|
|
||||||
type Manager struct {
|
|
||||||
display wlclient.WaylandDisplay
|
|
||||||
ctx *wlclient.Context
|
|
||||||
registry *wlclient.Registry
|
|
||||||
manager *ext_workspace.ExtWorkspaceManagerV1
|
|
||||||
|
|
||||||
outputNames syncmap.Map[uint32, string]
|
|
||||||
|
|
||||||
groups syncmap.Map[uint32, *workspaceGroupState]
|
|
||||||
|
|
||||||
workspaces syncmap.Map[uint32, *workspaceState]
|
|
||||||
|
|
||||||
wlMutex sync.Mutex
|
|
||||||
cmdq chan cmd
|
|
||||||
stopChan chan struct{}
|
|
||||||
wg sync.WaitGroup
|
|
||||||
|
|
||||||
subscribers syncmap.Map[string, chan State]
|
|
||||||
dirty chan struct{}
|
|
||||||
notifierWg sync.WaitGroup
|
|
||||||
lastNotified *State
|
|
||||||
|
|
||||||
stateMutex sync.RWMutex
|
|
||||||
state *State
|
|
||||||
}
|
|
||||||
|
|
||||||
type workspaceGroupState struct {
|
|
||||||
id uint32
|
|
||||||
handle *ext_workspace.ExtWorkspaceGroupHandleV1
|
|
||||||
outputIDs map[uint32]bool
|
|
||||||
workspaceIDs []uint32
|
|
||||||
removed bool
|
|
||||||
}
|
|
||||||
|
|
||||||
type workspaceState struct {
|
|
||||||
id uint32
|
|
||||||
handle *ext_workspace.ExtWorkspaceHandleV1
|
|
||||||
workspaceID string
|
|
||||||
name string
|
|
||||||
coordinates []uint32
|
|
||||||
state uint32
|
|
||||||
groupID uint32
|
|
||||||
removed bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) GetState() State {
|
|
||||||
m.stateMutex.RLock()
|
|
||||||
defer m.stateMutex.RUnlock()
|
|
||||||
if m.state == nil {
|
|
||||||
return State{
|
|
||||||
Groups: []*WorkspaceGroup{},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
stateCopy := *m.state
|
|
||||||
return stateCopy
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) Subscribe(id string) chan State {
|
|
||||||
ch := make(chan State, 64)
|
|
||||||
|
|
||||||
m.subscribers.Store(id, ch)
|
|
||||||
|
|
||||||
return ch
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) Unsubscribe(id string) {
|
|
||||||
if ch, ok := m.subscribers.LoadAndDelete(id); ok {
|
|
||||||
close(ch)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) notifySubscribers() {
|
|
||||||
select {
|
|
||||||
case m.dirty <- struct{}{}:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func stateChanged(old, new *State) bool {
|
|
||||||
if old == nil || new == nil {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if len(old.Groups) != len(new.Groups) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
for i, newGroup := range new.Groups {
|
|
||||||
if i >= len(old.Groups) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
oldGroup := old.Groups[i]
|
|
||||||
if oldGroup.ID != newGroup.ID {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if len(oldGroup.Outputs) != len(newGroup.Outputs) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
for j, newOutput := range newGroup.Outputs {
|
|
||||||
if j >= len(oldGroup.Outputs) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if oldGroup.Outputs[j] != newOutput {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(oldGroup.Workspaces) != len(newGroup.Workspaces) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
for j, newWs := range newGroup.Workspaces {
|
|
||||||
if j >= len(oldGroup.Workspaces) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
oldWs := oldGroup.Workspaces[j]
|
|
||||||
if oldWs.ID != newWs.ID || oldWs.Name != newWs.Name || oldWs.State != newWs.State {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if oldWs.Active != newWs.Active || oldWs.Urgent != newWs.Urgent || oldWs.Hidden != newWs.Hidden {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if len(oldWs.Coordinates) != len(newWs.Coordinates) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
for k, coord := range newWs.Coordinates {
|
|
||||||
if k >= len(oldWs.Coordinates) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if oldWs.Coordinates[k] != coord {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
@@ -104,7 +104,7 @@ func (m *Manager) claimScreensaverName(handler *screensaverHandler, name, iface
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if reply != dbus.RequestNameReplyPrimaryOwner {
|
if reply != dbus.RequestNameReplyPrimaryOwner {
|
||||||
log.Warnf("Screensaver name %s already owned by another process", name)
|
log.Infof("Screensaver name %s already owned by another process (e.g. hypridle/swayidle)", name)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if err := m.exportScreensaverOnPaths(handler, iface, paths...); err != nil {
|
if err := m.exportScreensaverOnPaths(handler, iface, paths...); err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package mime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/desktop"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/params"
|
||||||
|
)
|
||||||
|
|
||||||
|
type defaultResult struct {
|
||||||
|
MimeType string `json:"mimeType"`
|
||||||
|
DesktopID string `json:"desktopId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type appsResult struct {
|
||||||
|
MimeType string `json:"mimeType"`
|
||||||
|
DesktopIDs []string `json:"desktopIds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type queryResult struct {
|
||||||
|
Defaults map[string]string `json:"defaults"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleRequest(conn net.Conn, req models.Request) {
|
||||||
|
switch req.Method {
|
||||||
|
case "mime.getDefault":
|
||||||
|
handleGetDefault(conn, req)
|
||||||
|
case "mime.setDefault":
|
||||||
|
handleSetDefault(conn, req)
|
||||||
|
case "mime.setDefaults":
|
||||||
|
handleSetDefaults(conn, req)
|
||||||
|
case "mime.appsForMime":
|
||||||
|
handleAppsForMime(conn, req)
|
||||||
|
case "mime.queryDefaults":
|
||||||
|
handleQueryDefaults(conn, req)
|
||||||
|
case "mime.invalidate":
|
||||||
|
desktop.InvalidateCache()
|
||||||
|
models.Respond(conn, req.ID, models.SuccessResult{Success: true})
|
||||||
|
default:
|
||||||
|
models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleGetDefault(conn net.Conn, req models.Request) {
|
||||||
|
mimeType, err := mimeParam(req.Params, "mimeType")
|
||||||
|
if err != nil {
|
||||||
|
models.RespondError(conn, req.ID, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
models.Respond(conn, req.ID, defaultResult{
|
||||||
|
MimeType: mimeType,
|
||||||
|
DesktopID: desktop.GetDefault(mimeType),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleSetDefault(conn net.Conn, req models.Request) {
|
||||||
|
mimeType, err := mimeParam(req.Params, "mimeType")
|
||||||
|
if err != nil {
|
||||||
|
models.RespondError(conn, req.ID, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
desktopID, err := params.StringNonEmpty(req.Params, "desktopId")
|
||||||
|
if err != nil {
|
||||||
|
models.RespondError(conn, req.ID, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := desktop.SetDefault(mimeType, desktopID); err != nil {
|
||||||
|
models.RespondError(conn, req.ID, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
models.Respond(conn, req.ID, models.SuccessResult{Success: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleSetDefaults(conn net.Conn, req models.Request) {
|
||||||
|
desktopID, err := params.StringNonEmpty(req.Params, "desktopId")
|
||||||
|
if err != nil {
|
||||||
|
models.RespondError(conn, req.ID, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mimeTypes, err := mimeListParam(req, "mimeTypes")
|
||||||
|
if err != nil {
|
||||||
|
models.RespondError(conn, req.ID, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := desktop.SetDefaults(mimeTypes, desktopID); err != nil {
|
||||||
|
models.RespondError(conn, req.ID, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
models.Respond(conn, req.ID, models.SuccessResult{Success: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleAppsForMime(conn net.Conn, req models.Request) {
|
||||||
|
mimeType, err := mimeParam(req.Params, "mimeType")
|
||||||
|
if err != nil {
|
||||||
|
models.RespondError(conn, req.ID, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ids := desktop.AppsForMime(mimeType)
|
||||||
|
if ids == nil {
|
||||||
|
ids = []string{}
|
||||||
|
}
|
||||||
|
models.Respond(conn, req.ID, appsResult{
|
||||||
|
MimeType: mimeType,
|
||||||
|
DesktopIDs: ids,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleQueryDefaults(conn net.Conn, req models.Request) {
|
||||||
|
mimeTypes, err := mimeListParam(req, "mimeTypes")
|
||||||
|
if err != nil {
|
||||||
|
models.RespondError(conn, req.ID, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
models.Respond(conn, req.ID, queryResult{
|
||||||
|
Defaults: desktop.QueryDefaults(mimeTypes),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func mimeParam(p map[string]any, key string) (string, error) {
|
||||||
|
raw, err := params.StringNonEmpty(p, key)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
canonical := desktop.StripMimeParams(raw)
|
||||||
|
if canonical == "" {
|
||||||
|
return "", fmt.Errorf("invalid '%s' parameter", key)
|
||||||
|
}
|
||||||
|
return canonical, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mimeListParam(req models.Request, key string) ([]string, error) {
|
||||||
|
raw, ok := models.Get[[]any](req, key)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("missing or invalid '%s' parameter", key)
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(raw))
|
||||||
|
for _, v := range raw {
|
||||||
|
s, ok := v.(string)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
canonical := desktop.StripMimeParams(s)
|
||||||
|
if canonical == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, canonical)
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil, fmt.Errorf("no valid mime types provided")
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -23,6 +23,13 @@ const (
|
|||||||
agentIdentifier = "com.danklinux.NMAgent"
|
agentIdentifier = "com.danklinux.NMAgent"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
nmSecretAgentFlagAllowInteraction = 0x1
|
||||||
|
nmSecretAgentFlagRequestNew = 0x2
|
||||||
|
nmSecretAgentFlagUserRequested = 0x4
|
||||||
|
nmSecretAgentFlagOnlySystem = 0x80000000
|
||||||
|
)
|
||||||
|
|
||||||
type SecretAgent struct {
|
type SecretAgent struct {
|
||||||
conn *dbus.Conn
|
conn *dbus.Conn
|
||||||
objPath dbus.ObjectPath
|
objPath dbus.ObjectPath
|
||||||
@@ -129,6 +136,21 @@ func (a *SecretAgent) GetSecrets(
|
|||||||
|
|
||||||
log.Infof("[SecretAgent] connType=%s, name=%s, vpnSvc=%s, fields=%v, flags=%d, vpnPasswordFlags=%d", connType, displayName, vpnSvc, fields, flags, vpnPasswordFlags)
|
log.Infof("[SecretAgent] connType=%s, name=%s, vpnSvc=%s, fields=%v, flags=%d, vpnPasswordFlags=%d", connType, displayName, vpnSvc, fields, flags, vpnPasswordFlags)
|
||||||
|
|
||||||
|
if flags&nmSecretAgentFlagOnlySystem != 0 {
|
||||||
|
log.Infof("[SecretAgent] ONLY_SYSTEM flag set, deferring to system secret storage")
|
||||||
|
return nil, dbus.NewError("org.freedesktop.NetworkManager.SecretAgent.Error.NoSecrets", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
var connUuid string
|
||||||
|
if c, ok := conn["connection"]; ok {
|
||||||
|
if v, ok := c["uuid"]; ok {
|
||||||
|
if s, ok2 := v.Value().(string); ok2 {
|
||||||
|
connUuid = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 1: Determine if this connection is ours and what fields we need.
|
||||||
if a.backend != nil {
|
if a.backend != nil {
|
||||||
a.backend.stateMutex.RLock()
|
a.backend.stateMutex.RLock()
|
||||||
isConnecting := a.backend.state.IsConnecting
|
isConnecting := a.backend.state.IsConnecting
|
||||||
@@ -145,15 +167,6 @@ func (a *SecretAgent) GetSecrets(
|
|||||||
return nil, dbus.NewError("org.freedesktop.NetworkManager.SecretAgent.Error.NoSecrets", nil)
|
return nil, dbus.NewError("org.freedesktop.NetworkManager.SecretAgent.Error.NoSecrets", nil)
|
||||||
}
|
}
|
||||||
case "vpn", "wireguard":
|
case "vpn", "wireguard":
|
||||||
var connUuid string
|
|
||||||
if c, ok := conn["connection"]; ok {
|
|
||||||
if v, ok := c["uuid"]; ok {
|
|
||||||
if s, ok2 := v.Value().(string); ok2 {
|
|
||||||
connUuid = s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we're connecting to a VPN, only respond if it's the one we're connecting to
|
// If we're connecting to a VPN, only respond if it's the one we're connecting to
|
||||||
// This prevents interfering with nmcli/other tools when our app isn't connecting
|
// This prevents interfering with nmcli/other tools when our app isn't connecting
|
||||||
if isConnectingVPN && connUuid != connectingVPNUUID {
|
if isConnectingVPN && connUuid != connectingVPNUUID {
|
||||||
@@ -163,6 +176,7 @@ func (a *SecretAgent) GetSecrets(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 2: Resolve fields from hints or password-flags.
|
||||||
if len(fields) == 0 {
|
if len(fields) == 0 {
|
||||||
if settingName == "vpn" {
|
if settingName == "vpn" {
|
||||||
if a.backend != nil {
|
if a.backend != nil {
|
||||||
@@ -230,41 +244,19 @@ func (a *SecretAgent) GetSecrets(
|
|||||||
return nil, dbus.NewError("org.freedesktop.NetworkManager.SecretAgent.Error.NoSecrets", nil)
|
return nil, dbus.NewError("org.freedesktop.NetworkManager.SecretAgent.Error.NoSecrets", nil)
|
||||||
}
|
}
|
||||||
log.Infof("[SecretAgent] Agent-owned secrets, inferred fields: %v", fields)
|
log.Infof("[SecretAgent] Agent-owned secrets, inferred fields: %v", fields)
|
||||||
|
} else if passwordFlags&NM_SETTING_SECRET_FLAG_NOT_SAVED != 0 {
|
||||||
|
log.Infof("[SecretAgent] Secrets not saved, will need to prompt (flags=%d)", passwordFlags)
|
||||||
|
// Fall through — fields remain empty, prompt will be required.
|
||||||
} else {
|
} else {
|
||||||
log.Infof("[SecretAgent] No secrets needed, using system stored secrets (flags=%d)", passwordFlags)
|
log.Infof("[SecretAgent] Secrets stored in NM config (flags=%d), deferring to system", passwordFlags)
|
||||||
out := nmSettingMap{}
|
return nil, dbus.NewError("org.freedesktop.NetworkManager.SecretAgent.Error.NoSecrets", nil)
|
||||||
out[settingName] = nmVariantMap{}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
reason := reasonFromFlags(flags)
|
|
||||||
if a.manager != nil && connType == "802-11-wireless" && a.manager.WasRecentlyFailed(ssid) {
|
|
||||||
reason = "wrong-password"
|
|
||||||
}
|
|
||||||
if settingName == "vpn" && isPKCS11Auth(conn, vpnSvc) {
|
|
||||||
reason = "pkcs11"
|
|
||||||
}
|
|
||||||
|
|
||||||
var connId, connUuid string
|
|
||||||
if c, ok := conn["connection"]; ok {
|
|
||||||
if v, ok := c["id"]; ok {
|
|
||||||
if s, ok2 := v.Value().(string); ok2 {
|
|
||||||
connId = s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if v, ok := c["uuid"]; ok {
|
|
||||||
if s, ok2 := v.Value().(string); ok2 {
|
|
||||||
connUuid = s
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 3: Cached VPN credentials — user-provided, take priority.
|
||||||
if settingName == "vpn" && a.backend != nil {
|
if settingName == "vpn" && a.backend != nil {
|
||||||
// Check for cached PKCS11 PIN first
|
if isPKCS11Request := len(fields) == 1 && fields[0] == "key_pass"; isPKCS11Request {
|
||||||
isPKCS11Request := len(fields) == 1 && fields[0] == "key_pass"
|
|
||||||
if isPKCS11Request {
|
|
||||||
a.backend.cachedPKCS11Mu.Lock()
|
a.backend.cachedPKCS11Mu.Lock()
|
||||||
cached := a.backend.cachedPKCS11PIN
|
cached := a.backend.cachedPKCS11PIN
|
||||||
if cached != nil && cached.ConnectionUUID == connUuid {
|
if cached != nil && cached.ConnectionUUID == connUuid {
|
||||||
@@ -283,7 +275,6 @@ func (a *SecretAgent) GetSecrets(
|
|||||||
a.backend.cachedPKCS11Mu.Unlock()
|
a.backend.cachedPKCS11Mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for cached VPN password
|
|
||||||
a.backend.cachedVPNCredsMu.Lock()
|
a.backend.cachedVPNCredsMu.Lock()
|
||||||
cached := a.backend.cachedVPNCreds
|
cached := a.backend.cachedVPNCreds
|
||||||
if cached != nil && cached.ConnectionUUID == connUuid {
|
if cached != nil && cached.ConnectionUUID == connUuid {
|
||||||
@@ -314,6 +305,7 @@ func (a *SecretAgent) GetSecrets(
|
|||||||
a.backend.cachedGPSamlMu.Lock()
|
a.backend.cachedGPSamlMu.Lock()
|
||||||
cachedGPSaml := a.backend.cachedGPSamlCookie
|
cachedGPSaml := a.backend.cachedGPSamlCookie
|
||||||
if cachedGPSaml != nil && cachedGPSaml.ConnectionUUID == connUuid {
|
if cachedGPSaml != nil && cachedGPSaml.ConnectionUUID == connUuid {
|
||||||
|
a.backend.cachedGPSamlCookie = nil
|
||||||
a.backend.cachedGPSamlMu.Unlock()
|
a.backend.cachedGPSamlMu.Unlock()
|
||||||
|
|
||||||
log.Infof("[SecretAgent] Using cached GlobalProtect SAML cookie for %s", connUuid)
|
log.Infof("[SecretAgent] Using cached GlobalProtect SAML cookie for %s", connUuid)
|
||||||
@@ -369,6 +361,37 @@ func (a *SecretAgent) GetSecrets(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 4: Non-interactive secret retrieval (keyring).
|
||||||
|
// Always try the keyring even when REQUEST_NEW is set — the vault may have
|
||||||
|
// been unlocked by a prior call's Prompt flow, making the lookup non-interactive.
|
||||||
|
if secretOut := a.trySecretService(connUuid, settingName, fields); secretOut != nil {
|
||||||
|
return secretOut, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 5: If interaction is not allowed, we're done.
|
||||||
|
if flags&nmSecretAgentFlagAllowInteraction == 0 {
|
||||||
|
log.Infof("[SecretAgent] ALLOW_INTERACTION not set, cannot prompt user")
|
||||||
|
return nil, dbus.NewError("org.freedesktop.NetworkManager.SecretAgent.Error.NoSecrets", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 6: Prepare prompt.
|
||||||
|
reason := reasonFromFlags(flags)
|
||||||
|
if a.manager != nil && connType == "802-11-wireless" && a.manager.WasRecentlyFailed(ssid) {
|
||||||
|
reason = "wrong-password"
|
||||||
|
}
|
||||||
|
if settingName == "vpn" && isPKCS11Auth(conn, vpnSvc) {
|
||||||
|
reason = "pkcs11"
|
||||||
|
}
|
||||||
|
|
||||||
|
var connId string
|
||||||
|
if c, ok := conn["connection"]; ok {
|
||||||
|
if v, ok := c["id"]; ok {
|
||||||
|
if s, ok2 := v.Value().(string); ok2 {
|
||||||
|
connId = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
@@ -403,6 +426,7 @@ func (a *SecretAgent) GetSecrets(
|
|||||||
wasConnectingVPN := a.backend.state.IsConnectingVPN
|
wasConnectingVPN := a.backend.state.IsConnectingVPN
|
||||||
cancelledSSID := a.backend.state.ConnectingSSID
|
cancelledSSID := a.backend.state.ConnectingSSID
|
||||||
cancelledVPNUUID := a.backend.state.ConnectingVPNUUID
|
cancelledVPNUUID := a.backend.state.ConnectingVPNUUID
|
||||||
|
connPreExisting := a.backend.state.ConnectingPreExisting
|
||||||
if wasConnecting || wasConnectingVPN {
|
if wasConnecting || wasConnectingVPN {
|
||||||
log.Infof("[SecretAgent] Clearing connecting state due to cancelled prompt")
|
log.Infof("[SecretAgent] Clearing connecting state due to cancelled prompt")
|
||||||
a.backend.state.IsConnecting = false
|
a.backend.state.IsConnecting = false
|
||||||
@@ -414,7 +438,8 @@ func (a *SecretAgent) GetSecrets(
|
|||||||
|
|
||||||
// If this was a WiFi connection that was just cancelled, remove the connection profile
|
// If this was a WiFi connection that was just cancelled, remove the connection profile
|
||||||
// (it was created with AddConnection but activation was cancelled)
|
// (it was created with AddConnection but activation was cancelled)
|
||||||
if wasConnecting && cancelledSSID != "" && connType == "802-11-wireless" {
|
// Only do this for newly created connections, not pre-existing ones.
|
||||||
|
if wasConnecting && cancelledSSID != "" && connType == "802-11-wireless" && !connPreExisting {
|
||||||
log.Infof("[SecretAgent] Removing connection profile for cancelled WiFi connection: %s", cancelledSSID)
|
log.Infof("[SecretAgent] Removing connection profile for cancelled WiFi connection: %s", cancelledSSID)
|
||||||
if err := a.backend.ForgetWiFiNetwork(cancelledSSID); err != nil {
|
if err := a.backend.ForgetWiFiNetwork(cancelledSSID); err != nil {
|
||||||
log.Warnf("[SecretAgent] Failed to remove cancelled connection profile: %v", err)
|
log.Warnf("[SecretAgent] Failed to remove cancelled connection profile: %v", err)
|
||||||
@@ -623,7 +648,7 @@ func fieldsNeeded(setting string, hints []string, conn map[string]nmVariantMap)
|
|||||||
return hints
|
return hints
|
||||||
}
|
}
|
||||||
return infer8021xFields(conn)
|
return infer8021xFields(conn)
|
||||||
case "vpn":
|
case "vpn", "wireguard":
|
||||||
return hints
|
return hints
|
||||||
default:
|
default:
|
||||||
return []string{}
|
return []string{}
|
||||||
|
|||||||
@@ -339,6 +339,41 @@ func TestInferVPNFields_GPSaml(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSecretAgent_GetSecrets_OnlySystemFlag(t *testing.T) {
|
||||||
|
agent := &SecretAgent{}
|
||||||
|
conn := map[string]nmVariantMap{
|
||||||
|
"connection": {
|
||||||
|
"id": dbus.MakeVariant("TestWiFi"),
|
||||||
|
"type": dbus.MakeVariant("802-11-wireless"),
|
||||||
|
},
|
||||||
|
"802-11-wireless": {
|
||||||
|
"ssid": dbus.MakeVariant("TestSSID"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := agent.GetSecrets(conn, "/test/path", "802-11-wireless-security", nil, 0x80000000)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "NoSecrets")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSecretAgent_GetSecrets_NoInteractionFlag(t *testing.T) {
|
||||||
|
agent := &SecretAgent{}
|
||||||
|
conn := map[string]nmVariantMap{
|
||||||
|
"connection": {
|
||||||
|
"id": dbus.MakeVariant("TestWiFi"),
|
||||||
|
"type": dbus.MakeVariant("802-11-wireless"),
|
||||||
|
},
|
||||||
|
"802-11-wireless": {
|
||||||
|
"ssid": dbus.MakeVariant("TestSSID"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// flags=0 means ALLOW_INTERACTION is not set
|
||||||
|
_, err := agent.GetSecrets(conn, "/test/path", "802-11-wireless-security", nil, 0x0)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "NoSecrets")
|
||||||
|
}
|
||||||
|
|
||||||
func TestNmVariantMap(t *testing.T) {
|
func TestNmVariantMap(t *testing.T) {
|
||||||
// Test that nmVariantMap and nmSettingMap work correctly
|
// Test that nmVariantMap and nmSettingMap work correctly
|
||||||
settingMap := make(nmSettingMap)
|
settingMap := make(nmSettingMap)
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ type BackendState struct {
|
|||||||
IsConnecting bool
|
IsConnecting bool
|
||||||
ConnectingSSID string
|
ConnectingSSID string
|
||||||
ConnectingDevice string
|
ConnectingDevice string
|
||||||
|
ConnectingPreExisting bool
|
||||||
IsConnectingVPN bool
|
IsConnectingVPN bool
|
||||||
ConnectingVPNUUID string
|
ConnectingVPNUUID string
|
||||||
LastError string
|
LastError string
|
||||||
|
|||||||
@@ -245,18 +245,34 @@ func (b *NetworkManagerBackend) GetWiFiQRCodeContent(ssid string) (string, error
|
|||||||
return "", fmt.Errorf("QR code generation only supports WPA connections, `%s` uses %s", ssid, securityType)
|
return "", fmt.Errorf("QR code generation only supports WPA connections, `%s` uses %s", ssid, securityType)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var psk string
|
||||||
|
|
||||||
secrets, err := conn.GetSecrets("802-11-wireless-security")
|
secrets, err := conn.GetSecrets("802-11-wireless-security")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to retrieve connection secrets for `%s`: %w", ssid, err)
|
log.Debugf("[GetWiFiQRCodeContent] conn.GetSecrets failed: %v, falling back to secret service", err)
|
||||||
|
} else if secSecrets, ok := secrets["802-11-wireless-security"]; ok {
|
||||||
|
if s, ok := secSecrets["psk"].(string); ok {
|
||||||
|
psk = s
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
secSecrets, ok := secrets["802-11-wireless-security"]
|
if psk == "" {
|
||||||
if !ok {
|
uuid := ""
|
||||||
return "", fmt.Errorf("failed to retrieve password for `%s`", ssid)
|
if connMeta, ok := connSettings["connection"]; ok {
|
||||||
|
if u, ok := connMeta["uuid"].(string); ok {
|
||||||
|
uuid = u
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if uuid != "" {
|
||||||
|
sess, err := openSecretService()
|
||||||
|
if err == nil {
|
||||||
|
psk = sess.lookup(uuid, "802-11-wireless-security", "psk")
|
||||||
|
sess.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
psk, ok := secSecrets["psk"].(string)
|
if psk == "" {
|
||||||
if !ok {
|
|
||||||
return "", fmt.Errorf("failed to retrieve password for `%s`", ssid)
|
return "", fmt.Errorf("failed to retrieve password for `%s`", ssid)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,6 +297,7 @@ func (b *NetworkManagerBackend) ConnectWiFi(req ConnectionRequest) error {
|
|||||||
b.state.IsConnecting = true
|
b.state.IsConnecting = true
|
||||||
b.state.ConnectingSSID = req.SSID
|
b.state.ConnectingSSID = req.SSID
|
||||||
b.state.ConnectingDevice = req.Device
|
b.state.ConnectingDevice = req.Device
|
||||||
|
b.state.ConnectingPreExisting = false
|
||||||
b.state.LastError = ""
|
b.state.LastError = ""
|
||||||
b.stateMutex.Unlock()
|
b.stateMutex.Unlock()
|
||||||
|
|
||||||
@@ -292,6 +309,9 @@ func (b *NetworkManagerBackend) ConnectWiFi(req ConnectionRequest) error {
|
|||||||
|
|
||||||
existingConn, err := b.findConnection(req.SSID)
|
existingConn, err := b.findConnection(req.SSID)
|
||||||
if err == nil && existingConn != nil {
|
if err == nil && existingConn != nil {
|
||||||
|
b.stateMutex.Lock()
|
||||||
|
b.state.ConnectingPreExisting = true
|
||||||
|
b.stateMutex.Unlock()
|
||||||
_, err := nm.ActivateConnection(existingConn, devInfo.device, nil)
|
_, err := nm.ActivateConnection(existingConn, devInfo.device, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warnf("[ConnectWiFi] Failed to activate existing connection: %v", err)
|
log.Warnf("[ConnectWiFi] Failed to activate existing connection: %v", err)
|
||||||
@@ -607,6 +627,7 @@ func (b *NetworkManagerBackend) findConnection(ssid string) (gonetworkmanager.Co
|
|||||||
if bytes.Equal(candidateSSID, ssidBytes) {
|
if bytes.Equal(candidateSSID, ssidBytes) {
|
||||||
return conn, nil
|
return conn, nil
|
||||||
}
|
}
|
||||||
|
log.Debugf("[findConnection] SSID mismatch: stored=%q, request=%q", string(candidateSSID), ssid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
package network
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
|
"github.com/godbus/dbus/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
secretServiceBusName = "org.freedesktop.secrets"
|
||||||
|
secretServicePath = "/org/freedesktop/secrets"
|
||||||
|
secretServiceIface = "org.freedesktop.Secret.Service"
|
||||||
|
secretItemIface = "org.freedesktop.Secret.Item"
|
||||||
|
secretPromptIface = "org.freedesktop.Secret.Prompt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type secretServiceSession struct {
|
||||||
|
conn *dbus.Conn
|
||||||
|
svc dbus.BusObject
|
||||||
|
sessionPath dbus.ObjectPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func openSecretService() (*secretServiceSession, error) {
|
||||||
|
c, err := dbus.ConnectSessionBus()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := c.Object(secretServiceBusName, dbus.ObjectPath(secretServicePath))
|
||||||
|
|
||||||
|
var sessionPath dbus.ObjectPath
|
||||||
|
call := svc.Call(secretServiceIface+".OpenSession", 0, "plain", dbus.MakeVariant(""))
|
||||||
|
if call.Err != nil {
|
||||||
|
c.Close()
|
||||||
|
return nil, call.Err
|
||||||
|
}
|
||||||
|
if err := call.Store(new(dbus.Variant), &sessionPath); err != nil {
|
||||||
|
c.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &secretServiceSession{
|
||||||
|
conn: c,
|
||||||
|
svc: svc,
|
||||||
|
sessionPath: sessionPath,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *secretServiceSession) unlock(items []dbus.ObjectPath) error {
|
||||||
|
var prompt dbus.ObjectPath
|
||||||
|
var unlocked []dbus.ObjectPath
|
||||||
|
call := s.svc.Call(secretServiceIface+".Unlock", 0, items)
|
||||||
|
if call.Err != nil {
|
||||||
|
return call.Err
|
||||||
|
}
|
||||||
|
if err := call.Store(&unlocked, &prompt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if prompt == "/" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.conn.AddMatchSignal(
|
||||||
|
dbus.WithMatchInterface(secretPromptIface),
|
||||||
|
dbus.WithMatchObjectPath(prompt),
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer s.conn.RemoveMatchSignal(
|
||||||
|
dbus.WithMatchInterface(secretPromptIface),
|
||||||
|
dbus.WithMatchObjectPath(prompt),
|
||||||
|
)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
ch := make(chan *dbus.Signal, 10)
|
||||||
|
s.conn.Signal(ch)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer s.conn.RemoveSignal(ch)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case v := <-ch:
|
||||||
|
if v.Path == prompt && v.Name == secretPromptIface+".Completed" {
|
||||||
|
if len(v.Body) < 2 {
|
||||||
|
log.Debugf("[SecretAgent] Unlock prompt Completed signal has %d body element(s), expected >= 2", len(v.Body))
|
||||||
|
} else {
|
||||||
|
if dismissed, ok := v.Body[0].(bool); ok && dismissed {
|
||||||
|
log.Debugf("[SecretAgent] Unlock prompt dismissed by user")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
promptObj := s.conn.Object(secretServiceBusName, prompt)
|
||||||
|
if err := promptObj.Call(secretPromptIface+".Prompt", 0, "").Store(); err != nil {
|
||||||
|
cancel()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
<-ctx.Done()
|
||||||
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
|
promptObj.Call(secretPromptIface+".Dismiss", 0)
|
||||||
|
return fmt.Errorf("timed out waiting for unlock prompt")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *secretServiceSession) lookup(connUuid, settingName, settingKey string) string {
|
||||||
|
attrs := map[string]string{
|
||||||
|
"connection-uuid": connUuid,
|
||||||
|
"setting-name": settingName,
|
||||||
|
"setting-key": settingKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
var unlocked []dbus.ObjectPath
|
||||||
|
var locked []dbus.ObjectPath
|
||||||
|
call := s.svc.Call(secretServiceIface+".SearchItems", 0, attrs)
|
||||||
|
if call.Err != nil {
|
||||||
|
log.Debugf("[SecretAgent] Secret service SearchItems failed: %v", call.Err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if err := call.Store(&unlocked, &locked); err != nil {
|
||||||
|
log.Debugf("[SecretAgent] Failed to store SearchItems result: %v", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(unlocked) == 0 && len(locked) > 0 {
|
||||||
|
log.Debugf("[SecretAgent] Attempting to unlock %d locked item(s) for %s", len(locked), connUuid)
|
||||||
|
if err := s.unlock(locked); err != nil {
|
||||||
|
log.Debugf("[SecretAgent] Failed to unlock items: %v", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
unlocked = locked
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(unlocked) == 0 {
|
||||||
|
log.Debugf("[SecretAgent] No secret service items found for %s", connUuid)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
item := s.conn.Object(secretServiceBusName, unlocked[0])
|
||||||
|
var secret struct {
|
||||||
|
Session dbus.ObjectPath
|
||||||
|
Parameters []byte
|
||||||
|
Value []byte
|
||||||
|
ContentType string
|
||||||
|
}
|
||||||
|
call = item.Call(secretItemIface+".GetSecret", 0, s.sessionPath)
|
||||||
|
if call.Err != nil {
|
||||||
|
log.Debugf("[SecretAgent] Secret service GetSecret failed: %v", call.Err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if err := call.Store(&secret); err != nil {
|
||||||
|
log.Debugf("[SecretAgent] Failed to store GetSecret result: %v", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
secretValue := string(secret.Value)
|
||||||
|
if secretValue == "" {
|
||||||
|
log.Debugf("[SecretAgent] Secret service returned empty value for %s/%s", connUuid, settingKey)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof("[SecretAgent] Retrieved secret from secret service for %s/%s", connUuid, settingKey)
|
||||||
|
return secretValue
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *secretServiceSession) close() {
|
||||||
|
s.conn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *SecretAgent) trySecretService(
|
||||||
|
connUuid string,
|
||||||
|
settingName string,
|
||||||
|
fields []string,
|
||||||
|
) nmSettingMap {
|
||||||
|
if connUuid == "" {
|
||||||
|
log.Debugf("[SecretAgent] trySecretService: connUuid is empty, skipping keyring lookup")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(fields) == 0 {
|
||||||
|
log.Debugf("[SecretAgent] trySecretService: no fields requested, skipping keyring lookup")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch settingName {
|
||||||
|
case "802-11-wireless-security", "802-1x", "vpn", "wireguard":
|
||||||
|
default:
|
||||||
|
log.Debugf("[SecretAgent] trySecretService: setting %s not supported for keyring lookup", settingName)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sess, err := openSecretService()
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf("[SecretAgent] Failed to open secret service session: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer sess.close()
|
||||||
|
|
||||||
|
found := make(map[string]string)
|
||||||
|
for _, field := range fields {
|
||||||
|
val := sess.lookup(connUuid, settingName, field)
|
||||||
|
if val == "" {
|
||||||
|
log.Debugf("[SecretAgent] Secret service missing field '%s' for %s", field, connUuid)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
found[field] = val
|
||||||
|
}
|
||||||
|
|
||||||
|
out := nmSettingMap{}
|
||||||
|
sec := nmVariantMap{}
|
||||||
|
for k, v := range found {
|
||||||
|
sec[k] = dbus.MakeVariant(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch settingName {
|
||||||
|
case "vpn":
|
||||||
|
secretsDict := make(map[string]string)
|
||||||
|
for k, v := range found {
|
||||||
|
if k != "username" {
|
||||||
|
secretsDict[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vpnSec := nmVariantMap{}
|
||||||
|
vpnSec["secrets"] = dbus.MakeVariant(secretsDict)
|
||||||
|
out[settingName] = vpnSec
|
||||||
|
log.Infof("[SecretAgent] Returning VPN secrets from secret service with %d fields", len(secretsDict))
|
||||||
|
case "802-1x":
|
||||||
|
secretsOnly := nmVariantMap{}
|
||||||
|
for k, v := range found {
|
||||||
|
switch k {
|
||||||
|
case "password", "private-key-password", "phase2-private-key-password", "pin":
|
||||||
|
secretsOnly[k] = dbus.MakeVariant(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out[settingName] = secretsOnly
|
||||||
|
log.Infof("[SecretAgent] Returning 802-1x secrets from secret service with %d fields", len(secretsOnly))
|
||||||
|
default:
|
||||||
|
out[settingName] = sec
|
||||||
|
log.Infof("[SecretAgent] Returning %s secrets from secret service", settingName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -13,13 +13,15 @@ import (
|
|||||||
serverDbus "github.com/AvengeMedia/DankMaterialShell/core/internal/server/dbus"
|
serverDbus "github.com/AvengeMedia/DankMaterialShell/core/internal/server/dbus"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/dwl"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/dwl"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/evdev"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/evdev"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/extworkspace"
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/freedesktop"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/freedesktop"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/location"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/location"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/mime"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
|
||||||
serverPlugins "github.com/AvengeMedia/DankMaterialShell/core/internal/server/plugins"
|
serverPlugins "github.com/AvengeMedia/DankMaterialShell/core/internal/server/plugins"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/sysupdate"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/tailscale"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode"
|
||||||
serverThemes "github.com/AvengeMedia/DankMaterialShell/core/internal/server/themes"
|
serverThemes "github.com/AvengeMedia/DankMaterialShell/core/internal/server/themes"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
|
||||||
@@ -91,6 +93,11 @@ func RouteRequest(conn net.Conn, req models.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(req.Method, "mime.") {
|
||||||
|
mime.HandleRequest(conn, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if strings.HasPrefix(req.Method, "browser.") || strings.HasPrefix(req.Method, "apppicker.") {
|
if strings.HasPrefix(req.Method, "browser.") || strings.HasPrefix(req.Method, "apppicker.") {
|
||||||
if appPickerManager == nil {
|
if appPickerManager == nil {
|
||||||
models.RespondError(conn, req.ID, "apppicker manager not initialized")
|
models.RespondError(conn, req.ID, "apppicker manager not initialized")
|
||||||
@@ -109,6 +116,15 @@ func RouteRequest(conn net.Conn, req models.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(req.Method, "tailscale.") {
|
||||||
|
if tailscaleManager == nil {
|
||||||
|
models.RespondError(conn, req.ID, "Tailscale not available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tailscale.HandleRequest(conn, req, tailscaleManager)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if strings.HasPrefix(req.Method, "dwl.") {
|
if strings.HasPrefix(req.Method, "dwl.") {
|
||||||
if dwlManager == nil {
|
if dwlManager == nil {
|
||||||
models.RespondError(conn, req.ID, "dwl manager not initialized")
|
models.RespondError(conn, req.ID, "dwl manager not initialized")
|
||||||
@@ -127,27 +143,6 @@ func RouteRequest(conn net.Conn, req models.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.HasPrefix(req.Method, "extworkspace.") {
|
|
||||||
if extWorkspaceManager == nil {
|
|
||||||
if extWorkspaceAvailable.Load() {
|
|
||||||
extWorkspaceInitMutex.Lock()
|
|
||||||
if extWorkspaceManager == nil {
|
|
||||||
if err := InitializeExtWorkspaceManager(); err != nil {
|
|
||||||
extWorkspaceInitMutex.Unlock()
|
|
||||||
models.RespondError(conn, req.ID, "extworkspace manager not available")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
extWorkspaceInitMutex.Unlock()
|
|
||||||
} else {
|
|
||||||
models.RespondError(conn, req.ID, "extworkspace manager not initialized")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
extworkspace.HandleRequest(conn, req, extWorkspaceManager)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.HasPrefix(req.Method, "wlroutput.") {
|
if strings.HasPrefix(req.Method, "wlroutput.") {
|
||||||
if wlrOutputManager == nil {
|
if wlrOutputManager == nil {
|
||||||
models.RespondError(conn, req.ID, "wlroutput manager not initialized")
|
models.RespondError(conn, req.ID, "wlroutput manager not initialized")
|
||||||
@@ -202,6 +197,15 @@ func RouteRequest(conn net.Conn, req models.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(req.Method, "sysupdate.") {
|
||||||
|
if sysUpdateManager == nil {
|
||||||
|
models.RespondError(conn, req.ID, "sysupdate manager not initialized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sysupdate.HandleRequest(conn, req, sysUpdateManager)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
switch req.Method {
|
switch req.Method {
|
||||||
case "ping":
|
case "ping":
|
||||||
models.Respond(conn, req.ID, "pong")
|
models.Respond(conn, req.ID, "pong")
|
||||||
|
|||||||
+115
-98
@@ -24,12 +24,13 @@ import (
|
|||||||
serverDbus "github.com/AvengeMedia/DankMaterialShell/core/internal/server/dbus"
|
serverDbus "github.com/AvengeMedia/DankMaterialShell/core/internal/server/dbus"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/dwl"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/dwl"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/evdev"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/evdev"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/extworkspace"
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/freedesktop"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/freedesktop"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/location"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/location"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/sysupdate"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/tailscale"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/trayrecovery"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/trayrecovery"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
|
||||||
@@ -64,8 +65,8 @@ var waylandManager *wayland.Manager
|
|||||||
var bluezManager *bluez.Manager
|
var bluezManager *bluez.Manager
|
||||||
var appPickerManager *apppicker.Manager
|
var appPickerManager *apppicker.Manager
|
||||||
var cupsManager *cups.Manager
|
var cupsManager *cups.Manager
|
||||||
|
var tailscaleManager *tailscale.Manager
|
||||||
var dwlManager *dwl.Manager
|
var dwlManager *dwl.Manager
|
||||||
var extWorkspaceManager *extworkspace.Manager
|
|
||||||
var brightnessManager *brightness.Manager
|
var brightnessManager *brightness.Manager
|
||||||
var wlrOutputManager *wlroutput.Manager
|
var wlrOutputManager *wlroutput.Manager
|
||||||
var evdevManager *evdev.Manager
|
var evdevManager *evdev.Manager
|
||||||
@@ -75,6 +76,7 @@ var wlContext *wlcontext.SharedContext
|
|||||||
var themeModeManager *thememode.Manager
|
var themeModeManager *thememode.Manager
|
||||||
var trayRecoveryManager *trayrecovery.Manager
|
var trayRecoveryManager *trayrecovery.Manager
|
||||||
var locationManager *location.Manager
|
var locationManager *location.Manager
|
||||||
|
var sysUpdateManager *sysupdate.Manager
|
||||||
var geoClientInstance geolocation.Client
|
var geoClientInstance geolocation.Client
|
||||||
|
|
||||||
const dbusClientID = "dms-dbus-client"
|
const dbusClientID = "dms-dbus-client"
|
||||||
@@ -82,8 +84,6 @@ const dbusClientID = "dms-dbus-client"
|
|||||||
var capabilitySubscribers syncmap.Map[string, chan ServerInfo]
|
var capabilitySubscribers syncmap.Map[string, chan ServerInfo]
|
||||||
var cupsSubscribers syncmap.Map[string, bool]
|
var cupsSubscribers syncmap.Map[string, bool]
|
||||||
var cupsSubscriberCount atomic.Int32
|
var cupsSubscriberCount atomic.Int32
|
||||||
var extWorkspaceAvailable atomic.Bool
|
|
||||||
var extWorkspaceInitMutex sync.Mutex
|
|
||||||
|
|
||||||
func getSocketDir() string {
|
func getSocketDir() string {
|
||||||
if runtime := os.Getenv("XDG_RUNTIME_DIR"); runtime != "" {
|
if runtime := os.Getenv("XDG_RUNTIME_DIR"); runtime != "" {
|
||||||
@@ -289,30 +289,6 @@ func InitializeBrightnessManager() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func InitializeExtWorkspaceManager() error {
|
|
||||||
log.Info("Attempting to initialize ExtWorkspace...")
|
|
||||||
|
|
||||||
if wlContext == nil {
|
|
||||||
ctx, err := wlcontext.New()
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("Failed to create shared Wayland context: %v", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
wlContext = ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
manager, err := extworkspace.NewManager(wlContext.Display())
|
|
||||||
if err != nil {
|
|
||||||
log.Debug("Failed to initialize extworkspace manager: %v", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
extWorkspaceManager = manager
|
|
||||||
|
|
||||||
log.Info("ExtWorkspace initialized successfully")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func InitializeWlrOutputManager() error {
|
func InitializeWlrOutputManager() error {
|
||||||
log.Info("Attempting to initialize WlrOutput management...")
|
log.Info("Attempting to initialize WlrOutput management...")
|
||||||
|
|
||||||
@@ -421,6 +397,19 @@ func InitializeLocationManager(geoClient geolocation.Client) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func InitializeSysUpdateManager() error {
|
||||||
|
manager, err := sysupdate.NewManager()
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("Failed to initialize sysupdate manager: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sysUpdateManager = manager
|
||||||
|
|
||||||
|
log.Info("Sysupdate manager initialized")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func handleConnection(conn net.Conn) {
|
func handleConnection(conn net.Conn) {
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
@@ -474,12 +463,12 @@ func getCapabilities() Capabilities {
|
|||||||
caps = append(caps, "cups")
|
caps = append(caps, "cups")
|
||||||
}
|
}
|
||||||
|
|
||||||
if dwlManager != nil {
|
if tailscaleManager != nil && tailscaleManager.IsAvailable() {
|
||||||
caps = append(caps, "dwl")
|
caps = append(caps, "tailscale")
|
||||||
}
|
}
|
||||||
|
|
||||||
if extWorkspaceAvailable.Load() {
|
if dwlManager != nil {
|
||||||
caps = append(caps, "extworkspace")
|
caps = append(caps, "dwl")
|
||||||
}
|
}
|
||||||
|
|
||||||
if brightnessManager != nil {
|
if brightnessManager != nil {
|
||||||
@@ -506,6 +495,10 @@ func getCapabilities() Capabilities {
|
|||||||
caps = append(caps, "dbus")
|
caps = append(caps, "dbus")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if sysUpdateManager != nil {
|
||||||
|
caps = append(caps, "sysupdate")
|
||||||
|
}
|
||||||
|
|
||||||
return Capabilities{Capabilities: caps}
|
return Capabilities{Capabilities: caps}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -540,12 +533,12 @@ func getServerInfo() ServerInfo {
|
|||||||
caps = append(caps, "cups")
|
caps = append(caps, "cups")
|
||||||
}
|
}
|
||||||
|
|
||||||
if dwlManager != nil {
|
if tailscaleManager != nil && tailscaleManager.IsAvailable() {
|
||||||
caps = append(caps, "dwl")
|
caps = append(caps, "tailscale")
|
||||||
}
|
}
|
||||||
|
|
||||||
if extWorkspaceAvailable.Load() {
|
if dwlManager != nil {
|
||||||
caps = append(caps, "extworkspace")
|
caps = append(caps, "dwl")
|
||||||
}
|
}
|
||||||
|
|
||||||
if brightnessManager != nil {
|
if brightnessManager != nil {
|
||||||
@@ -576,6 +569,10 @@ func getServerInfo() ServerInfo {
|
|||||||
caps = append(caps, "dbus")
|
caps = append(caps, "dbus")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if sysUpdateManager != nil {
|
||||||
|
caps = append(caps, "sysupdate")
|
||||||
|
}
|
||||||
|
|
||||||
return ServerInfo{
|
return ServerInfo{
|
||||||
APIVersion: APIVersion,
|
APIVersion: APIVersion,
|
||||||
CLIVersion: CLIVersion,
|
CLIVersion: CLIVersion,
|
||||||
@@ -1016,6 +1013,38 @@ func handleSubscribe(conn net.Conn, req models.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if shouldSubscribe("tailscale") && tailscaleManager != nil && tailscaleManager.IsAvailable() {
|
||||||
|
wg.Add(1)
|
||||||
|
tailscaleChan := tailscaleManager.Subscribe(clientID + "-tailscale")
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
defer tailscaleManager.Unsubscribe(clientID + "-tailscale")
|
||||||
|
|
||||||
|
initialState := tailscaleManager.GetState()
|
||||||
|
select {
|
||||||
|
case eventChan <- ServiceEvent{Service: "tailscale", Data: initialState}:
|
||||||
|
case <-stopChan:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case state, ok := <-tailscaleChan:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case eventChan <- ServiceEvent{Service: "tailscale", Data: state}:
|
||||||
|
case <-stopChan:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case <-stopChan:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
if shouldSubscribe("dwl") && dwlManager != nil {
|
if shouldSubscribe("dwl") && dwlManager != nil {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
dwlChan := dwlManager.Subscribe(clientID + "-dwl")
|
dwlChan := dwlManager.Subscribe(clientID + "-dwl")
|
||||||
@@ -1048,50 +1077,6 @@ func handleSubscribe(conn net.Conn, req models.Request) {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
if shouldSubscribe("extworkspace") {
|
|
||||||
if extWorkspaceManager == nil && extWorkspaceAvailable.Load() {
|
|
||||||
extWorkspaceInitMutex.Lock()
|
|
||||||
if extWorkspaceManager == nil {
|
|
||||||
if err := InitializeExtWorkspaceManager(); err != nil {
|
|
||||||
log.Warnf("Failed to initialize ExtWorkspace manager for subscription: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
extWorkspaceInitMutex.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
if extWorkspaceManager != nil {
|
|
||||||
wg.Add(1)
|
|
||||||
extWorkspaceChan := extWorkspaceManager.Subscribe(clientID + "-extworkspace")
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
defer extWorkspaceManager.Unsubscribe(clientID + "-extworkspace")
|
|
||||||
|
|
||||||
initialState := extWorkspaceManager.GetState()
|
|
||||||
select {
|
|
||||||
case eventChan <- ServiceEvent{Service: "extworkspace", Data: initialState}:
|
|
||||||
case <-stopChan:
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case state, ok := <-extWorkspaceChan:
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case eventChan <- ServiceEvent{Service: "extworkspace", Data: state}:
|
|
||||||
case <-stopChan:
|
|
||||||
return
|
|
||||||
}
|
|
||||||
case <-stopChan:
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if shouldSubscribe("brightness") && brightnessManager != nil {
|
if shouldSubscribe("brightness") && brightnessManager != nil {
|
||||||
wg.Add(2)
|
wg.Add(2)
|
||||||
brightnessStateChan := brightnessManager.Subscribe(clientID + "-brightness-state")
|
brightnessStateChan := brightnessManager.Subscribe(clientID + "-brightness-state")
|
||||||
@@ -1243,6 +1228,38 @@ func handleSubscribe(conn net.Conn, req models.Request) {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if shouldSubscribe("sysupdate") && sysUpdateManager != nil {
|
||||||
|
wg.Add(1)
|
||||||
|
sysupdateChan := sysUpdateManager.Subscribe(clientID + "-sysupdate")
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
defer sysUpdateManager.Unsubscribe(clientID + "-sysupdate")
|
||||||
|
|
||||||
|
initialState := sysUpdateManager.GetState()
|
||||||
|
select {
|
||||||
|
case eventChan <- ServiceEvent{Service: "sysupdate", Data: initialState}:
|
||||||
|
case <-stopChan:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case state, ok := <-sysupdateChan:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case eventChan <- ServiceEvent{Service: "sysupdate", Data: state}:
|
||||||
|
case <-stopChan:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case <-stopChan:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
if shouldSubscribe("dbus") && dbusManager != nil {
|
if shouldSubscribe("dbus") && dbusManager != nil {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
dbusChan := dbusManager.SubscribeSignals(dbusClientID)
|
dbusChan := dbusManager.SubscribeSignals(dbusClientID)
|
||||||
@@ -1318,9 +1335,6 @@ func cleanupManagers() {
|
|||||||
if dwlManager != nil {
|
if dwlManager != nil {
|
||||||
dwlManager.Close()
|
dwlManager.Close()
|
||||||
}
|
}
|
||||||
if extWorkspaceManager != nil {
|
|
||||||
extWorkspaceManager.Close()
|
|
||||||
}
|
|
||||||
if brightnessManager != nil {
|
if brightnessManager != nil {
|
||||||
brightnessManager.Close()
|
brightnessManager.Close()
|
||||||
}
|
}
|
||||||
@@ -1348,14 +1362,28 @@ func cleanupManagers() {
|
|||||||
if locationManager != nil {
|
if locationManager != nil {
|
||||||
locationManager.Close()
|
locationManager.Close()
|
||||||
}
|
}
|
||||||
|
if sysUpdateManager != nil {
|
||||||
|
sysUpdateManager.Close()
|
||||||
|
}
|
||||||
if geoClientInstance != nil {
|
if geoClientInstance != nil {
|
||||||
geoClientInstance.Close()
|
geoClientInstance.Close()
|
||||||
}
|
}
|
||||||
|
if tailscaleManager != nil {
|
||||||
|
tailscaleManager.Close()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func Start(printDocs bool) error {
|
func Start(printDocs bool) error {
|
||||||
cleanupStaleSockets()
|
cleanupStaleSockets()
|
||||||
|
|
||||||
|
// Tailscale manager always starts — reconnects internally via WatchIPNBus.
|
||||||
|
// The capability is only advertised once tailscaled is reachable; the
|
||||||
|
// callback wakes capability subscribers so QML clients see it transition.
|
||||||
|
tailscaleManager = tailscale.NewManager("")
|
||||||
|
tailscaleManager.SetAvailabilityCallback(func(bool) {
|
||||||
|
notifyCapabilityChange()
|
||||||
|
})
|
||||||
|
|
||||||
socketPath := GetSocketPath()
|
socketPath := GetSocketPath()
|
||||||
os.Remove(socketPath)
|
os.Remove(socketPath)
|
||||||
|
|
||||||
@@ -1486,13 +1514,6 @@ func Start(printDocs bool) error {
|
|||||||
log.Info(" - appId : Focused window app ID")
|
log.Info(" - appId : Focused window app ID")
|
||||||
log.Info(" - kbLayout : Current keyboard layout")
|
log.Info(" - kbLayout : Current keyboard layout")
|
||||||
log.Info(" - keymode : Current keybind mode")
|
log.Info(" - keymode : Current keybind mode")
|
||||||
log.Info("ExtWorkspace:")
|
|
||||||
log.Info(" extworkspace.getState - Get current workspace state (groups, workspaces)")
|
|
||||||
log.Info(" extworkspace.activateWorkspace - Activate workspace (params: groupID, workspaceID)")
|
|
||||||
log.Info(" extworkspace.deactivateWorkspace - Deactivate workspace (params: groupID, workspaceID)")
|
|
||||||
log.Info(" extworkspace.removeWorkspace - Remove workspace (params: groupID, workspaceID)")
|
|
||||||
log.Info(" extworkspace.createWorkspace - Create workspace (params: groupID, name)")
|
|
||||||
log.Info(" extworkspace.subscribe - Subscribe to workspace state changes (streaming)")
|
|
||||||
log.Info("Brightness:")
|
log.Info("Brightness:")
|
||||||
log.Info(" brightness.getState - Get current brightness state for all devices")
|
log.Info(" brightness.getState - Get current brightness state for all devices")
|
||||||
log.Info(" brightness.setBrightness - Set device brightness (params: device, percent)")
|
log.Info(" brightness.setBrightness - Set device brightness (params: device, percent)")
|
||||||
@@ -1673,14 +1694,6 @@ func Start(printDocs bool) error {
|
|||||||
log.Debugf("DWL manager unavailable: %v", err)
|
log.Debugf("DWL manager unavailable: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if extworkspace.CheckCapability() {
|
|
||||||
extWorkspaceAvailable.Store(true)
|
|
||||||
log.Info("ExtWorkspace capability detected and will be available on subscription")
|
|
||||||
} else {
|
|
||||||
log.Debug("ExtWorkspace capability not available")
|
|
||||||
extWorkspaceAvailable.Store(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := InitializeWlrOutputManager(); err != nil {
|
if err := InitializeWlrOutputManager(); err != nil {
|
||||||
log.Debugf("WlrOutput manager unavailable: %v", err)
|
log.Debugf("WlrOutput manager unavailable: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1733,6 +1746,10 @@ func Start(printDocs bool) error {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
if err := InitializeSysUpdateManager(); err != nil {
|
||||||
|
log.Warnf("Sysupdate manager unavailable: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
log.Info("")
|
log.Info("")
|
||||||
log.Infof("Ready! Capabilities: %v", getCapabilities().Capabilities)
|
log.Infof("Ready! Capabilities: %v", getCapabilities().Capabilities)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os/exec"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Backend interface {
|
||||||
|
ID() string
|
||||||
|
DisplayName() string
|
||||||
|
Repo() RepoKind
|
||||||
|
IsAvailable(ctx context.Context) bool
|
||||||
|
NeedsAuth() bool
|
||||||
|
RunsInTerminal() bool
|
||||||
|
CheckUpdates(ctx context.Context) ([]Package, error)
|
||||||
|
Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type Selection struct {
|
||||||
|
System Backend
|
||||||
|
Overlay []Backend
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Selection) All() []Backend {
|
||||||
|
if s.System == nil {
|
||||||
|
return s.Overlay
|
||||||
|
}
|
||||||
|
out := make([]Backend, 0, 1+len(s.Overlay))
|
||||||
|
out = append(out, s.System)
|
||||||
|
out = append(out, s.Overlay...)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Selection) Info() []BackendInfo {
|
||||||
|
all := s.All()
|
||||||
|
out := make([]BackendInfo, 0, len(all))
|
||||||
|
for _, b := range all {
|
||||||
|
out = append(out, BackendInfo{
|
||||||
|
ID: b.ID(),
|
||||||
|
DisplayName: b.DisplayName(),
|
||||||
|
Repo: b.Repo(),
|
||||||
|
NeedsAuth: b.NeedsAuth(),
|
||||||
|
RunsInTerminal: b.RunsInTerminal(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
registryMu sync.RWMutex
|
||||||
|
systemCandidates []func() Backend
|
||||||
|
overlayCandidate []func() Backend
|
||||||
|
)
|
||||||
|
|
||||||
|
func RegisterSystemBackend(factory func() Backend) {
|
||||||
|
registryMu.Lock()
|
||||||
|
defer registryMu.Unlock()
|
||||||
|
systemCandidates = append(systemCandidates, factory)
|
||||||
|
}
|
||||||
|
|
||||||
|
func RegisterOverlayBackend(factory func() Backend) {
|
||||||
|
registryMu.Lock()
|
||||||
|
defer registryMu.Unlock()
|
||||||
|
overlayCandidate = append(overlayCandidate, factory)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Select(ctx context.Context) Selection {
|
||||||
|
registryMu.RLock()
|
||||||
|
sys := append([]func() Backend(nil), systemCandidates...)
|
||||||
|
ov := append([]func() Backend(nil), overlayCandidate...)
|
||||||
|
registryMu.RUnlock()
|
||||||
|
|
||||||
|
var sel Selection
|
||||||
|
for _, factory := range sys {
|
||||||
|
b := factory()
|
||||||
|
if !b.IsAvailable(ctx) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sel.System = b
|
||||||
|
break
|
||||||
|
}
|
||||||
|
for _, factory := range ov {
|
||||||
|
b := factory()
|
||||||
|
if !b.IsAvailable(ctx) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sel.Overlay = append(sel.Overlay, b)
|
||||||
|
}
|
||||||
|
return sel
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandExists(name string) bool {
|
||||||
|
_, err := exec.LookPath(name)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
RegisterSystemBackend(func() Backend { return &aptBackend{} })
|
||||||
|
}
|
||||||
|
|
||||||
|
var aptUpgradableLine = regexp.MustCompile(`^([^/]+)/\S+\s+(\S+)\s+\S+\s+\[upgradable from:\s+([^\]]+)\]`)
|
||||||
|
|
||||||
|
type aptBackend struct{}
|
||||||
|
|
||||||
|
func (aptBackend) ID() string { return "apt" }
|
||||||
|
func (aptBackend) DisplayName() string { return "APT" }
|
||||||
|
func (aptBackend) Repo() RepoKind { return RepoSystem }
|
||||||
|
func (aptBackend) NeedsAuth() bool { return true }
|
||||||
|
func (aptBackend) RunsInTerminal() bool { return false }
|
||||||
|
func (aptBackend) IsAvailable(_ context.Context) bool {
|
||||||
|
return commandExists("apt") || commandExists("apt-get")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aptBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
|
||||||
|
cmd := exec.CommandContext(ctx, "apt", "list", "--upgradable")
|
||||||
|
cmd.Env = append(cmd.Environ(), "LC_ALL=C")
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return parseAptUpgradable(string(out)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aptBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
|
||||||
|
bin := "apt-get"
|
||||||
|
if !commandExists(bin) {
|
||||||
|
bin = "apt"
|
||||||
|
}
|
||||||
|
if opts.DryRun {
|
||||||
|
return Run(ctx, []string{bin, "upgrade", "--dry-run"}, RunOptions{
|
||||||
|
Env: []string{"DEBIAN_FRONTEND=noninteractive", "LC_ALL=C"},
|
||||||
|
OnLine: onLine,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if !BackendHasTargets(aptBackend{}, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return Run(ctx, aptUpgradeArgv(bin, opts), RunOptions{OnLine: onLine, AttachStdio: opts.AttachStdio})
|
||||||
|
}
|
||||||
|
|
||||||
|
func aptUpgradeArgv(bin string, opts UpgradeOptions) []string {
|
||||||
|
return privilegedArgv(opts, "env", "DEBIAN_FRONTEND=noninteractive", "LC_ALL=C", bin, "upgrade", "-y")
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAptUpgradable(text string) []Package {
|
||||||
|
if text == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var pkgs []Package
|
||||||
|
for line := range strings.SplitSeq(text, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m := aptUpgradableLine.FindStringSubmatch(line)
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pkgs = append(pkgs, Package{
|
||||||
|
Name: m[1],
|
||||||
|
Repo: RepoSystem,
|
||||||
|
Backend: "apt",
|
||||||
|
FromVersion: m[3],
|
||||||
|
ToVersion: m[2],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return pkgs
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseAptUpgradable(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want []Package
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty",
|
||||||
|
input: "",
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "header line only",
|
||||||
|
input: `Listing... Done
|
||||||
|
`,
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single upgradable",
|
||||||
|
input: `Listing... Done
|
||||||
|
bash/stable 5.2.40-1 amd64 [upgradable from: 5.2.39-1]`,
|
||||||
|
want: []Package{
|
||||||
|
{Name: "bash", Repo: RepoSystem, Backend: "apt", FromVersion: "5.2.39-1", ToVersion: "5.2.40-1"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple architectures and suites",
|
||||||
|
input: `Listing... Done
|
||||||
|
bash/stable 5.2.40-1 amd64 [upgradable from: 5.2.39-1]
|
||||||
|
libfoo/stable-security 1.0.0-2 amd64 [upgradable from: 1.0.0-1]
|
||||||
|
zsh/testing 5.9-6 arm64 [upgradable from: 5.9-5]`,
|
||||||
|
want: []Package{
|
||||||
|
{Name: "bash", Repo: RepoSystem, Backend: "apt", FromVersion: "5.2.39-1", ToVersion: "5.2.40-1"},
|
||||||
|
{Name: "libfoo", Repo: RepoSystem, Backend: "apt", FromVersion: "1.0.0-1", ToVersion: "1.0.0-2"},
|
||||||
|
{Name: "zsh", Repo: RepoSystem, Backend: "apt", FromVersion: "5.9-5", ToVersion: "5.9-6"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "package name with hyphens, dots, plus signs",
|
||||||
|
input: `Listing... Done
|
||||||
|
g++/stable 4:13.3.0-1 amd64 [upgradable from: 4:13.2.0-1]
|
||||||
|
libsdl2-2.0-0/stable 2.30.0+dfsg-1 amd64 [upgradable from: 2.28.5+dfsg-1]`,
|
||||||
|
want: []Package{
|
||||||
|
{Name: "g++", Repo: RepoSystem, Backend: "apt", FromVersion: "4:13.2.0-1", ToVersion: "4:13.3.0-1"},
|
||||||
|
{Name: "libsdl2-2.0-0", Repo: RepoSystem, Backend: "apt", FromVersion: "2.28.5+dfsg-1", ToVersion: "2.30.0+dfsg-1"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-matching lines ignored",
|
||||||
|
input: "WARNING: this is some warning\nbash/stable 5.2.40-1 amd64 [upgradable from: 5.2.39-1]",
|
||||||
|
want: []Package{
|
||||||
|
{Name: "bash", Repo: RepoSystem, Backend: "apt", FromVersion: "5.2.39-1", ToVersion: "5.2.40-1"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := parseAptUpgradable(tt.input)
|
||||||
|
if !reflect.DeepEqual(got, tt.want) {
|
||||||
|
t.Errorf("parseAptUpgradable() = %#v\nwant %#v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
RegisterSystemBackend(func() Backend { return &dnfBackend{bin: "dnf5"} })
|
||||||
|
RegisterSystemBackend(func() Backend { return &dnfBackend{bin: "dnf"} })
|
||||||
|
}
|
||||||
|
|
||||||
|
type dnfBackend struct {
|
||||||
|
bin string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b dnfBackend) ID() string { return b.bin }
|
||||||
|
func (b dnfBackend) DisplayName() string { return strings.ToUpper(b.bin) }
|
||||||
|
func (b dnfBackend) Repo() RepoKind { return RepoSystem }
|
||||||
|
func (b dnfBackend) NeedsAuth() bool { return true }
|
||||||
|
func (b dnfBackend) RunsInTerminal() bool { return false }
|
||||||
|
|
||||||
|
func (b dnfBackend) IsAvailable(ctx context.Context) bool {
|
||||||
|
if !commandExists(b.bin) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if commandExists("rpm-ostree") && ostreeBooted(ctx) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b dnfBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
|
||||||
|
out, err := dnfListUpgrades(ctx, b.bin)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
installed := rpmInstalledVersions(ctx)
|
||||||
|
return parseDnfList(out, b.bin, installed), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b dnfBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
|
||||||
|
if opts.DryRun {
|
||||||
|
return Run(ctx, []string{b.bin, "upgrade", "--assumeno"}, RunOptions{OnLine: onLine})
|
||||||
|
}
|
||||||
|
if !BackendHasTargets(b, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return Run(ctx, dnfUpgradeArgv(b.bin, opts), RunOptions{OnLine: onLine, AttachStdio: opts.AttachStdio})
|
||||||
|
}
|
||||||
|
|
||||||
|
func dnfUpgradeArgv(bin string, opts UpgradeOptions) []string {
|
||||||
|
return privilegedArgv(opts, bin, "upgrade", "--refresh", "-y")
|
||||||
|
}
|
||||||
|
|
||||||
|
func dnfListUpgrades(ctx context.Context, bin string) (string, error) {
|
||||||
|
argv := dnfCheckUpdatesArgv(bin)
|
||||||
|
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err == nil {
|
||||||
|
return string(out), nil
|
||||||
|
}
|
||||||
|
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && exitErr.ExitCode() == 100 {
|
||||||
|
return string(out), nil
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
func dnfCheckUpdatesArgv(bin string) []string {
|
||||||
|
subcommand := "check-update"
|
||||||
|
if bin == "dnf5" {
|
||||||
|
subcommand = "check-upgrade"
|
||||||
|
}
|
||||||
|
return []string{bin, subcommand, "--refresh", "--quiet"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpmInstalledVersions(ctx context.Context) map[string]string {
|
||||||
|
out, err := exec.CommandContext(ctx, "rpm", "-qa", "--qf", `%{NAME}\t%{VERSION}-%{RELEASE}\n`).Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m := make(map[string]string)
|
||||||
|
for line := range strings.SplitSeq(string(out), "\n") {
|
||||||
|
name, ver, ok := strings.Cut(line, "\t")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m[name] = ver
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDnfList(text, backendID string, installed map[string]string) []Package {
|
||||||
|
if text == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var pkgs []Package
|
||||||
|
for line := range strings.SplitSeq(text, "\n") {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nameArch := fields[0]
|
||||||
|
version := fields[1]
|
||||||
|
dot := strings.LastIndex(nameArch, ".")
|
||||||
|
if dot <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !looksLikeRpmVersion(version) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := nameArch[:dot]
|
||||||
|
pkgs = append(pkgs, Package{
|
||||||
|
Name: nameArch,
|
||||||
|
Repo: RepoSystem,
|
||||||
|
Backend: backendID,
|
||||||
|
FromVersion: installed[name],
|
||||||
|
ToVersion: version,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return pkgs
|
||||||
|
}
|
||||||
|
|
||||||
|
func looksLikeRpmVersion(s string) bool {
|
||||||
|
if s == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range s {
|
||||||
|
if r >= '0' && r <= '9' {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseDnfList(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
backendID string
|
||||||
|
installed map[string]string
|
||||||
|
want []Package
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty",
|
||||||
|
input: "",
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single package with installed cross-ref",
|
||||||
|
input: "bash.x86_64 5.2.40-1.fc41 updates",
|
||||||
|
backendID: "dnf",
|
||||||
|
installed: map[string]string{"bash": "5.2.39-1.fc41"},
|
||||||
|
want: []Package{
|
||||||
|
{Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf", FromVersion: "5.2.39-1.fc41", ToVersion: "5.2.40-1.fc41"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "noarch package and missing installed entry",
|
||||||
|
input: `bash.x86_64 5.2.40-1.fc41 updates
|
||||||
|
fonts-misc.noarch 1.0.5-2.fc41 updates`,
|
||||||
|
backendID: "dnf",
|
||||||
|
installed: map[string]string{"bash": "5.2.39-1.fc41"},
|
||||||
|
want: []Package{
|
||||||
|
{Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf", FromVersion: "5.2.39-1.fc41", ToVersion: "5.2.40-1.fc41"},
|
||||||
|
{Name: "fonts-misc.noarch", Repo: RepoSystem, Backend: "dnf", FromVersion: "", ToVersion: "1.0.5-2.fc41"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "skips header rows",
|
||||||
|
input: `Available
|
||||||
|
Upgrades
|
||||||
|
bash.x86_64 5.2.40-1.fc41 updates`,
|
||||||
|
backendID: "dnf",
|
||||||
|
installed: nil,
|
||||||
|
want: []Package{
|
||||||
|
{Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf", FromVersion: "", ToVersion: "5.2.40-1.fc41"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "skips lines with too few fields",
|
||||||
|
input: "incomplete",
|
||||||
|
backendID: "dnf",
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "skips dnf5 banner / column header lines",
|
||||||
|
input: `Updates available
|
||||||
|
Last metadata expiration check: 0:01:23 ago on Tue Apr 29 14:00:00 2026.
|
||||||
|
Package Version Repository Size
|
||||||
|
bash.x86_64 5.2.40-1.fc41 updates`,
|
||||||
|
backendID: "dnf",
|
||||||
|
installed: nil,
|
||||||
|
want: []Package{
|
||||||
|
{Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf", FromVersion: "", ToVersion: "5.2.40-1.fc41"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "skips dnf warning lines while keeping package rows",
|
||||||
|
input: `Failed to expire repository cache in path "/home/user/.cache/libdnf5/updates": cannot open file
|
||||||
|
example-driver.x86_64 2:9.8.7-1.fc99 updates
|
||||||
|
example-tool.noarch 1.2.3^45.gitabcdef-1.fc99 copr`,
|
||||||
|
backendID: "dnf5",
|
||||||
|
installed: map[string]string{
|
||||||
|
"example-driver": "2:9.8.6-1.fc99",
|
||||||
|
"example-tool": "1.2.2^44.gitabcdef-1.fc99",
|
||||||
|
},
|
||||||
|
want: []Package{
|
||||||
|
{Name: "example-driver.x86_64", Repo: RepoSystem, Backend: "dnf5", FromVersion: "2:9.8.6-1.fc99", ToVersion: "2:9.8.7-1.fc99"},
|
||||||
|
{Name: "example-tool.noarch", Repo: RepoSystem, Backend: "dnf5", FromVersion: "1.2.2^44.gitabcdef-1.fc99", ToVersion: "1.2.3^45.gitabcdef-1.fc99"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := parseDnfList(tt.input, tt.backendID, tt.installed)
|
||||||
|
if !reflect.DeepEqual(got, tt.want) {
|
||||||
|
t.Errorf("parseDnfList() = %#v\nwant %#v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDnfCheckUpdatesArgv(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
bin string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{bin: "dnf5", want: []string{"dnf5", "check-upgrade", "--refresh", "--quiet"}},
|
||||||
|
{bin: "dnf", want: []string{"dnf", "check-update", "--refresh", "--quiet"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.bin, func(t *testing.T) {
|
||||||
|
got := dnfCheckUpdatesArgv(tt.bin)
|
||||||
|
if !reflect.DeepEqual(got, tt.want) {
|
||||||
|
t.Fatalf("dnfCheckUpdatesArgv(%q) = %#v, want %#v", tt.bin, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
RegisterOverlayBackend(func() Backend { return &flatpakBackend{} })
|
||||||
|
}
|
||||||
|
|
||||||
|
type flatpakBackend struct{}
|
||||||
|
|
||||||
|
func (flatpakBackend) ID() string { return "flatpak" }
|
||||||
|
func (flatpakBackend) DisplayName() string { return "Flatpak" }
|
||||||
|
func (flatpakBackend) Repo() RepoKind { return RepoFlatpak }
|
||||||
|
func (flatpakBackend) NeedsAuth() bool { return false }
|
||||||
|
func (flatpakBackend) RunsInTerminal() bool { return false }
|
||||||
|
func (flatpakBackend) IsAvailable(_ context.Context) bool { return commandExists("flatpak") }
|
||||||
|
|
||||||
|
func (flatpakBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
|
||||||
|
// Run `flatpak update`
|
||||||
|
cmd := exec.CommandContext(ctx, "flatpak", "update")
|
||||||
|
cmd.Stdin = strings.NewReader("n\nn\n") // decline up to 2 installation prompts
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && exitErr.ExitCode() == 1 && len(out) > 0 {
|
||||||
|
} else if len(out) == 0 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
installed := flatpakInstalled(ctx)
|
||||||
|
return parseFlatpakUpdateOutput(string(out), installed), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type flatpakInstalledEntry struct {
|
||||||
|
version string
|
||||||
|
branch string
|
||||||
|
}
|
||||||
|
|
||||||
|
func flatpakInstalled(ctx context.Context) map[string]flatpakInstalledEntry {
|
||||||
|
m := flatpakListInstalled(ctx, false)
|
||||||
|
if m == nil {
|
||||||
|
m = make(map[string]flatpakInstalledEntry)
|
||||||
|
}
|
||||||
|
for k, v := range flatpakListInstalled(ctx, true) {
|
||||||
|
if _, exists := m[k]; !exists {
|
||||||
|
m[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func flatpakListInstalled(ctx context.Context, system bool) map[string]flatpakInstalledEntry {
|
||||||
|
args := []string{"flatpak", "list", "--columns=application,version,branch"}
|
||||||
|
if system {
|
||||||
|
args = append(args, "--system")
|
||||||
|
}
|
||||||
|
out, err := exec.CommandContext(ctx, args[0], args[1:]...).Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m := make(map[string]flatpakInstalledEntry)
|
||||||
|
for line := range strings.SplitSeq(string(out), "\n") {
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields := strings.Split(line, "\t")
|
||||||
|
if len(fields) == 0 || fields[0] == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
appID := fields[0]
|
||||||
|
entry := flatpakInstalledEntry{}
|
||||||
|
if len(fields) > 1 {
|
||||||
|
entry.version = fields[1]
|
||||||
|
}
|
||||||
|
if len(fields) > 2 {
|
||||||
|
entry.branch = fields[2]
|
||||||
|
}
|
||||||
|
key := appID
|
||||||
|
if entry.branch != "" {
|
||||||
|
key = appID + "//" + entry.branch
|
||||||
|
}
|
||||||
|
m[key] = entry
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (flatpakBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
|
||||||
|
if opts.DryRun {
|
||||||
|
return Run(ctx, []string{"flatpak", "update", "--no-deploy", "-y"}, RunOptions{OnLine: onLine})
|
||||||
|
}
|
||||||
|
if !BackendHasTargets(flatpakBackend{}, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return Run(ctx, flatpakUpgradeArgv(), RunOptions{OnLine: onLine})
|
||||||
|
}
|
||||||
|
|
||||||
|
func flatpakUpgradeArgv() []string {
|
||||||
|
return []string{"flatpak", "update", "-y", "--noninteractive"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFlatpakUpdateOutput(text string, installed map[string]flatpakInstalledEntry) []Package {
|
||||||
|
var pkgs []Package
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
for line := range strings.SplitSeq(text, "\n") {
|
||||||
|
p := parseFlatpakUpdateRow(strings.TrimRight(line, "\r"), installed)
|
||||||
|
if p == nil || seen[p.Ref] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[p.Ref] = true
|
||||||
|
pkgs = append(pkgs, *p)
|
||||||
|
}
|
||||||
|
return pkgs
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFlatpakUpdateRow(line string, installed map[string]flatpakInstalledEntry) *Package {
|
||||||
|
// Row format: " N.\t<name>\t<appID>\t<branch>\t<op>\t<remote>\t<size>"
|
||||||
|
fields := strings.Split(line, "\t")
|
||||||
|
if len(fields) < 5 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// First field must look like " N." (optional whitespace, digits, period)
|
||||||
|
rowField := strings.TrimSpace(fields[0])
|
||||||
|
if len(rowField) < 2 || rowField[len(rowField)-1] != '.' {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, c := range rowField[:len(rowField)-1] {
|
||||||
|
if c < '0' || c > '9' {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
appID := strings.TrimSpace(fields[2])
|
||||||
|
branch := strings.TrimSpace(fields[3])
|
||||||
|
op := strings.TrimSpace(fields[4])
|
||||||
|
if appID == "" || op == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch op {
|
||||||
|
case "i", "u", "r": // install, update, reinstall
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ref := appID
|
||||||
|
if branch != "" {
|
||||||
|
ref = appID + "//" + branch
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.TrimSpace(fields[1])
|
||||||
|
if name == "" {
|
||||||
|
name = appID
|
||||||
|
}
|
||||||
|
|
||||||
|
var from string
|
||||||
|
if op != "i" {
|
||||||
|
if inst, ok := installed[ref]; ok {
|
||||||
|
from = inst.version
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Package{
|
||||||
|
Name: name,
|
||||||
|
Repo: RepoFlatpak,
|
||||||
|
Backend: "flatpak",
|
||||||
|
FromVersion: from,
|
||||||
|
Ref: ref,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseFlatpakUpdateOutput(t *testing.T) {
|
||||||
|
realOutput := "Looking for updates…\n\n\n 1.\t \torg.gtk.Gtk3theme.adw-gtk3-dark\t3.22\ti\tflathub\t< 131.4 kB\n\nProceed with these changes to the system installation? [Y/n]: n\n"
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
installed map[string]flatpakInstalledEntry
|
||||||
|
want []Package
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty output",
|
||||||
|
input: "",
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nothing to do",
|
||||||
|
input: "Looking for updates…\n\nNothing to do.\n",
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "real flatpak update output — new install",
|
||||||
|
input: realOutput,
|
||||||
|
want: []Package{
|
||||||
|
{
|
||||||
|
Name: "org.gtk.Gtk3theme.adw-gtk3-dark",
|
||||||
|
Repo: RepoFlatpak,
|
||||||
|
Backend: "flatpak",
|
||||||
|
FromVersion: "",
|
||||||
|
Ref: "org.gtk.Gtk3theme.adw-gtk3-dark//3.22",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "update with installed version",
|
||||||
|
input: "Looking for updates…\n\n 1.\tSlack\tcom.slack.Slack\tstable\tu\tflathub\t< 5.2 MB\n\nProceed? [Y/n]: n\n",
|
||||||
|
installed: map[string]flatpakInstalledEntry{
|
||||||
|
"com.slack.Slack//stable": {version: "4.40.0"},
|
||||||
|
},
|
||||||
|
want: []Package{
|
||||||
|
{
|
||||||
|
Name: "Slack",
|
||||||
|
Repo: RepoFlatpak,
|
||||||
|
Backend: "flatpak",
|
||||||
|
FromVersion: "4.40.0",
|
||||||
|
Ref: "com.slack.Slack//stable",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "reinstall op included",
|
||||||
|
input: " 1.\t\torg.freedesktop.Platform\t25.08\tr\tflathub\t< 100 MB\n",
|
||||||
|
want: []Package{
|
||||||
|
{
|
||||||
|
Name: "org.freedesktop.Platform",
|
||||||
|
Repo: RepoFlatpak,
|
||||||
|
Backend: "flatpak",
|
||||||
|
Ref: "org.freedesktop.Platform//25.08",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown op excluded",
|
||||||
|
input: " 1.\t\torg.freedesktop.Platform\t25.08\te\tflathub\t0\n",
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "deduplicates same ref",
|
||||||
|
input: " 1.\t\tcom.example.App\tstable\ti\tflathub\t< 1 MB\n 2.\t\tcom.example.App\tstable\ti\tflathub\t< 1 MB\n",
|
||||||
|
want: []Package{
|
||||||
|
{
|
||||||
|
Name: "com.example.App",
|
||||||
|
Repo: RepoFlatpak,
|
||||||
|
Backend: "flatpak",
|
||||||
|
Ref: "com.example.App//stable",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-table lines ignored",
|
||||||
|
input: "Looking for updates…\nSome warning line\nID\tBranch\tOp\n 1.\t\tcom.example.App\tstable\ti\tflathub\t< 1 MB\nProceed? [Y/n]: n\n",
|
||||||
|
want: []Package{
|
||||||
|
{
|
||||||
|
Name: "com.example.App",
|
||||||
|
Repo: RepoFlatpak,
|
||||||
|
Backend: "flatpak",
|
||||||
|
Ref: "com.example.App//stable",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := parseFlatpakUpdateOutput(tt.input, tt.installed)
|
||||||
|
if !reflect.DeepEqual(got, tt.want) {
|
||||||
|
t.Errorf("parseFlatpakUpdateOutput() = %#v\nwant %#v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
RegisterSystemBackend(func() Backend { return &archHelperBackend{id: "paru"} })
|
||||||
|
RegisterSystemBackend(func() Backend { return &archHelperBackend{id: "yay"} })
|
||||||
|
RegisterSystemBackend(func() Backend { return &pacmanBackend{} })
|
||||||
|
}
|
||||||
|
|
||||||
|
var archUpdateLine = regexp.MustCompile(`^(\S+)\s+(\S+)\s+->\s+(\S+)`)
|
||||||
|
|
||||||
|
type pacmanBackend struct{}
|
||||||
|
|
||||||
|
func (pacmanBackend) ID() string { return "pacman" }
|
||||||
|
func (pacmanBackend) DisplayName() string { return "Pacman" }
|
||||||
|
func (pacmanBackend) Repo() RepoKind { return RepoSystem }
|
||||||
|
func (pacmanBackend) NeedsAuth() bool { return true }
|
||||||
|
func (pacmanBackend) RunsInTerminal() bool { return false }
|
||||||
|
func (pacmanBackend) IsAvailable(_ context.Context) bool { return commandExists("pacman") }
|
||||||
|
|
||||||
|
func (b pacmanBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
|
||||||
|
out, err := pacmanRepoUpdates(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return parseArchUpdates(out, b.ID(), RepoSystem), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b pacmanBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
|
||||||
|
if opts.DryRun {
|
||||||
|
return Run(ctx, []string{"pacman", "-Sup"}, RunOptions{OnLine: onLine})
|
||||||
|
}
|
||||||
|
if !BackendHasTargets(b, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return Run(ctx, pacmanUpgradeArgv(opts), RunOptions{OnLine: onLine, AttachStdio: opts.AttachStdio})
|
||||||
|
}
|
||||||
|
|
||||||
|
func pacmanUpgradeArgv(opts UpgradeOptions) []string {
|
||||||
|
return privilegedArgv(opts, "pacman", "-Syu", "--noconfirm", "--needed")
|
||||||
|
}
|
||||||
|
|
||||||
|
type archHelperBackend struct {
|
||||||
|
id string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b archHelperBackend) ID() string { return b.id }
|
||||||
|
func (b archHelperBackend) Repo() RepoKind { return RepoSystem }
|
||||||
|
func (b archHelperBackend) NeedsAuth() bool { return true }
|
||||||
|
func (b archHelperBackend) RunsInTerminal() bool {
|
||||||
|
return os.Getenv("DMS_FORCE_PKEXEC") != "1"
|
||||||
|
}
|
||||||
|
func (b archHelperBackend) IsAvailable(_ context.Context) bool { return commandExists(b.id) }
|
||||||
|
|
||||||
|
func (b archHelperBackend) DisplayName() string {
|
||||||
|
switch b.id {
|
||||||
|
case "paru":
|
||||||
|
return "Paru (AUR)"
|
||||||
|
case "yay":
|
||||||
|
return "Yay (AUR)"
|
||||||
|
default:
|
||||||
|
return b.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b archHelperBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
|
||||||
|
repoOut, err := pacmanRepoUpdates(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pkgs := parseArchUpdates(repoOut, b.id, RepoSystem)
|
||||||
|
|
||||||
|
aurOut, err := capturePermissive(ctx, b.id, "-Qua")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pkgs = append(pkgs, parseArchUpdates(aurOut, b.id, RepoAUR)...)
|
||||||
|
return pkgs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b archHelperBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
|
||||||
|
if opts.DryRun {
|
||||||
|
return Run(ctx, []string{b.id, "-Sup"}, RunOptions{OnLine: onLine})
|
||||||
|
}
|
||||||
|
if !BackendHasTargets(b, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if os.Getenv("DMS_FORCE_PKEXEC") == "1" {
|
||||||
|
argv := append([]string{"pkexec"}, archHelperUpgradeArgv(b.id, opts.IncludeAUR)...)
|
||||||
|
return Run(ctx, argv, RunOptions{OnLine: onLine, AttachStdio: opts.AttachStdio})
|
||||||
|
}
|
||||||
|
term := findTerminal(opts.Terminal)
|
||||||
|
if term == "" {
|
||||||
|
return fmt.Errorf("no terminal found (pick one in DMS settings, set $TERMINAL, or install kitty/ghostty/foot/alacritty)")
|
||||||
|
}
|
||||||
|
cmd := strings.Join(archHelperUpgradeArgv(b.id, opts.IncludeAUR), " ")
|
||||||
|
title := fmt.Sprintf("DMS — System Update (%s)", b.id)
|
||||||
|
return Run(ctx, wrapInTerminal(term, title, cmd), RunOptions{OnLine: onLine})
|
||||||
|
}
|
||||||
|
|
||||||
|
func archHelperUpgradeArgv(id string, includeAUR bool) []string {
|
||||||
|
argv := []string{id, "-Syu", "--noconfirm", "--needed"}
|
||||||
|
if !includeAUR {
|
||||||
|
argv = append(argv, "--repo")
|
||||||
|
}
|
||||||
|
return argv
|
||||||
|
}
|
||||||
|
|
||||||
|
func pacmanRepoUpdates(ctx context.Context) (string, error) {
|
||||||
|
if commandExists("checkupdates") {
|
||||||
|
return capturePermissive(ctx, "checkupdates")
|
||||||
|
}
|
||||||
|
if commandExists("fakeroot") {
|
||||||
|
out, err := pacmanCheckViaFakeroot(ctx)
|
||||||
|
if err == nil {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
log.Warnf("[sysupdate] fakeroot db refresh failed, falling back to stale pacman -Qu: %v", err)
|
||||||
|
}
|
||||||
|
return capturePermissive(ctx, "pacman", "-Qu")
|
||||||
|
}
|
||||||
|
|
||||||
|
func pacmanCheckViaFakeroot(ctx context.Context) (string, error) {
|
||||||
|
dir, err := pacmanPrivateDB()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := seedPacmanDB(dir); err != nil {
|
||||||
|
return "", fmt.Errorf("seed sync db: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh := exec.CommandContext(ctx, "fakeroot", "--", "pacman", "-Sy", "--dbpath", dir, "--logfile", "/dev/null", "--disable-sandbox")
|
||||||
|
if out, err := refresh.CombinedOutput(); err != nil {
|
||||||
|
return "", fmt.Errorf("fakeroot pacman -Sy: %w (%s)", err, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return capturePermissive(ctx, "pacman", "-Qu", "--dbpath", dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedPacmanDB(dir string) error {
|
||||||
|
syncDir := filepath.Join(dir, "sync")
|
||||||
|
if err := os.MkdirAll(syncDir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dbs, err := filepath.Glob("/var/lib/pacman/sync/*.db")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, src := range dbs {
|
||||||
|
if err := copyFile(src, filepath.Join(syncDir, filepath.Base(src))); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
localLink := filepath.Join(dir, "local")
|
||||||
|
if fi, err := os.Lstat(localLink); err == nil {
|
||||||
|
if fi.Mode()&os.ModeSymlink == 0 {
|
||||||
|
if err := os.RemoveAll(localLink); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return os.Symlink("/var/lib/pacman/local", localLink)
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyFile(src, dst string) error {
|
||||||
|
in, err := os.Open(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer in.Close()
|
||||||
|
fi, err := in.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(out, in); err != nil {
|
||||||
|
out.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := out.Sync(); err != nil {
|
||||||
|
out.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := out.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Preserve mtime so libalpm's If-Modified-Since against --dbpath actually
|
||||||
|
// refetches when the mirror has newer content; otherwise the seeded copy's
|
||||||
|
// fresh mtime always beats the server's Last-Modified and the private DB
|
||||||
|
// stays stuck at whatever /var/lib/pacman/sync contained at seed time.
|
||||||
|
return os.Chtimes(dst, fi.ModTime(), fi.ModTime())
|
||||||
|
}
|
||||||
|
|
||||||
|
func pacmanPrivateDB() (string, error) {
|
||||||
|
tmp := os.Getenv("TMPDIR")
|
||||||
|
if tmp == "" {
|
||||||
|
tmp = "/tmp"
|
||||||
|
}
|
||||||
|
dir := filepath.Join(tmp, fmt.Sprintf("dms-checkup-db-%d", os.Getuid()))
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return dir, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func capturePermissive(ctx context.Context, argv ...string) (string, error) {
|
||||||
|
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err == nil {
|
||||||
|
return string(out), nil
|
||||||
|
}
|
||||||
|
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
|
||||||
|
switch exitErr.ExitCode() {
|
||||||
|
case 1, 2:
|
||||||
|
return string(out), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseArchUpdates(text, backendID string, repo RepoKind) []Package {
|
||||||
|
if text == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var pkgs []Package
|
||||||
|
for line := range strings.SplitSeq(text, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m := archUpdateLine.FindStringSubmatch(line)
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p := Package{
|
||||||
|
Name: m[1],
|
||||||
|
Repo: repo,
|
||||||
|
Backend: backendID,
|
||||||
|
FromVersion: m[2],
|
||||||
|
ToVersion: m[3],
|
||||||
|
}
|
||||||
|
if repo == RepoAUR {
|
||||||
|
p.ChangelogURL = "https://aur.archlinux.org/packages/" + p.Name
|
||||||
|
}
|
||||||
|
pkgs = append(pkgs, p)
|
||||||
|
}
|
||||||
|
return pkgs
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseArchUpdates(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
backendID string
|
||||||
|
repo RepoKind
|
||||||
|
want []Package
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty",
|
||||||
|
input: "",
|
||||||
|
backendID: "paru",
|
||||||
|
repo: RepoSystem,
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "whitespace only",
|
||||||
|
input: " \n\n \n",
|
||||||
|
backendID: "paru",
|
||||||
|
repo: RepoSystem,
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single repo update",
|
||||||
|
input: "bat 0.26.0-1 -> 0.26.1-2",
|
||||||
|
backendID: "paru",
|
||||||
|
repo: RepoSystem,
|
||||||
|
want: []Package{
|
||||||
|
{Name: "bat", Repo: RepoSystem, Backend: "paru", FromVersion: "0.26.0-1", ToVersion: "0.26.1-2"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple updates with epoch versions",
|
||||||
|
input: `cups 2:2.4.18-1 -> 2:2.4.19-1
|
||||||
|
linux 6.18.0-1 -> 6.18.1-1
|
||||||
|
mesa 26.4.0-1 -> 26.4.1-1`,
|
||||||
|
backendID: "paru",
|
||||||
|
repo: RepoSystem,
|
||||||
|
want: []Package{
|
||||||
|
{Name: "cups", Repo: RepoSystem, Backend: "paru", FromVersion: "2:2.4.18-1", ToVersion: "2:2.4.19-1"},
|
||||||
|
{Name: "linux", Repo: RepoSystem, Backend: "paru", FromVersion: "6.18.0-1", ToVersion: "6.18.1-1"},
|
||||||
|
{Name: "mesa", Repo: RepoSystem, Backend: "paru", FromVersion: "26.4.0-1", ToVersion: "26.4.1-1"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "AUR update with changelog url",
|
||||||
|
input: "google-chrome 147.0.7727.116-1 -> 147.0.7727.137-1",
|
||||||
|
backendID: "paru",
|
||||||
|
repo: RepoAUR,
|
||||||
|
want: []Package{
|
||||||
|
{
|
||||||
|
Name: "google-chrome",
|
||||||
|
Repo: RepoAUR,
|
||||||
|
Backend: "paru",
|
||||||
|
FromVersion: "147.0.7727.116-1",
|
||||||
|
ToVersion: "147.0.7727.137-1",
|
||||||
|
ChangelogURL: "https://aur.archlinux.org/packages/google-chrome",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "git package latest-commit marker",
|
||||||
|
input: "niri-git 26.04.r5.ga85b922-1 -> latest-commit",
|
||||||
|
backendID: "yay",
|
||||||
|
repo: RepoAUR,
|
||||||
|
want: []Package{
|
||||||
|
{
|
||||||
|
Name: "niri-git",
|
||||||
|
Repo: RepoAUR,
|
||||||
|
Backend: "yay",
|
||||||
|
FromVersion: "26.04.r5.ga85b922-1",
|
||||||
|
ToVersion: "latest-commit",
|
||||||
|
ChangelogURL: "https://aur.archlinux.org/packages/niri-git",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "skips lines that don't match arrow format",
|
||||||
|
input: `bat 0.26.0-1 -> 0.26.1-2
|
||||||
|
this is not an update line
|
||||||
|
foo`,
|
||||||
|
backendID: "pacman",
|
||||||
|
repo: RepoSystem,
|
||||||
|
want: []Package{
|
||||||
|
{Name: "bat", Repo: RepoSystem, Backend: "pacman", FromVersion: "0.26.0-1", ToVersion: "0.26.1-2"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "extra whitespace tolerated",
|
||||||
|
input: " bat 0.26.0-1 -> 0.26.1-2 ",
|
||||||
|
backendID: "paru",
|
||||||
|
repo: RepoSystem,
|
||||||
|
want: []Package{
|
||||||
|
{Name: "bat", Repo: RepoSystem, Backend: "paru", FromVersion: "0.26.0-1", ToVersion: "0.26.1-2"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := parseArchUpdates(tt.input, tt.backendID, tt.repo)
|
||||||
|
if !reflect.DeepEqual(got, tt.want) {
|
||||||
|
t.Errorf("parseArchUpdates() = %#v\nwant %#v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os/exec"
|
||||||
|
)
|
||||||
|
|
||||||
|
const ostreeExitUpdateAvailable = 77
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
RegisterSystemBackend(func() Backend { return &rpmOstreeBackend{} })
|
||||||
|
}
|
||||||
|
|
||||||
|
type rpmOstreeBackend struct{}
|
||||||
|
|
||||||
|
func (rpmOstreeBackend) ID() string { return "rpm-ostree" }
|
||||||
|
func (rpmOstreeBackend) DisplayName() string { return "rpm-ostree" }
|
||||||
|
func (rpmOstreeBackend) Repo() RepoKind { return RepoOSTree }
|
||||||
|
func (rpmOstreeBackend) NeedsAuth() bool { return true }
|
||||||
|
func (rpmOstreeBackend) RunsInTerminal() bool { return false }
|
||||||
|
|
||||||
|
func (b rpmOstreeBackend) IsAvailable(ctx context.Context) bool {
|
||||||
|
if !commandExists("rpm-ostree") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return ostreeBooted(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ostreeStatus struct {
|
||||||
|
Deployments []ostreeDeployment `json:"deployments"`
|
||||||
|
CachedUpdate *ostreeCached `json:"cached-update"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ostreeDeployment struct {
|
||||||
|
Origin string `json:"origin"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Timestamp int64 `json:"timestamp"`
|
||||||
|
Booted bool `json:"booted"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ostreeCached struct {
|
||||||
|
Origin string `json:"origin"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Timestamp int64 `json:"timestamp"`
|
||||||
|
Checksum string `json:"checksum"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ostreeBooted(ctx context.Context) bool {
|
||||||
|
cmd := exec.CommandContext(ctx, "rpm-ostree", "status", "--json")
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var s ostreeStatus
|
||||||
|
if err := json.Unmarshal(out, &s); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return len(s.Deployments) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rpmOstreeBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
|
||||||
|
cmd := exec.CommandContext(ctx, "rpm-ostree", "upgrade", "--check")
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
exitErr, ok := errors.AsType[*exec.ExitError](err)
|
||||||
|
if !ok || exitErr.ExitCode() != ostreeExitUpdateAvailable {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
statusOut, err := exec.CommandContext(ctx, "rpm-ostree", "status", "--json").Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return parseRpmOstreeStatus(statusOut)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRpmOstreeStatus(statusOut []byte) ([]Package, error) {
|
||||||
|
var s ostreeStatus
|
||||||
|
if err := json.Unmarshal(statusOut, &s); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if s.CachedUpdate == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
booted := bootedDeployment(s.Deployments)
|
||||||
|
from := ""
|
||||||
|
if booted != nil {
|
||||||
|
from = booted.Version
|
||||||
|
}
|
||||||
|
if from == s.CachedUpdate.Version {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
name := s.CachedUpdate.Origin
|
||||||
|
if name == "" {
|
||||||
|
name = "system"
|
||||||
|
}
|
||||||
|
return []Package{{
|
||||||
|
Name: name,
|
||||||
|
Repo: RepoOSTree,
|
||||||
|
Backend: "rpm-ostree",
|
||||||
|
FromVersion: from,
|
||||||
|
ToVersion: s.CachedUpdate.Version,
|
||||||
|
}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func bootedDeployment(deps []ostreeDeployment) *ostreeDeployment {
|
||||||
|
for i := range deps {
|
||||||
|
if deps[i].Booted {
|
||||||
|
return &deps[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rpmOstreeBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
|
||||||
|
if !BackendHasTargets(rpmOstreeBackend{}, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return Run(ctx, rpmOstreeUpgradeArgv(opts), RunOptions{OnLine: onLine, AttachStdio: opts.AttachStdio})
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpmOstreeUpgradeArgv(opts UpgradeOptions) []string {
|
||||||
|
argv := []string{"rpm-ostree", "upgrade"}
|
||||||
|
if opts.DryRun {
|
||||||
|
argv = append(argv, "--check")
|
||||||
|
}
|
||||||
|
return argv
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseRpmOstreeStatus(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want []Package
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no cached update",
|
||||||
|
input: `{"deployments":[{"version":"39.20240101.0","booted":true}],"cached-update":null}`,
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cached update available, booted version differs",
|
||||||
|
input: `{
|
||||||
|
"deployments": [
|
||||||
|
{"origin": "fedora:fedora/x86_64/silverblue", "version": "39.20240101.0", "booted": true},
|
||||||
|
{"origin": "fedora:fedora/x86_64/silverblue", "version": "39.20231215.0", "booted": false}
|
||||||
|
],
|
||||||
|
"cached-update": {
|
||||||
|
"origin": "fedora:fedora/x86_64/silverblue",
|
||||||
|
"version": "39.20240115.0",
|
||||||
|
"checksum": "abc123"
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
want: []Package{
|
||||||
|
{
|
||||||
|
Name: "fedora:fedora/x86_64/silverblue",
|
||||||
|
Repo: RepoOSTree,
|
||||||
|
Backend: "rpm-ostree",
|
||||||
|
FromVersion: "39.20240101.0",
|
||||||
|
ToVersion: "39.20240115.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cached update equals booted version (no real update)",
|
||||||
|
input: `{
|
||||||
|
"deployments": [{"version": "39.20240101.0", "booted": true}],
|
||||||
|
"cached-update": {"origin": "x", "version": "39.20240101.0"}
|
||||||
|
}`,
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no booted deployment falls back to empty from",
|
||||||
|
input: `{
|
||||||
|
"deployments": [{"version": "39.20240101.0", "booted": false}],
|
||||||
|
"cached-update": {"origin": "fedora:silverblue", "version": "39.20240115.0"}
|
||||||
|
}`,
|
||||||
|
want: []Package{
|
||||||
|
{
|
||||||
|
Name: "fedora:silverblue",
|
||||||
|
Repo: RepoOSTree,
|
||||||
|
Backend: "rpm-ostree",
|
||||||
|
FromVersion: "",
|
||||||
|
ToVersion: "39.20240115.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing origin defaults to system",
|
||||||
|
input: `{
|
||||||
|
"deployments": [{"version": "1.0", "booted": true}],
|
||||||
|
"cached-update": {"version": "1.1"}
|
||||||
|
}`,
|
||||||
|
want: []Package{
|
||||||
|
{
|
||||||
|
Name: "system",
|
||||||
|
Repo: RepoOSTree,
|
||||||
|
Backend: "rpm-ostree",
|
||||||
|
FromVersion: "1.0",
|
||||||
|
ToVersion: "1.1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "malformed JSON",
|
||||||
|
input: `{not json`,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := parseRpmOstreeStatus([]byte(tt.input))
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Fatalf("parseRpmOstreeStatus() err = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
if tt.wantErr {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, tt.want) {
|
||||||
|
t.Errorf("parseRpmOstreeStatus() = %#v\nwant %#v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/xml"
|
||||||
|
"errors"
|
||||||
|
"os/exec"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
RegisterSystemBackend(func() Backend { return &zypperBackend{} })
|
||||||
|
}
|
||||||
|
|
||||||
|
type zypperBackend struct{}
|
||||||
|
|
||||||
|
func (zypperBackend) ID() string { return "zypper" }
|
||||||
|
func (zypperBackend) DisplayName() string { return "Zypper" }
|
||||||
|
func (zypperBackend) Repo() RepoKind { return RepoSystem }
|
||||||
|
func (zypperBackend) NeedsAuth() bool { return true }
|
||||||
|
func (zypperBackend) RunsInTerminal() bool { return false }
|
||||||
|
func (zypperBackend) IsAvailable(_ context.Context) bool { return commandExists("zypper") }
|
||||||
|
|
||||||
|
type zypperUpdateList struct {
|
||||||
|
XMLName xml.Name `xml:"stream"`
|
||||||
|
Updates []zypperUpdate `xml:"update-list>update"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type zypperUpdate struct {
|
||||||
|
Name string `xml:"name,attr"`
|
||||||
|
Edition string `xml:"edition,attr"`
|
||||||
|
EditionOld string `xml:"edition-old,attr"`
|
||||||
|
Kind string `xml:"kind,attr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (zypperBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
|
||||||
|
cmd := exec.CommandContext(ctx, "zypper", "--non-interactive", "--xmlout", "list-updates")
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
|
||||||
|
switch exitErr.ExitCode() {
|
||||||
|
case 100, 101, 102, 103:
|
||||||
|
err = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parseZypperXML(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseZypperXML(out []byte) ([]Package, error) {
|
||||||
|
var list zypperUpdateList
|
||||||
|
if err := xml.Unmarshal(out, &list); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pkgs := make([]Package, 0, len(list.Updates))
|
||||||
|
for _, u := range list.Updates {
|
||||||
|
if u.Kind != "" && u.Kind != "package" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pkgs = append(pkgs, Package{
|
||||||
|
Name: u.Name,
|
||||||
|
Repo: RepoSystem,
|
||||||
|
Backend: "zypper",
|
||||||
|
FromVersion: u.EditionOld,
|
||||||
|
ToVersion: u.Edition,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return pkgs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (zypperBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
|
||||||
|
if opts.DryRun {
|
||||||
|
return Run(ctx, []string{"zypper", "--non-interactive", "--dry-run", "update"}, RunOptions{OnLine: onLine})
|
||||||
|
}
|
||||||
|
if !BackendHasTargets(zypperBackend{}, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return Run(ctx, zypperUpgradeArgv(opts), RunOptions{OnLine: onLine, AttachStdio: opts.AttachStdio})
|
||||||
|
}
|
||||||
|
|
||||||
|
func zypperUpgradeArgv(opts UpgradeOptions) []string {
|
||||||
|
return privilegedArgv(opts, "zypper", "--non-interactive", "update")
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseZypperXML(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want []Package
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty stream",
|
||||||
|
input: `<?xml version="1.0"?><stream><update-list></update-list></stream>`,
|
||||||
|
want: []Package{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single package update",
|
||||||
|
input: `<?xml version="1.0"?>
|
||||||
|
<stream>
|
||||||
|
<update-list>
|
||||||
|
<update name="zsh" edition="5.9-6" edition-old="5.9-5" kind="package" arch="x86_64">
|
||||||
|
<source url="https://download.opensuse.org/" alias="repo-oss"/>
|
||||||
|
</update>
|
||||||
|
</update-list>
|
||||||
|
</stream>`,
|
||||||
|
want: []Package{
|
||||||
|
{Name: "zsh", Repo: RepoSystem, Backend: "zypper", FromVersion: "5.9-5", ToVersion: "5.9-6"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "skips non-package kinds",
|
||||||
|
input: `<?xml version="1.0"?>
|
||||||
|
<stream>
|
||||||
|
<update-list>
|
||||||
|
<update name="foo" edition="2.0" edition-old="1.0" kind="package"/>
|
||||||
|
<update name="security-patch" edition="1" edition-old="0" kind="patch"/>
|
||||||
|
<update name="bar" edition="3.0" edition-old="2.0" kind="package"/>
|
||||||
|
</update-list>
|
||||||
|
</stream>`,
|
||||||
|
want: []Package{
|
||||||
|
{Name: "foo", Repo: RepoSystem, Backend: "zypper", FromVersion: "1.0", ToVersion: "2.0"},
|
||||||
|
{Name: "bar", Repo: RepoSystem, Backend: "zypper", FromVersion: "2.0", ToVersion: "3.0"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "treats missing kind as package",
|
||||||
|
input: `<?xml version="1.0"?>
|
||||||
|
<stream><update-list>
|
||||||
|
<update name="kernel" edition="6.18.1-1" edition-old="6.18.0-1"/>
|
||||||
|
</update-list></stream>`,
|
||||||
|
want: []Package{
|
||||||
|
{Name: "kernel", Repo: RepoSystem, Backend: "zypper", FromVersion: "6.18.0-1", ToVersion: "6.18.1-1"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "malformed XML returns error",
|
||||||
|
input: `not xml at all`,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := parseZypperXML([]byte(tt.input))
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Fatalf("parseZypperXML() err = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
if tt.wantErr {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, tt.want) {
|
||||||
|
t.Errorf("parseZypperXML() = %#v\nwant %#v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RunOptions struct {
|
||||||
|
Env []string
|
||||||
|
OnLine func(string)
|
||||||
|
AttachStdio bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func Run(ctx context.Context, argv []string, opts RunOptions) error {
|
||||||
|
if len(argv) == 0 {
|
||||||
|
return fmt.Errorf("sysupdate.Run: empty argv")
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
|
||||||
|
if len(opts.Env) > 0 {
|
||||||
|
cmd.Env = append(cmd.Environ(), opts.Env...)
|
||||||
|
}
|
||||||
|
if opts.AttachStdio {
|
||||||
|
cmd.Cancel = func() error {
|
||||||
|
if cmd.Process == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return cmd.Process.Kill()
|
||||||
|
}
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
|
cmd.Cancel = func() error {
|
||||||
|
if cmd.Process == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
stderr, err := cmd.StderrPipe()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(2)
|
||||||
|
go pump(stdout, opts.OnLine, &wg)
|
||||||
|
go pump(stderr, opts.OnLine, &wg)
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
return cmd.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func pump(r io.Reader, onLine func(string), wg *sync.WaitGroup) {
|
||||||
|
defer wg.Done()
|
||||||
|
if onLine == nil {
|
||||||
|
_, _ = io.Copy(io.Discard, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scanner := bufio.NewScanner(r)
|
||||||
|
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||||
|
for scanner.Scan() {
|
||||||
|
onLine(scanner.Text())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Capture(ctx context.Context, argv []string) (string, error) {
|
||||||
|
if len(argv) == 0 {
|
||||||
|
return "", fmt.Errorf("sysupdate.Capture: empty argv")
|
||||||
|
}
|
||||||
|
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
|
||||||
|
out, err := cmd.Output()
|
||||||
|
return string(out), err
|
||||||
|
}
|
||||||
|
|
||||||
|
// privescBin returns the binary to use for privilege escalation.
|
||||||
|
// When useSudo is true it auto-detects the best available tool (sudo/doas/run0).
|
||||||
|
// When false it falls back to pkexec for GUI callers.
|
||||||
|
func privescBin(useSudo bool) string {
|
||||||
|
if useSudo {
|
||||||
|
if t, err := privesc.Detect(); err == nil {
|
||||||
|
return t.Name()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "pkexec"
|
||||||
|
}
|
||||||
|
|
||||||
|
func findTerminal(override string) string {
|
||||||
|
if override != "" && commandExists(override) {
|
||||||
|
return override
|
||||||
|
}
|
||||||
|
if t := os.Getenv("TERMINAL"); t != "" && commandExists(t) {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
for _, t := range []string{"ghostty", "kitty", "foot", "alacritty", "wezterm", "konsole", "gnome-terminal", "xterm"} {
|
||||||
|
if commandExists(t) {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrapInTerminal(term, title, shellCmd string) []string {
|
||||||
|
const appID = "com.danklinux.dms"
|
||||||
|
banner := fmt.Sprintf(
|
||||||
|
`printf '\033[1;36m=== %s ===\033[0m\n'; printf '\033[2m$ %s\033[0m\n'; printf '\033[33mYou may be prompted for your sudo password to apply system updates.\033[0m\n\n'`,
|
||||||
|
title, shellCmd,
|
||||||
|
)
|
||||||
|
closer := `printf '\n\033[1;32m=== Done. Press Enter to close. ===\033[0m\n'; read`
|
||||||
|
export := `export SUDO_PROMPT="[DMS] sudo password for %u: "; `
|
||||||
|
full := export + banner + "; " + shellCmd + "; " + closer
|
||||||
|
|
||||||
|
switch term {
|
||||||
|
case "kitty":
|
||||||
|
return []string{term, "--class", appID, "-T", title, "-e", "sh", "-c", full}
|
||||||
|
case "alacritty":
|
||||||
|
return []string{term, "--class", appID, "-T", title, "-e", "sh", "-c", full}
|
||||||
|
case "foot":
|
||||||
|
return []string{term, "--app-id=" + appID, "--title=" + title, "-e", "sh", "-c", full}
|
||||||
|
case "ghostty":
|
||||||
|
return []string{term, "--class=" + appID, "--title=" + title, "-e", "sh", "-c", full}
|
||||||
|
case "wezterm":
|
||||||
|
return []string{term, "--class", appID, "-T", title, "-e", "sh", "-c", full}
|
||||||
|
case "xterm":
|
||||||
|
return []string{term, "-class", appID, "-T", title, "-e", "sh", "-c", full}
|
||||||
|
case "konsole":
|
||||||
|
return []string{term, "-p", "tabtitle=" + title, "-e", "sh", "-c", full}
|
||||||
|
case "gnome-terminal":
|
||||||
|
return []string{term, "--title=" + title, "--", "sh", "-c", full}
|
||||||
|
default:
|
||||||
|
return []string{term, "-e", "sh", "-c", full}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/params"
|
||||||
|
)
|
||||||
|
|
||||||
|
func HandleRequest(conn net.Conn, req models.Request, m *Manager) {
|
||||||
|
switch req.Method {
|
||||||
|
case "sysupdate.getState":
|
||||||
|
models.Respond(conn, req.ID, m.GetState())
|
||||||
|
case "sysupdate.refresh":
|
||||||
|
force := params.BoolOpt(req.Params, "force", false)
|
||||||
|
m.Refresh(RefreshOptions{Force: force})
|
||||||
|
models.Respond(conn, req.ID, m.GetState())
|
||||||
|
case "sysupdate.upgrade":
|
||||||
|
handleUpgrade(conn, req, m)
|
||||||
|
case "sysupdate.cancel":
|
||||||
|
m.Cancel()
|
||||||
|
models.Respond(conn, req.ID, m.GetState())
|
||||||
|
case "sysupdate.acquire":
|
||||||
|
m.Acquire()
|
||||||
|
models.Respond(conn, req.ID, models.SuccessResult{Success: true})
|
||||||
|
case "sysupdate.release":
|
||||||
|
m.Release()
|
||||||
|
models.Respond(conn, req.ID, models.SuccessResult{Success: true})
|
||||||
|
case "sysupdate.setInterval":
|
||||||
|
seconds, err := params.Int(req.Params, "seconds")
|
||||||
|
if err != nil {
|
||||||
|
models.RespondError(conn, req.ID, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.SetInterval(seconds)
|
||||||
|
models.Respond(conn, req.ID, m.GetState())
|
||||||
|
default:
|
||||||
|
models.RespondError(conn, req.ID, "unknown method: "+req.Method)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleUpgrade(conn net.Conn, req models.Request, m *Manager) {
|
||||||
|
opts := UpgradeOptions{
|
||||||
|
IncludeFlatpak: params.BoolOpt(req.Params, "includeFlatpak", true),
|
||||||
|
IncludeAUR: params.BoolOpt(req.Params, "includeAUR", true),
|
||||||
|
DryRun: params.BoolOpt(req.Params, "dry", false),
|
||||||
|
CustomCommand: params.StringOpt(req.Params, "customCommand", ""),
|
||||||
|
Terminal: params.StringOpt(req.Params, "terminal", ""),
|
||||||
|
}
|
||||||
|
if err := m.Upgrade(opts); err != nil {
|
||||||
|
models.RespondError(conn, req.ID, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
models.Respond(conn, req.ID, m.GetState())
|
||||||
|
}
|
||||||
@@ -0,0 +1,527 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultIntervalSeconds = 30 * 60
|
||||||
|
minIntervalSeconds = 5 * 60
|
||||||
|
recentLogCapacity = 200
|
||||||
|
checkTimeout = 5 * time.Minute
|
||||||
|
upgradeTimeout = 30 * time.Minute
|
||||||
|
postUpgradeCompleteDelay = 3 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
type Manager struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
state State
|
||||||
|
subscribers syncmap.Map[string, chan State]
|
||||||
|
|
||||||
|
selection Selection
|
||||||
|
|
||||||
|
notifyDirty chan struct{}
|
||||||
|
stopChan chan struct{}
|
||||||
|
notifierWG sync.WaitGroup
|
||||||
|
schedulerWG sync.WaitGroup
|
||||||
|
|
||||||
|
acquireCount int32
|
||||||
|
wakeSched chan struct{}
|
||||||
|
|
||||||
|
refreshSerial sync.Mutex
|
||||||
|
|
||||||
|
opMu sync.Mutex
|
||||||
|
opCtx context.Context
|
||||||
|
opCancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewManager() (*Manager, error) {
|
||||||
|
m := &Manager{
|
||||||
|
notifyDirty: make(chan struct{}, 1),
|
||||||
|
stopChan: make(chan struct{}),
|
||||||
|
wakeSched: make(chan struct{}, 1),
|
||||||
|
}
|
||||||
|
m.state = State{
|
||||||
|
Phase: PhaseIdle,
|
||||||
|
IntervalSeconds: defaultIntervalSeconds,
|
||||||
|
Backends: []BackendInfo{},
|
||||||
|
Packages: []Package{},
|
||||||
|
}
|
||||||
|
|
||||||
|
id, pretty := readOSRelease()
|
||||||
|
m.state.Distro = id
|
||||||
|
m.state.DistroPretty = pretty
|
||||||
|
|
||||||
|
m.selection = Select(context.Background())
|
||||||
|
m.state.Backends = m.selection.Info()
|
||||||
|
if len(m.state.Backends) == 0 {
|
||||||
|
m.state.Error = &ErrorInfo{
|
||||||
|
Code: ErrCodeNoBackend,
|
||||||
|
Message: "no supported package manager found",
|
||||||
|
Hint: "install a supported package manager (pacman, dnf, apt, zypper) or flatpak",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m.notifierWG.Add(1)
|
||||||
|
go m.notifier()
|
||||||
|
|
||||||
|
m.schedulerWG.Add(1)
|
||||||
|
go m.scheduler()
|
||||||
|
|
||||||
|
go m.runRefresh(context.Background())
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) GetState() State {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
return cloneState(m.state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Subscribe(id string) chan State {
|
||||||
|
ch := make(chan State, 16)
|
||||||
|
m.subscribers.Store(id, ch)
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Unsubscribe(id string) {
|
||||||
|
if val, ok := m.subscribers.LoadAndDelete(id); ok {
|
||||||
|
close(val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Close() {
|
||||||
|
select {
|
||||||
|
case <-m.stopChan:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
close(m.stopChan)
|
||||||
|
}
|
||||||
|
m.opMu.Lock()
|
||||||
|
if m.opCancel != nil {
|
||||||
|
m.opCancel()
|
||||||
|
}
|
||||||
|
m.opMu.Unlock()
|
||||||
|
select {
|
||||||
|
case m.wakeSched <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
m.schedulerWG.Wait()
|
||||||
|
m.notifierWG.Wait()
|
||||||
|
m.subscribers.Range(func(key string, ch chan State) bool {
|
||||||
|
close(ch)
|
||||||
|
m.subscribers.Delete(key)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) SetInterval(seconds int) {
|
||||||
|
if seconds < minIntervalSeconds {
|
||||||
|
seconds = minIntervalSeconds
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
m.state.IntervalSeconds = seconds
|
||||||
|
m.mu.Unlock()
|
||||||
|
m.markDirty()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Refresh(opts RefreshOptions) {
|
||||||
|
m.mu.RLock()
|
||||||
|
phase := m.state.Phase
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case phase == PhaseUpgrading:
|
||||||
|
return
|
||||||
|
case phase == PhaseRefreshing && !opts.Force:
|
||||||
|
m.refreshSerial.Lock()
|
||||||
|
m.refreshSerial.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.runRefresh(context.Background())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Upgrade(opts UpgradeOptions) error {
|
||||||
|
if len(m.selection.All()) == 0 {
|
||||||
|
return errors.New("no backend available")
|
||||||
|
}
|
||||||
|
|
||||||
|
m.opMu.Lock()
|
||||||
|
if m.opCancel != nil {
|
||||||
|
m.opMu.Unlock()
|
||||||
|
return errors.New("operation already running")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), upgradeTimeout)
|
||||||
|
m.opCtx = ctx
|
||||||
|
m.opCancel = cancel
|
||||||
|
m.opMu.Unlock()
|
||||||
|
|
||||||
|
go m.runUpgrade(ctx, opts)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Cancel() {
|
||||||
|
m.opMu.Lock()
|
||||||
|
cancel := m.opCancel
|
||||||
|
m.opMu.Unlock()
|
||||||
|
if cancel == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Acquire() {
|
||||||
|
first := atomic.AddInt32(&m.acquireCount, 1) == 1
|
||||||
|
select {
|
||||||
|
case m.wakeSched <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
if first {
|
||||||
|
go m.runRefresh(context.Background())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Release() {
|
||||||
|
if atomic.AddInt32(&m.acquireCount, -1) < 0 {
|
||||||
|
atomic.StoreInt32(&m.acquireCount, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) scheduler() {
|
||||||
|
defer m.schedulerWG.Done()
|
||||||
|
for {
|
||||||
|
if atomic.LoadInt32(&m.acquireCount) == 0 {
|
||||||
|
select {
|
||||||
|
case <-m.stopChan:
|
||||||
|
return
|
||||||
|
case <-m.wakeSched:
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.RLock()
|
||||||
|
interval := m.state.IntervalSeconds
|
||||||
|
m.mu.RUnlock()
|
||||||
|
if interval < minIntervalSeconds {
|
||||||
|
interval = minIntervalSeconds
|
||||||
|
}
|
||||||
|
t := time.NewTimer(time.Duration(interval) * time.Second)
|
||||||
|
select {
|
||||||
|
case <-m.stopChan:
|
||||||
|
t.Stop()
|
||||||
|
return
|
||||||
|
case <-m.wakeSched:
|
||||||
|
t.Stop()
|
||||||
|
case <-t.C:
|
||||||
|
m.runRefresh(context.Background())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) runRefresh(parent context.Context) {
|
||||||
|
m.refreshSerial.Lock()
|
||||||
|
defer m.refreshSerial.Unlock()
|
||||||
|
|
||||||
|
if len(m.selection.All()) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(parent, checkTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
if m.state.Phase == PhaseUpgrading {
|
||||||
|
m.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.state.Phase = PhaseRefreshing
|
||||||
|
m.state.Error = nil
|
||||||
|
m.state.RecentLog = nil
|
||||||
|
m.mu.Unlock()
|
||||||
|
m.markDirty()
|
||||||
|
|
||||||
|
type backendResult struct {
|
||||||
|
pkgs []Package
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
backends := m.selection.All()
|
||||||
|
results := make([]backendResult, len(backends))
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i, b := range backends {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int, b Backend) {
|
||||||
|
defer wg.Done()
|
||||||
|
pkgs, err := b.CheckUpdates(ctx)
|
||||||
|
results[i] = backendResult{pkgs: pkgs, err: err}
|
||||||
|
}(i, b)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
m.mu.Lock()
|
||||||
|
m.state.LastCheckUnix = now
|
||||||
|
m.state.Packages = m.state.Packages[:0]
|
||||||
|
var firstErr error
|
||||||
|
for i, r := range results {
|
||||||
|
if r.err != nil {
|
||||||
|
if firstErr == nil {
|
||||||
|
firstErr = fmt.Errorf("%s: %w", backends[i].ID(), r.err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m.state.Packages = append(m.state.Packages, r.pkgs...)
|
||||||
|
}
|
||||||
|
m.state.Count = len(m.state.Packages)
|
||||||
|
if firstErr != nil {
|
||||||
|
m.state.Phase = PhaseError
|
||||||
|
m.state.Error = &ErrorInfo{Code: ErrCodeBackendFailed, Message: firstErr.Error()}
|
||||||
|
} else {
|
||||||
|
m.state.Phase = PhaseIdle
|
||||||
|
m.state.LastSuccessUnix = now
|
||||||
|
m.state.NextCheckUnix = now + int64(m.state.IntervalSeconds)
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
m.markDirty()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) runUpgrade(ctx context.Context, opts UpgradeOptions) {
|
||||||
|
defer func() {
|
||||||
|
m.opMu.Lock()
|
||||||
|
if m.opCancel != nil {
|
||||||
|
m.opCancel = nil
|
||||||
|
m.opCtx = nil
|
||||||
|
}
|
||||||
|
m.opMu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
if opts.CustomCommand != "" {
|
||||||
|
m.runCustomUpgrade(ctx, opts.CustomCommand, opts.Terminal)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(opts.Targets) == 0 {
|
||||||
|
m.mu.RLock()
|
||||||
|
opts.Targets = append([]Package(nil), m.state.Packages...)
|
||||||
|
m.mu.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
backends := upgradeBackends(m.selection, opts)
|
||||||
|
if len(backends) == 0 {
|
||||||
|
m.setError(ErrCodeNoBackend, "no backend selected for upgrade")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
opID := fmt.Sprintf("op-%d", time.Now().UnixNano())
|
||||||
|
m.mu.Lock()
|
||||||
|
m.state.Phase = PhaseUpgrading
|
||||||
|
m.state.OperationID = opID
|
||||||
|
m.state.OperationStarted = time.Now().Unix()
|
||||||
|
m.state.RecentLog = m.state.RecentLog[:0]
|
||||||
|
m.state.Error = nil
|
||||||
|
m.mu.Unlock()
|
||||||
|
m.markDirty()
|
||||||
|
|
||||||
|
onLine := func(line string) { m.appendLog(line) }
|
||||||
|
for _, b := range backends {
|
||||||
|
m.appendLog(fmt.Sprintf("== %s ==", b.DisplayName()))
|
||||||
|
if err := b.Upgrade(ctx, opts, onLine); err != nil {
|
||||||
|
code := ErrCodeBackendFailed
|
||||||
|
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||||
|
code = ErrCodeTimeout
|
||||||
|
} else if errors.Is(ctx.Err(), context.Canceled) {
|
||||||
|
code = ErrCodeCancelled
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
m.state.Phase = PhaseError
|
||||||
|
m.state.Error = &ErrorInfo{Code: code, Message: fmt.Sprintf("%s: %v", b.ID(), err)}
|
||||||
|
m.mu.Unlock()
|
||||||
|
m.markDirty()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m.finishSuccessfulUpgrade(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) runCustomUpgrade(ctx context.Context, command, terminalOverride string) {
|
||||||
|
term := findTerminal(terminalOverride)
|
||||||
|
if term == "" {
|
||||||
|
m.setError(ErrCodeBackendFailed, "no terminal found (pick one in DMS settings, set $TERMINAL, or install kitty/ghostty/foot/alacritty)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
opID := fmt.Sprintf("op-%d", time.Now().UnixNano())
|
||||||
|
m.mu.Lock()
|
||||||
|
m.state.Phase = PhaseUpgrading
|
||||||
|
m.state.OperationID = opID
|
||||||
|
m.state.OperationStarted = time.Now().Unix()
|
||||||
|
m.state.RecentLog = m.state.RecentLog[:0]
|
||||||
|
m.state.Error = nil
|
||||||
|
m.mu.Unlock()
|
||||||
|
m.markDirty()
|
||||||
|
|
||||||
|
onLine := func(line string) { m.appendLog(line) }
|
||||||
|
argv := wrapInTerminal(term, "DMS — System Update (custom)", command)
|
||||||
|
if err := Run(ctx, argv, RunOptions{OnLine: onLine}); err != nil {
|
||||||
|
code := ErrCodeBackendFailed
|
||||||
|
switch {
|
||||||
|
case errors.Is(ctx.Err(), context.DeadlineExceeded):
|
||||||
|
code = ErrCodeTimeout
|
||||||
|
case errors.Is(ctx.Err(), context.Canceled):
|
||||||
|
code = ErrCodeCancelled
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
m.state.Phase = PhaseError
|
||||||
|
m.state.Error = &ErrorInfo{Code: code, Message: err.Error()}
|
||||||
|
m.mu.Unlock()
|
||||||
|
m.markDirty()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
m.finishSuccessfulUpgrade(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) finishSuccessfulUpgrade(clearPackages bool) {
|
||||||
|
m.appendLog("Upgrade complete.")
|
||||||
|
|
||||||
|
timer := time.NewTimer(postUpgradeCompleteDelay)
|
||||||
|
defer timer.Stop()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-m.stopChan:
|
||||||
|
return
|
||||||
|
case <-timer.C:
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
m.state.Phase = PhaseIdle
|
||||||
|
m.state.OperationID = ""
|
||||||
|
m.state.OperationStarted = 0
|
||||||
|
if clearPackages {
|
||||||
|
m.state.Packages = m.state.Packages[:0]
|
||||||
|
m.state.Count = 0
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
m.markDirty()
|
||||||
|
go m.runRefresh(context.Background())
|
||||||
|
}
|
||||||
|
|
||||||
|
func upgradeBackends(sel Selection, opts UpgradeOptions) []Backend {
|
||||||
|
var out []Backend
|
||||||
|
if sel.System != nil {
|
||||||
|
out = appendUpgradeBackend(out, sel.System, opts)
|
||||||
|
}
|
||||||
|
for _, b := range sel.Overlay {
|
||||||
|
switch {
|
||||||
|
case b.Repo() == RepoFlatpak && !opts.IncludeFlatpak:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = appendUpgradeBackend(out, b, opts)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendUpgradeBackend(out []Backend, b Backend, opts UpgradeOptions) []Backend {
|
||||||
|
if !BackendHasTargets(b, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
return append(out, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) appendLog(line string) {
|
||||||
|
m.mu.Lock()
|
||||||
|
if cap(m.state.RecentLog) == 0 {
|
||||||
|
m.state.RecentLog = make([]string, 0, recentLogCapacity)
|
||||||
|
}
|
||||||
|
if len(m.state.RecentLog) >= recentLogCapacity {
|
||||||
|
copy(m.state.RecentLog, m.state.RecentLog[1:])
|
||||||
|
m.state.RecentLog = m.state.RecentLog[:recentLogCapacity-1]
|
||||||
|
}
|
||||||
|
m.state.RecentLog = append(m.state.RecentLog, line)
|
||||||
|
m.mu.Unlock()
|
||||||
|
m.markDirty()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) setError(code ErrorCode, msg string) {
|
||||||
|
m.mu.Lock()
|
||||||
|
m.state.Phase = PhaseError
|
||||||
|
m.state.Error = &ErrorInfo{Code: code, Message: msg}
|
||||||
|
m.mu.Unlock()
|
||||||
|
m.markDirty()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) markDirty() {
|
||||||
|
select {
|
||||||
|
case m.notifyDirty <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) notifier() {
|
||||||
|
defer m.notifierWG.Done()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-m.stopChan:
|
||||||
|
return
|
||||||
|
case <-m.notifyDirty:
|
||||||
|
snap := m.GetState()
|
||||||
|
m.subscribers.Range(func(key string, ch chan State) bool {
|
||||||
|
select {
|
||||||
|
case ch <- snap:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneState(s State) State {
|
||||||
|
out := s
|
||||||
|
out.Backends = append([]BackendInfo(nil), s.Backends...)
|
||||||
|
out.Packages = append([]Package(nil), s.Packages...)
|
||||||
|
out.RecentLog = append([]string(nil), s.RecentLog...)
|
||||||
|
if s.Error != nil {
|
||||||
|
errCopy := *s.Error
|
||||||
|
out.Error = &errCopy
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func readOSRelease() (id, pretty string) {
|
||||||
|
f, err := os.Open("/etc/os-release")
|
||||||
|
if err != nil {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
for scanner.Scan() {
|
||||||
|
k, v, ok := strings.Cut(scanner.Text(), "=")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v = strings.Trim(v, "\"")
|
||||||
|
switch k {
|
||||||
|
case "ID":
|
||||||
|
id = v
|
||||||
|
case "PRETTY_NAME":
|
||||||
|
pretty = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
log.Debugf("[sysupdate] read os-release: %v", err)
|
||||||
|
}
|
||||||
|
return id, pretty
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
func BackendHasTargets(b Backend, targets []Package, includeAUR, includeFlatpak bool) bool {
|
||||||
|
if b == nil || len(targets) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
id := b.ID()
|
||||||
|
repo := b.Repo()
|
||||||
|
for _, p := range targets {
|
||||||
|
switch p.Repo {
|
||||||
|
case RepoFlatpak:
|
||||||
|
if !includeFlatpak {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
case RepoAUR:
|
||||||
|
if !includeAUR {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch repo {
|
||||||
|
case RepoFlatpak:
|
||||||
|
if p.Repo == RepoFlatpak || p.Backend == id {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
case RepoOSTree:
|
||||||
|
if p.Repo == RepoOSTree || p.Backend == id {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if p.Backend == id {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpgradeNeedsPrivilege(backends []Backend, targets []Package, opts UpgradeOptions) bool {
|
||||||
|
if opts.DryRun {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, b := range backends {
|
||||||
|
if b == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if b.NeedsAuth() && BackendHasTargets(b, targets, opts.IncludeAUR, opts.IncludeFlatpak) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func privilegedArgv(opts UpgradeOptions, argv ...string) []string {
|
||||||
|
privesc := privescBin(opts.UseSudo)
|
||||||
|
out := make([]string, 0, len(argv)+1)
|
||||||
|
out = append(out, privesc)
|
||||||
|
out = append(out, argv...)
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
type Phase string
|
||||||
|
|
||||||
|
const (
|
||||||
|
PhaseIdle Phase = "idle"
|
||||||
|
PhaseRefreshing Phase = "refreshing"
|
||||||
|
PhaseUpgrading Phase = "upgrading"
|
||||||
|
PhaseError Phase = "error"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RepoKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
RepoSystem RepoKind = "system"
|
||||||
|
RepoAUR RepoKind = "aur"
|
||||||
|
RepoFlatpak RepoKind = "flatpak"
|
||||||
|
RepoOSTree RepoKind = "ostree"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ErrorCode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ErrCodeNone ErrorCode = ""
|
||||||
|
ErrCodeNoBackend ErrorCode = "no-backend"
|
||||||
|
ErrCodeBusy ErrorCode = "busy"
|
||||||
|
ErrCodeBackendFailed ErrorCode = "backend-failed"
|
||||||
|
ErrCodeTimeout ErrorCode = "timeout"
|
||||||
|
ErrCodeCancelled ErrorCode = "cancelled"
|
||||||
|
ErrCodeInvalidRequest ErrorCode = "invalid-request"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Package struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Repo RepoKind `json:"repo"`
|
||||||
|
Backend string `json:"backend"`
|
||||||
|
FromVersion string `json:"fromVersion,omitempty"`
|
||||||
|
ToVersion string `json:"toVersion,omitempty"`
|
||||||
|
SizeBytes int64 `json:"sizeBytes,omitempty"`
|
||||||
|
ChangelogURL string `json:"changelogUrl,omitempty"`
|
||||||
|
Ref string `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BackendInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
Repo RepoKind `json:"repo"`
|
||||||
|
NeedsAuth bool `json:"needsAuth"`
|
||||||
|
RunsInTerminal bool `json:"runsInTerminal"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ErrorInfo struct {
|
||||||
|
Code ErrorCode `json:"code,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
Hint string `json:"hint,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type State struct {
|
||||||
|
Phase Phase `json:"phase"`
|
||||||
|
Distro string `json:"distro,omitempty"`
|
||||||
|
DistroPretty string `json:"distroPretty,omitempty"`
|
||||||
|
Backends []BackendInfo `json:"backends"`
|
||||||
|
Packages []Package `json:"packages"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
IntervalSeconds int `json:"intervalSeconds"`
|
||||||
|
LastCheckUnix int64 `json:"lastCheckUnix,omitempty"`
|
||||||
|
LastSuccessUnix int64 `json:"lastSuccessUnix,omitempty"`
|
||||||
|
NextCheckUnix int64 `json:"nextCheckUnix,omitempty"`
|
||||||
|
OperationID string `json:"operationId,omitempty"`
|
||||||
|
OperationStarted int64 `json:"operationStartedUnix,omitempty"`
|
||||||
|
RecentLog []string `json:"recentLog,omitempty"`
|
||||||
|
Error *ErrorInfo `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpgradeOptions struct {
|
||||||
|
IncludeFlatpak bool
|
||||||
|
IncludeAUR bool
|
||||||
|
DryRun bool
|
||||||
|
UseSudo bool
|
||||||
|
AttachStdio bool
|
||||||
|
CustomCommand string
|
||||||
|
Terminal string
|
||||||
|
Targets []Package
|
||||||
|
}
|
||||||
|
|
||||||
|
type RefreshOptions struct {
|
||||||
|
Force bool
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUpgradeCommandBuilders(t *testing.T) {
|
||||||
|
pkexecOpts := UpgradeOptions{UseSudo: false}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
got []string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "dnf full upgrade",
|
||||||
|
got: dnfUpgradeArgv("dnf5", pkexecOpts),
|
||||||
|
want: []string{"pkexec", "dnf5", "upgrade", "--refresh", "-y"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "apt full upgrade",
|
||||||
|
got: aptUpgradeArgv("apt-get", pkexecOpts),
|
||||||
|
want: []string{"pkexec", "env", "DEBIAN_FRONTEND=noninteractive", "LC_ALL=C", "apt-get", "upgrade", "-y"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "zypper full update",
|
||||||
|
got: zypperUpgradeArgv(pkexecOpts),
|
||||||
|
want: []string{"pkexec", "zypper", "--non-interactive", "update"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pacman full sync upgrade",
|
||||||
|
got: pacmanUpgradeArgv(pkexecOpts),
|
||||||
|
want: []string{"pkexec", "pacman", "-Syu", "--noconfirm", "--needed"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "aur helper full update with aur",
|
||||||
|
got: archHelperUpgradeArgv("paru", true),
|
||||||
|
want: []string{"paru", "-Syu", "--noconfirm", "--needed"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "aur helper repo-only full update",
|
||||||
|
got: archHelperUpgradeArgv("yay", false),
|
||||||
|
want: []string{"yay", "-Syu", "--noconfirm", "--needed", "--repo"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "flatpak full update",
|
||||||
|
got: flatpakUpgradeArgv(),
|
||||||
|
want: []string{"flatpak", "update", "-y", "--noninteractive"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rpm-ostree upgrade",
|
||||||
|
got: rpmOstreeUpgradeArgv(UpgradeOptions{}),
|
||||||
|
want: []string{"rpm-ostree", "upgrade"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rpm-ostree check",
|
||||||
|
got: rpmOstreeUpgradeArgv(UpgradeOptions{DryRun: true}),
|
||||||
|
want: []string{"rpm-ostree", "upgrade", "--check"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if !reflect.DeepEqual(tt.got, tt.want) {
|
||||||
|
t.Fatalf("argv = %#v, want %#v", tt.got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackendHasTargetsRespectsBackendAndOptions(t *testing.T) {
|
||||||
|
targets := []Package{
|
||||||
|
{Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf5"},
|
||||||
|
{Name: "google-chrome", Repo: RepoAUR, Backend: "paru"},
|
||||||
|
{Name: "Discord", Repo: RepoFlatpak, Backend: "flatpak"},
|
||||||
|
{Name: "silverblue", Repo: RepoOSTree, Backend: "rpm-ostree"},
|
||||||
|
}
|
||||||
|
|
||||||
|
if !BackendHasTargets(dnfBackend{bin: "dnf5"}, targets, true, true) {
|
||||||
|
t.Fatal("dnf5 target was not detected")
|
||||||
|
}
|
||||||
|
if BackendHasTargets(dnfBackend{bin: "dnf"}, targets, true, true) {
|
||||||
|
t.Fatal("dnf target should not match dnf5 package targets")
|
||||||
|
}
|
||||||
|
if !BackendHasTargets(archHelperBackend{id: "paru"}, targets, true, true) {
|
||||||
|
t.Fatal("AUR helper target was not detected")
|
||||||
|
}
|
||||||
|
if BackendHasTargets(archHelperBackend{id: "paru"}, targets, false, true) {
|
||||||
|
t.Fatal("AUR helper should not match AUR-only target when AUR is disabled")
|
||||||
|
}
|
||||||
|
if !BackendHasTargets(flatpakBackend{}, targets, true, true) {
|
||||||
|
t.Fatal("Flatpak target was not detected")
|
||||||
|
}
|
||||||
|
if BackendHasTargets(flatpakBackend{}, targets, true, false) {
|
||||||
|
t.Fatal("Flatpak target should not match when Flatpak is disabled")
|
||||||
|
}
|
||||||
|
if !BackendHasTargets(rpmOstreeBackend{}, targets, true, true) {
|
||||||
|
t.Fatal("rpm-ostree target was not detected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpgradeNeedsPrivilegeSkipsFlatpakOnly(t *testing.T) {
|
||||||
|
backends := []Backend{dnfBackend{bin: "dnf5"}, flatpakBackend{}}
|
||||||
|
opts := UpgradeOptions{IncludeAUR: true, IncludeFlatpak: true}
|
||||||
|
|
||||||
|
flatpakOnly := []Package{{Name: "Discord", Repo: RepoFlatpak, Backend: "flatpak"}}
|
||||||
|
if UpgradeNeedsPrivilege(backends, flatpakOnly, opts) {
|
||||||
|
t.Fatal("Flatpak-only updates should not need privileged auth")
|
||||||
|
}
|
||||||
|
|
||||||
|
mixed := []Package{
|
||||||
|
{Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf5"},
|
||||||
|
{Name: "Discord", Repo: RepoFlatpak, Backend: "flatpak"},
|
||||||
|
}
|
||||||
|
if !UpgradeNeedsPrivilege(backends, mixed, opts) {
|
||||||
|
t.Fatal("mixed system updates should need privileged auth")
|
||||||
|
}
|
||||||
|
|
||||||
|
opts.DryRun = true
|
||||||
|
if UpgradeNeedsPrivilege(backends, mixed, opts) {
|
||||||
|
t.Fatal("dry-run updates should not need privileged auth")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpgradeBackendsFiltersFlatpakOnly(t *testing.T) {
|
||||||
|
sel := Selection{
|
||||||
|
System: dnfBackend{bin: "dnf5"},
|
||||||
|
Overlay: []Backend{flatpakBackend{}},
|
||||||
|
}
|
||||||
|
opts := UpgradeOptions{
|
||||||
|
IncludeAUR: true,
|
||||||
|
IncludeFlatpak: true,
|
||||||
|
Targets: []Package{{Name: "Discord", Repo: RepoFlatpak, Backend: "flatpak"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := upgradeBackends(sel, opts)
|
||||||
|
if len(got) != 1 || got[0].ID() != "flatpak" {
|
||||||
|
t.Fatalf("upgradeBackends(flatpak-only) = %#v, want only flatpak", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
opts.Targets = append(opts.Targets, Package{Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf5"})
|
||||||
|
got = upgradeBackends(sel, opts)
|
||||||
|
if len(got) != 2 || got[0].ID() != "dnf5" || got[1].ID() != "flatpak" {
|
||||||
|
t.Fatalf("upgradeBackends(mixed) = %#v, want dnf5 then flatpak", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package tailscale
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"tailscale.com/ipn/ipnstate"
|
||||||
|
"tailscale.com/tailcfg"
|
||||||
|
)
|
||||||
|
|
||||||
|
// convertStatus converts an ipnstate.Status into our TailscaleState IPC type.
|
||||||
|
func convertStatus(status *ipnstate.Status) *TailscaleState {
|
||||||
|
connected := status.BackendState == "Running"
|
||||||
|
|
||||||
|
state := &TailscaleState{
|
||||||
|
Connected: connected,
|
||||||
|
BackendState: status.BackendState,
|
||||||
|
Version: status.Version,
|
||||||
|
}
|
||||||
|
|
||||||
|
if status.CurrentTailnet != nil {
|
||||||
|
state.TailnetName = status.CurrentTailnet.Name
|
||||||
|
state.MagicDNSSuffix = status.CurrentTailnet.MagicDNSSuffix
|
||||||
|
}
|
||||||
|
|
||||||
|
if !connected {
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
users := status.User
|
||||||
|
|
||||||
|
if status.Self != nil {
|
||||||
|
state.Self = convertPeerStatus(status.Self, users)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(status.Peer) > 0 {
|
||||||
|
peers := make([]Peer, 0, len(status.Peer))
|
||||||
|
for _, ps := range status.Peer {
|
||||||
|
peers = append(peers, convertPeerStatus(ps, users))
|
||||||
|
}
|
||||||
|
sort.Slice(peers, func(i, j int) bool {
|
||||||
|
if peers[i].Online != peers[j].Online {
|
||||||
|
return peers[i].Online
|
||||||
|
}
|
||||||
|
return strings.ToLower(peers[i].Hostname) < strings.ToLower(peers[j].Hostname)
|
||||||
|
})
|
||||||
|
state.Peers = peers
|
||||||
|
}
|
||||||
|
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertPeerStatus converts an ipnstate.PeerStatus into our Peer IPC type.
|
||||||
|
func convertPeerStatus(ps *ipnstate.PeerStatus, users map[tailcfg.UserID]tailcfg.UserProfile) Peer {
|
||||||
|
dnsName := strings.TrimSuffix(ps.DNSName, ".")
|
||||||
|
|
||||||
|
// DNSName first label is unique per node; OS HostName is not.
|
||||||
|
hostname := ps.HostName
|
||||||
|
if dnsName != "" {
|
||||||
|
parts := strings.SplitN(dnsName, ".", 2)
|
||||||
|
if len(parts) > 0 && parts[0] != "" {
|
||||||
|
hostname = parts[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
peer := Peer{
|
||||||
|
ID: string(ps.ID),
|
||||||
|
Hostname: hostname,
|
||||||
|
DNSName: dnsName,
|
||||||
|
OS: ps.OS,
|
||||||
|
Online: ps.Online,
|
||||||
|
Active: ps.Active,
|
||||||
|
ExitNode: ps.ExitNode,
|
||||||
|
Relay: ps.Relay,
|
||||||
|
RxBytes: ps.RxBytes,
|
||||||
|
TxBytes: ps.TxBytes,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ip := range ps.TailscaleIPs {
|
||||||
|
if ip.Is4() {
|
||||||
|
if peer.TailscaleIP == "" {
|
||||||
|
peer.TailscaleIP = ip.String()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if peer.TailscaleIPv6 == "" {
|
||||||
|
peer.TailscaleIPv6 = ip.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ps.Tags != nil {
|
||||||
|
peer.Tags = ps.Tags.AsSlice()
|
||||||
|
}
|
||||||
|
|
||||||
|
if ps.UserID > 0 {
|
||||||
|
if user, ok := users[ps.UserID]; ok {
|
||||||
|
peer.Owner = user.LoginName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ps.LastSeen.IsZero() {
|
||||||
|
peer.LastSeen = formatRelativeTime(ps.LastSeen)
|
||||||
|
}
|
||||||
|
|
||||||
|
return peer
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatRelativeTime formats a time as a human-readable relative duration (e.g., "5 minutes ago").
|
||||||
|
func formatRelativeTime(t time.Time) string {
|
||||||
|
d := time.Since(t)
|
||||||
|
switch {
|
||||||
|
case d < time.Minute:
|
||||||
|
return "just now"
|
||||||
|
case d < time.Hour:
|
||||||
|
m := int(d.Minutes())
|
||||||
|
if m == 1 {
|
||||||
|
return "1 minute ago"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d minutes ago", m)
|
||||||
|
case d < 24*time.Hour:
|
||||||
|
h := int(d.Hours())
|
||||||
|
if h == 1 {
|
||||||
|
return "1 hour ago"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d hours ago", h)
|
||||||
|
default:
|
||||||
|
days := int(d.Hours() / 24)
|
||||||
|
if days == 1 {
|
||||||
|
return "1 day ago"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d days ago", days)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
package tailscale
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/netip"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go4.org/mem"
|
||||||
|
"tailscale.com/ipn/ipnstate"
|
||||||
|
"tailscale.com/tailcfg"
|
||||||
|
"tailscale.com/types/key"
|
||||||
|
"tailscale.com/types/views"
|
||||||
|
)
|
||||||
|
|
||||||
|
func makeTestStatus() *ipnstate.Status {
|
||||||
|
return &ipnstate.Status{
|
||||||
|
Version: "1.94.2",
|
||||||
|
BackendState: "Running",
|
||||||
|
MagicDNSSuffix: "example.ts.net",
|
||||||
|
CurrentTailnet: &ipnstate.TailnetStatus{
|
||||||
|
Name: "user@example.com",
|
||||||
|
MagicDNSSuffix: "example.ts.net",
|
||||||
|
},
|
||||||
|
Self: &ipnstate.PeerStatus{
|
||||||
|
ID: "node1",
|
||||||
|
HostName: "cachyos",
|
||||||
|
DNSName: "cachyos.example.ts.net.",
|
||||||
|
OS: "linux",
|
||||||
|
TailscaleIPs: []netip.Addr{
|
||||||
|
netip.MustParseAddr("100.85.254.40"),
|
||||||
|
netip.MustParseAddr("fd7a:115c:a1e0::1"),
|
||||||
|
},
|
||||||
|
Online: true,
|
||||||
|
UserID: 12345,
|
||||||
|
},
|
||||||
|
Peer: map[key.NodePublic]*ipnstate.PeerStatus{
|
||||||
|
key.NodePublicFromRaw32(mem.B(make([]byte, 32))): {
|
||||||
|
ID: "node2",
|
||||||
|
HostName: "thinkpad-x390",
|
||||||
|
DNSName: "thinkpad-x390.example.ts.net.",
|
||||||
|
OS: "linux",
|
||||||
|
TailscaleIPs: []netip.Addr{
|
||||||
|
netip.MustParseAddr("100.97.21.17"),
|
||||||
|
netip.MustParseAddr("fd7a:115c:a1e0::2"),
|
||||||
|
},
|
||||||
|
Online: true,
|
||||||
|
Active: true,
|
||||||
|
Relay: "fra",
|
||||||
|
RxBytes: 1024,
|
||||||
|
TxBytes: 2048,
|
||||||
|
UserID: 12345,
|
||||||
|
ExitNode: false,
|
||||||
|
LastSeen: time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
User: map[tailcfg.UserID]tailcfg.UserProfile{
|
||||||
|
12345: {
|
||||||
|
ID: 12345,
|
||||||
|
LoginName: "user@example.com",
|
||||||
|
DisplayName: "User",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertStatus_Running(t *testing.T) {
|
||||||
|
status := makeTestStatus()
|
||||||
|
state := convertStatus(status)
|
||||||
|
|
||||||
|
require.NotNil(t, state)
|
||||||
|
assert.True(t, state.Connected)
|
||||||
|
assert.Equal(t, "1.94.2", state.Version)
|
||||||
|
assert.Equal(t, "Running", state.BackendState)
|
||||||
|
assert.Equal(t, "example.ts.net", state.MagicDNSSuffix)
|
||||||
|
assert.Equal(t, "user@example.com", state.TailnetName)
|
||||||
|
|
||||||
|
// Self
|
||||||
|
assert.Equal(t, "cachyos", state.Self.Hostname)
|
||||||
|
assert.Equal(t, "cachyos.example.ts.net", state.Self.DNSName)
|
||||||
|
assert.Equal(t, "100.85.254.40", state.Self.TailscaleIP)
|
||||||
|
assert.Equal(t, "fd7a:115c:a1e0::1", state.Self.TailscaleIPv6)
|
||||||
|
assert.Equal(t, "linux", state.Self.OS)
|
||||||
|
assert.True(t, state.Self.Online)
|
||||||
|
|
||||||
|
// Peers
|
||||||
|
require.Len(t, state.Peers, 1)
|
||||||
|
peer := state.Peers[0]
|
||||||
|
assert.Equal(t, "thinkpad-x390", peer.Hostname)
|
||||||
|
assert.Equal(t, "100.97.21.17", peer.TailscaleIP)
|
||||||
|
assert.Equal(t, "fra", peer.Relay)
|
||||||
|
assert.Equal(t, "user@example.com", peer.Owner)
|
||||||
|
assert.Equal(t, int64(1024), peer.RxBytes)
|
||||||
|
assert.True(t, peer.Online)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertStatus_NotRunning(t *testing.T) {
|
||||||
|
status := &ipnstate.Status{
|
||||||
|
BackendState: "Stopped",
|
||||||
|
}
|
||||||
|
|
||||||
|
state := convertStatus(status)
|
||||||
|
assert.False(t, state.Connected)
|
||||||
|
assert.Equal(t, "Stopped", state.BackendState)
|
||||||
|
assert.Empty(t, state.Peers)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertStatus_NilSelf(t *testing.T) {
|
||||||
|
status := &ipnstate.Status{
|
||||||
|
BackendState: "Running",
|
||||||
|
}
|
||||||
|
|
||||||
|
state := convertStatus(status)
|
||||||
|
assert.True(t, state.Connected)
|
||||||
|
assert.Equal(t, Peer{}, state.Self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertPeerStatus_Tags(t *testing.T) {
|
||||||
|
tags := views.SliceOf([]string{"tag:k8s", "tag:server"})
|
||||||
|
ps := &ipnstate.PeerStatus{
|
||||||
|
ID: "node3",
|
||||||
|
HostName: "k8s-node",
|
||||||
|
DNSName: "k8s-node.example.ts.net.",
|
||||||
|
OS: "linux",
|
||||||
|
Online: false,
|
||||||
|
Tags: &tags,
|
||||||
|
}
|
||||||
|
users := map[tailcfg.UserID]tailcfg.UserProfile{}
|
||||||
|
|
||||||
|
peer := convertPeerStatus(ps, users)
|
||||||
|
assert.Equal(t, "k8s-node", peer.Hostname)
|
||||||
|
assert.Contains(t, peer.Tags, "tag:k8s")
|
||||||
|
assert.Contains(t, peer.Tags, "tag:server")
|
||||||
|
assert.Equal(t, "", peer.Owner)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertPeerStatus_HostnameFromDNS(t *testing.T) {
|
||||||
|
// Hostname should always be derived from DNSName, not OS HostName
|
||||||
|
ps := &ipnstate.PeerStatus{
|
||||||
|
HostName: "GL-MT6000",
|
||||||
|
DNSName: "gl-mt6000-2.example.ts.net.",
|
||||||
|
}
|
||||||
|
users := map[tailcfg.UserID]tailcfg.UserProfile{}
|
||||||
|
|
||||||
|
peer := convertPeerStatus(ps, users)
|
||||||
|
assert.Equal(t, "gl-mt6000-2", peer.Hostname)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertPeerStatus_FallbackToHostName(t *testing.T) {
|
||||||
|
// When DNSName is empty, fall back to OS HostName
|
||||||
|
ps := &ipnstate.PeerStatus{
|
||||||
|
HostName: "my-device",
|
||||||
|
}
|
||||||
|
users := map[tailcfg.UserID]tailcfg.UserProfile{}
|
||||||
|
|
||||||
|
peer := convertPeerStatus(ps, users)
|
||||||
|
assert.Equal(t, "my-device", peer.Hostname)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertPeerStatus_LastSeen(t *testing.T) {
|
||||||
|
ps := &ipnstate.PeerStatus{
|
||||||
|
HostName: "recent-node",
|
||||||
|
LastSeen: time.Now().Add(-5 * time.Minute),
|
||||||
|
}
|
||||||
|
users := map[tailcfg.UserID]tailcfg.UserProfile{}
|
||||||
|
|
||||||
|
peer := convertPeerStatus(ps, users)
|
||||||
|
assert.NotEmpty(t, peer.LastSeen)
|
||||||
|
assert.Contains(t, peer.LastSeen, "minutes ago")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPeerSorting(t *testing.T) {
|
||||||
|
b1 := make([]byte, 32)
|
||||||
|
b2 := make([]byte, 32)
|
||||||
|
b2[0] = 1
|
||||||
|
b3 := make([]byte, 32)
|
||||||
|
b3[0] = 2
|
||||||
|
|
||||||
|
k1 := key.NodePublicFromRaw32(mem.B(b1))
|
||||||
|
k2 := key.NodePublicFromRaw32(mem.B(b2))
|
||||||
|
k3 := key.NodePublicFromRaw32(mem.B(b3))
|
||||||
|
|
||||||
|
status := &ipnstate.Status{
|
||||||
|
BackendState: "Running",
|
||||||
|
Peer: map[key.NodePublic]*ipnstate.PeerStatus{
|
||||||
|
k1: {HostName: "zebra", Online: false},
|
||||||
|
k2: {HostName: "alpha", Online: true},
|
||||||
|
k3: {HostName: "beta", Online: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
state := convertStatus(status)
|
||||||
|
|
||||||
|
// Online peers first (alpha, beta), then offline (zebra)
|
||||||
|
require.Len(t, state.Peers, 3)
|
||||||
|
assert.True(t, state.Peers[0].Online)
|
||||||
|
assert.True(t, state.Peers[1].Online)
|
||||||
|
assert.False(t, state.Peers[2].Online)
|
||||||
|
assert.Equal(t, "alpha", state.Peers[0].Hostname)
|
||||||
|
assert.Equal(t, "beta", state.Peers[1].Hostname)
|
||||||
|
assert.Equal(t, "zebra", state.Peers[2].Hostname)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatRelativeTime(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
duration string
|
||||||
|
contains string
|
||||||
|
}{
|
||||||
|
{"minutes", "5m", "minutes ago"},
|
||||||
|
{"hours", "3h", "hours ago"},
|
||||||
|
{"days", "48h", "days ago"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
d, _ := time.ParseDuration(tt.duration)
|
||||||
|
result := formatRelativeTime(time.Now().Add(-d))
|
||||||
|
assert.Contains(t, result, tt.contains)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package tailscale
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleRequest routes an IPC request to the appropriate handler.
|
||||||
|
func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
|
||||||
|
switch req.Method {
|
||||||
|
case "tailscale.getStatus":
|
||||||
|
handleGetStatus(conn, req, manager)
|
||||||
|
case "tailscale.refresh":
|
||||||
|
handleRefresh(conn, req, manager)
|
||||||
|
default:
|
||||||
|
models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleGetStatus(conn net.Conn, req models.Request, manager *Manager) {
|
||||||
|
state := manager.GetState()
|
||||||
|
models.Respond(conn, req.ID, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleRefresh(conn net.Conn, req models.Request, manager *Manager) {
|
||||||
|
manager.RefreshState()
|
||||||
|
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "refreshed"})
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package tailscale
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"tailscale.com/ipn"
|
||||||
|
"tailscale.com/ipn/ipnstate"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mockConn struct {
|
||||||
|
*bytes.Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockConn) Close() error { return nil }
|
||||||
|
func (m *mockConn) LocalAddr() net.Addr { return nil }
|
||||||
|
func (m *mockConn) RemoteAddr() net.Addr { return nil }
|
||||||
|
func (m *mockConn) SetDeadline(t time.Time) error { return nil }
|
||||||
|
func (m *mockConn) SetReadDeadline(t time.Time) error { return nil }
|
||||||
|
func (m *mockConn) SetWriteDeadline(t time.Time) error { return nil }
|
||||||
|
|
||||||
|
func handlerTestManager() *Manager {
|
||||||
|
client := &mockClient{
|
||||||
|
watchFn: func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
|
||||||
|
<-ctx.Done()
|
||||||
|
return nil, ctx.Err()
|
||||||
|
},
|
||||||
|
statusFn: func(ctx context.Context) (*ipnstate.Status, error) {
|
||||||
|
return runningStatus(), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
m := newManager(client)
|
||||||
|
m.RefreshState()
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetStatus(t *testing.T) {
|
||||||
|
m := handlerTestManager()
|
||||||
|
defer m.Close()
|
||||||
|
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
conn := &mockConn{Buffer: buf}
|
||||||
|
|
||||||
|
req := models.Request{ID: 1, Method: "tailscale.getStatus"}
|
||||||
|
handleGetStatus(conn, req, m)
|
||||||
|
|
||||||
|
var resp models.Response[TailscaleState]
|
||||||
|
err := json.NewDecoder(buf).Decode(&resp)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 1, resp.ID)
|
||||||
|
assert.NotNil(t, resp.Result)
|
||||||
|
assert.True(t, resp.Result.Connected)
|
||||||
|
assert.Equal(t, "cachyos", resp.Result.Self.Hostname)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleRefresh(t *testing.T) {
|
||||||
|
m := handlerTestManager()
|
||||||
|
defer m.Close()
|
||||||
|
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
conn := &mockConn{Buffer: buf}
|
||||||
|
|
||||||
|
req := models.Request{ID: 1, Method: "tailscale.refresh"}
|
||||||
|
handleRefresh(conn, req, m)
|
||||||
|
|
||||||
|
var resp models.Response[models.SuccessResult]
|
||||||
|
err := json.NewDecoder(buf).Decode(&resp)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 1, resp.ID)
|
||||||
|
assert.NotNil(t, resp.Result)
|
||||||
|
assert.True(t, resp.Result.Success)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleRequest_UnknownMethod(t *testing.T) {
|
||||||
|
m := handlerTestManager()
|
||||||
|
defer m.Close()
|
||||||
|
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
conn := &mockConn{Buffer: buf}
|
||||||
|
|
||||||
|
req := models.Request{ID: 1, Method: "tailscale.unknownMethod"}
|
||||||
|
HandleRequest(conn, req, m)
|
||||||
|
|
||||||
|
var resp models.Response[any]
|
||||||
|
err := json.NewDecoder(buf).Decode(&resp)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Nil(t, resp.Result)
|
||||||
|
assert.NotEmpty(t, resp.Error)
|
||||||
|
assert.Contains(t, resp.Error, "unknown method")
|
||||||
|
}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
package tailscale
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
|
||||||
|
"tailscale.com/client/local"
|
||||||
|
"tailscale.com/ipn"
|
||||||
|
"tailscale.com/ipn/ipnstate"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
statusTimeout = 3 * time.Second
|
||||||
|
debounceWindow = 150 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
|
// tailscaleClient abstracts the Tailscale local API for testing.
|
||||||
|
type tailscaleClient interface {
|
||||||
|
WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error)
|
||||||
|
Status(ctx context.Context) (*ipnstate.Status, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ipnBusWatcher abstracts the IPN bus watcher for testing.
|
||||||
|
type ipnBusWatcher interface {
|
||||||
|
Next() (ipn.Notify, error)
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// localClientWrapper wraps local.Client to satisfy tailscaleClient.
|
||||||
|
type localClientWrapper struct {
|
||||||
|
client *local.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *localClientWrapper) WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
|
||||||
|
return w.client.WatchIPNBus(ctx, mask)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *localClientWrapper) Status(ctx context.Context) (*ipnstate.Status, error) {
|
||||||
|
return w.client.Status(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager manages Tailscale state via IPN bus events and subscriber notifications.
|
||||||
|
type Manager struct {
|
||||||
|
state *TailscaleState
|
||||||
|
stateMutex sync.RWMutex
|
||||||
|
subscribers syncmap.Map[string, chan TailscaleState]
|
||||||
|
client tailscaleClient
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
watchWG sync.WaitGroup
|
||||||
|
closed atomic.Bool
|
||||||
|
dirty chan struct{}
|
||||||
|
available atomic.Bool
|
||||||
|
availabilityCallback atomic.Pointer[func(bool)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewManager creates a new Tailscale manager and starts watching the IPN bus.
|
||||||
|
func NewManager(socketPath string) *Manager {
|
||||||
|
lc := &local.Client{Socket: socketPath}
|
||||||
|
return newManager(&localClientWrapper{client: lc})
|
||||||
|
}
|
||||||
|
|
||||||
|
func newManager(client tailscaleClient) *Manager {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
m := &Manager{
|
||||||
|
state: &TailscaleState{},
|
||||||
|
client: client,
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
|
dirty: make(chan struct{}, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
m.watchWG.Add(2)
|
||||||
|
go m.watchLoop(ctx)
|
||||||
|
go m.debounceLoop(ctx)
|
||||||
|
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) watchLoop(ctx context.Context) {
|
||||||
|
defer m.watchWG.Done()
|
||||||
|
|
||||||
|
mask := ipn.NotifyInitialState | ipn.NotifyInitialNetMap | ipn.NotifyRateLimit
|
||||||
|
backoff := time.Second
|
||||||
|
unreachableSent := false
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
watcher, err := m.client.WatchIPNBus(ctx, mask)
|
||||||
|
if err != nil {
|
||||||
|
if !unreachableSent {
|
||||||
|
m.updateState(&TailscaleState{Connected: false, BackendState: "Unreachable"})
|
||||||
|
unreachableSent = true
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(backoff):
|
||||||
|
}
|
||||||
|
backoff = min(backoff*2, 30*time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
unreachableSent = false
|
||||||
|
backoff = time.Second
|
||||||
|
log.Info("[Tailscale] Connected to IPN bus")
|
||||||
|
m.markAvailable()
|
||||||
|
|
||||||
|
for {
|
||||||
|
notify, err := watcher.Next()
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("[Tailscale] IPN bus error: %v", err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if notify.State == nil && notify.NetMap == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case m.dirty <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watcher.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// debounceLoop coalesces rapid bus notifications into a single Status RPC
|
||||||
|
// per debounceWindow, since NetMap events can fire many times per second
|
||||||
|
// on busy tailnets.
|
||||||
|
func (m *Manager) debounceLoop(ctx context.Context) {
|
||||||
|
defer m.watchWG.Done()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-m.dirty:
|
||||||
|
}
|
||||||
|
|
||||||
|
timer := time.NewTimer(debounceWindow)
|
||||||
|
collecting := true
|
||||||
|
for collecting {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
timer.Stop()
|
||||||
|
return
|
||||||
|
case <-m.dirty:
|
||||||
|
case <-timer.C:
|
||||||
|
collecting = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m.fetchAndBroadcast(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) fetchAndBroadcast(ctx context.Context) {
|
||||||
|
statusCtx, cancel := context.WithTimeout(ctx, statusTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
status, err := m.client.Status(statusCtx)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("[Tailscale] Failed to fetch status: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state := convertStatus(status)
|
||||||
|
m.updateState(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) updateState(state *TailscaleState) {
|
||||||
|
m.stateMutex.Lock()
|
||||||
|
m.state = state
|
||||||
|
m.stateMutex.Unlock()
|
||||||
|
|
||||||
|
m.broadcastState(*state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) broadcastState(state TailscaleState) {
|
||||||
|
if m.closed.Load() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.subscribers.Range(func(key string, ch chan TailscaleState) bool {
|
||||||
|
select {
|
||||||
|
case ch <- state:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsAvailable reports whether tailscaled has been reachable via the IPN bus
|
||||||
|
// at least once since the manager started. False means tailscaled appears
|
||||||
|
// to not be installed or has never been running.
|
||||||
|
func (m *Manager) IsAvailable() bool {
|
||||||
|
return m.available.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAvailabilityCallback registers a callback fired when the manager
|
||||||
|
// transitions from unavailable to available. Replaces any previously set
|
||||||
|
// callback. Must be set before the manager has a chance to detect tailscaled.
|
||||||
|
func (m *Manager) SetAvailabilityCallback(cb func(bool)) {
|
||||||
|
m.availabilityCallback.Store(&cb)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) markAvailable() {
|
||||||
|
if m.available.Swap(true) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cb := m.availabilityCallback.Load(); cb != nil {
|
||||||
|
(*cb)(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetState returns a copy of the current Tailscale state.
|
||||||
|
func (m *Manager) GetState() TailscaleState {
|
||||||
|
m.stateMutex.RLock()
|
||||||
|
defer m.stateMutex.RUnlock()
|
||||||
|
|
||||||
|
if m.state == nil {
|
||||||
|
return TailscaleState{}
|
||||||
|
}
|
||||||
|
return *m.state
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe creates a buffered channel for the given client ID.
|
||||||
|
func (m *Manager) Subscribe(clientID string) chan TailscaleState {
|
||||||
|
ch := make(chan TailscaleState, 64)
|
||||||
|
m.subscribers.Store(clientID, ch)
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unsubscribe removes and closes the subscriber channel.
|
||||||
|
func (m *Manager) Unsubscribe(clientID string) {
|
||||||
|
if val, ok := m.subscribers.LoadAndDelete(clientID); ok {
|
||||||
|
close(val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close stops the watch loop and closes all subscriber channels.
|
||||||
|
func (m *Manager) Close() {
|
||||||
|
m.closed.Store(true)
|
||||||
|
m.cancel()
|
||||||
|
m.watchWG.Wait()
|
||||||
|
|
||||||
|
m.subscribers.Range(func(key string, ch chan TailscaleState) bool {
|
||||||
|
close(ch)
|
||||||
|
m.subscribers.Delete(key)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshState triggers an immediate status fetch and broadcasts.
|
||||||
|
func (m *Manager) RefreshState() {
|
||||||
|
ctx, cancel := context.WithTimeout(m.ctx, statusTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
status, err := m.client.Status(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("[Tailscale] Failed to refresh state: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state := convertStatus(status)
|
||||||
|
m.updateState(state)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user