mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-05-12 15:29:43 -04:00
Compare commits
289 Commits
master
...
hotfix-1.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09d92c3207 | ||
|
|
6f4940bc66 | ||
|
|
63c3523ae1 | ||
|
|
eb5afcdc40 | ||
|
|
dd2a3f3efc | ||
|
|
161fa2dba5 | ||
|
|
f5f50f7add | ||
|
|
372cf2f566 | ||
|
|
b70acbc283 | ||
|
|
571c1158bf | ||
|
|
ac03a2e4b2 | ||
|
|
07460dc3b7 | ||
|
|
7412fee590 | ||
|
|
85c2954958 | ||
|
|
7c9e805cbe | ||
|
|
6926470b04 | ||
|
|
713ba1efbb | ||
|
|
1919ca7243 | ||
|
|
bcf41ed5ca | ||
|
|
5033bdc630 | ||
|
|
b8bfaf9a26 | ||
|
|
da45714c54 | ||
|
|
0c2d00b79c | ||
|
|
c10b42f599 | ||
|
|
4c617cf022 | ||
|
|
e75b95b854 | ||
|
|
6b15670918 | ||
|
|
c52b9e19a1 | ||
|
|
7a3444bd30 | ||
|
|
a733d760e4 | ||
|
|
1b33079e39 | ||
|
|
1cf0dd1031 | ||
|
|
8d49a5cbfc | ||
|
|
f5928b09d3 | ||
|
|
38373aa5f2 | ||
|
|
665680e15e | ||
|
|
210607cfbc | ||
|
|
69fca14611 | ||
|
|
10a235e686 | ||
|
|
253cc7f8a3 | ||
|
|
a63ad99684 | ||
|
|
c44c032879 | ||
|
|
dc1ce55971 | ||
|
|
9f65882a12 | ||
|
|
97bf83cce6 | ||
|
|
96bf0162d6 | ||
|
|
73b833731a | ||
|
|
84522aeaad | ||
|
|
faf1a277d2 | ||
|
|
60515736e6 | ||
|
|
1715e2eab7 | ||
|
|
4e14cf5cce | ||
|
|
a644c93b1b | ||
|
|
f9428a1009 | ||
|
|
b4b51785e5 | ||
|
|
0a97df6d49 | ||
|
|
352ba77677 | ||
|
|
d320035d97 | ||
|
|
8d262a9555 | ||
|
|
9bfa8310d2 | ||
|
|
088ed806ae | ||
|
|
07d2c94676 | ||
|
|
0bc1b7a3c2 | ||
|
|
c5987b28c0 | ||
|
|
18901c7cde | ||
|
|
519a8357a1 | ||
|
|
799773c62b | ||
|
|
247a674c79 | ||
|
|
72b598057c | ||
|
|
8180e30e8e | ||
|
|
dd9851b4f0 | ||
|
|
056576fbe7 | ||
|
|
d28a5bdf7f | ||
|
|
f62ea119f7 | ||
|
|
a1f9b98727 | ||
|
|
b9c8914d46 | ||
|
|
8697840d46 | ||
|
|
1f64bb8031 | ||
|
|
eea7d12c0b | ||
|
|
85173126f4 | ||
|
|
222187d8a6 | ||
|
|
bef3f65f63 | ||
|
|
bff83fe563 | ||
|
|
cbf00d133a | ||
|
|
347f06b758 | ||
|
|
9070903512 | ||
|
|
e9d030f6d8 | ||
|
|
fbf9e6d1b9 | ||
|
|
e803812344 | ||
|
|
9a64f2acf0 | ||
|
|
c647eafadc | ||
|
|
720ec07d13 | ||
|
|
4b4334e611 | ||
|
|
b69a96e80b | ||
|
|
1e6a73fd60 | ||
|
|
60b6280750 | ||
|
|
9e079f8a4b | ||
|
|
62c2e858ef | ||
|
|
78357d45bb | ||
|
|
3ff9564c9b | ||
|
|
b0989cecad | ||
|
|
47be6a1033 | ||
|
|
31b415b086 | ||
|
|
7156e1e299 | ||
|
|
c72c9bfb08 | ||
|
|
73c75fcc2c | ||
|
|
2ff42eba41 | ||
|
|
9f13465cd7 | ||
|
|
366a98e0cc | ||
|
|
31aeb8dc4b | ||
|
|
c4e7f3d62f | ||
|
|
a1d13f276a | ||
|
|
dbf132d633 | ||
|
|
59451890f1 | ||
|
|
e633c9e039 | ||
|
|
6c1fff2df1 | ||
|
|
3891d125d1 | ||
|
|
997011e008 | ||
|
|
2504396435 | ||
|
|
d206723b36 | ||
|
|
a0ec3d59b8 | ||
|
|
17ef08aa58 | ||
|
|
57279d1c53 | ||
|
|
8b003ac9cd | ||
|
|
0ea10b0ad2 | ||
|
|
2db4c9daa0 | ||
|
|
363964e90b | ||
|
|
a7b49eba70 | ||
|
|
4ae334f60f | ||
|
|
86c0064ff9 | ||
|
|
5a6b52f07f | ||
|
|
5aaa56853f | ||
|
|
35913c22f5 | ||
|
|
d7b560573c | ||
|
|
02a274ebe2 | ||
|
|
fc7b61c20b | ||
|
|
5880043f56 | ||
|
|
fee3b7f2a7 | ||
|
|
c0b0339fca | ||
|
|
26c1e62204 | ||
|
|
7b2d4dbe30 | ||
|
|
78c5d46c6b | ||
|
|
3fb85df504 | ||
|
|
227dd24726 | ||
|
|
ae6a656899 | ||
|
|
a4055e0f01 | ||
|
|
6d98c229ef | ||
|
|
71d93ad85e | ||
|
|
4ec21fcd3d | ||
|
|
0a2fe03fee | ||
|
|
4f4745609b | ||
|
|
a69cd515fb | ||
|
|
06c4b97a6b | ||
|
|
a6cf71a190 | ||
|
|
21750156dc | ||
|
|
f9b737f543 | ||
|
|
246b59f3b9 | ||
|
|
dcda81ea64 | ||
|
|
9909b665cd | ||
|
|
4bcd786be3 | ||
|
|
64c9222000 | ||
|
|
12acf2dd51 | ||
|
|
fea97b4aad | ||
|
|
c6d398eeac | ||
|
|
7a74be83d7 | ||
|
|
67a6427418 | ||
|
|
18b20d3225 | ||
|
|
8a76885fb6 | ||
|
|
69b1e61ab7 | ||
|
|
3f24cf37ca | ||
|
|
01218f34cb | ||
|
|
9da58d8296 | ||
|
|
af0038e634 | ||
|
|
05c312b9eb | ||
|
|
89d5c958c4 | ||
|
|
e4d86ad595 | ||
|
|
532b54a028 | ||
|
|
504d027c3f | ||
|
|
e8f95f4533 | ||
|
|
b83256c83a | ||
|
|
8e2cd21be8 | ||
|
|
c5413608da | ||
|
|
586bcad442 | ||
|
|
3b3d10f730 | ||
|
|
4834891b36 | ||
|
|
f60e65aecb | ||
|
|
01387b0123 | ||
|
|
1476658c23 | ||
|
|
7861c6e316 | ||
|
|
d2247d7b24 | ||
|
|
2ff78d4a02 | ||
|
|
785243ce5f | ||
|
|
0e1b868384 | ||
|
|
2b08e800e8 | ||
|
|
74e4f8ea1e | ||
|
|
9c58569b4c | ||
|
|
29de677e00 | ||
|
|
fae4944845 | ||
|
|
07a0ac4b7d | ||
|
|
b2d8f4d73b | ||
|
|
fe58c45233 | ||
|
|
3ea4e389eb | ||
|
|
7276f295fc | ||
|
|
93ed96a789 | ||
|
|
bea325e94c | ||
|
|
2f8f1c30ad | ||
|
|
f859a14173 | ||
|
|
153f39da48 | ||
|
|
e4accdd1c7 | ||
|
|
a2c89e0a8c | ||
|
|
e282831c2e | ||
|
|
5c5ff6195a | ||
|
|
c4bbf54679 | ||
|
|
98acafb4b8 | ||
|
|
da20681fc0 | ||
|
|
b38cb961b2 | ||
|
|
7a0bb07518 | ||
|
|
403e3e90a2 | ||
|
|
50b91f14b6 | ||
|
|
b3df47fce0 | ||
|
|
09bd65d746 | ||
|
|
020d56ab7f | ||
|
|
f3bee65da9 | ||
|
|
b14b0946e2 | ||
|
|
ca44205f1c | ||
|
|
2d39e8fd2a | ||
|
|
6d4df6e927 | ||
|
|
b8ab86e6c0 | ||
|
|
837329a6d8 | ||
|
|
8c6c2ffd23 | ||
|
|
ad3c8b6755 | ||
|
|
03a8e1e0d5 | ||
|
|
4d4d3c20a1 | ||
|
|
cef16d6bc9 | ||
|
|
aafaad1791 | ||
|
|
7906fdc2b0 | ||
|
|
397650ca52 | ||
|
|
826207006a | ||
|
|
58c2fcd31c | ||
|
|
b2a2b425ec | ||
|
|
942c9c9609 | ||
|
|
46d6e1cff3 | ||
|
|
a4137c57c1 | ||
|
|
1ad8b627f1 | ||
|
|
58a02ce290 | ||
|
|
8e1ad1a2be | ||
|
|
68cd7ab32c | ||
|
|
f649ce9a8e | ||
|
|
c4df242f07 | ||
|
|
26846c8d55 | ||
|
|
31b44a667c | ||
|
|
4f3b73ee21 | ||
|
|
4cfae91f02 | ||
|
|
8d947a6e95 | ||
|
|
1e84d4252c | ||
|
|
76072e1d4c | ||
|
|
6408dce4a9 | ||
|
|
0b2e1cca38 | ||
|
|
c1bfd8c0b7 | ||
|
|
90ffa5833b | ||
|
|
169c669286 | ||
|
|
f8350deafc | ||
|
|
0286a1b80b | ||
|
|
7c3e6c1f02 | ||
|
|
d2d72db3c9 | ||
|
|
f81f861408 | ||
|
|
af494543f5 | ||
|
|
db4de55338 | ||
|
|
37ecbbbbde | ||
|
|
d6a6d2a438 | ||
|
|
bf1c6eec74 | ||
|
|
0ddae80584 | ||
|
|
5c96c03bfa | ||
|
|
dfe36e47d8 | ||
|
|
63e1b75e57 | ||
|
|
29efdd8598 | ||
|
|
34d03cf11b | ||
|
|
c339389d44 | ||
|
|
af5f6eb656 | ||
|
|
a6d28e2553 | ||
|
|
6213267908 | ||
|
|
d084114149 | ||
|
|
f6d99eca0d | ||
|
|
722eb3289e | ||
|
|
b7f2bdcb2d | ||
|
|
11c20db6e6 | ||
|
|
8a4e3f8bb1 | ||
|
|
bc8fe97c13 | ||
|
|
47262155aa |
@@ -1,104 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,497 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1,115 +0,0 @@
|
||||
{
|
||||
"$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
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
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: ""
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
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: "%"
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
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", "#")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
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
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -1,320 +0,0 @@
|
||||
# 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
|
||||
@@ -1,272 +0,0 @@
|
||||
# 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"]
|
||||
}
|
||||
```
|
||||
@@ -1,176 +0,0 @@
|
||||
# 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
|
||||
@@ -1,240 +0,0 @@
|
||||
# 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"]
|
||||
}
|
||||
```
|
||||
@@ -1,308 +0,0 @@
|
||||
# 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", "!")
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -1,119 +0,0 @@
|
||||
# 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`)
|
||||
@@ -1,120 +0,0 @@
|
||||
# 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
|
||||
@@ -1,273 +0,0 @@
|
||||
# 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: [] }
|
||||
```
|
||||
@@ -1,216 +0,0 @@
|
||||
# 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
|
||||
```
|
||||
@@ -1,369 +0,0 @@
|
||||
# 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
2
.github/workflows/dms-stable.yml
vendored
2
.github/workflows/dms-stable.yml
vendored
@@ -20,7 +20,7 @@ jobs:
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ steps.app_token.outputs.token }}
|
||||
|
||||
4
.github/workflows/go-ci.yml
vendored
4
.github/workflows/go-ci.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install flatpak
|
||||
run: sudo apt update && sudo apt install -y flatpak
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
run: sudo flatpak install -y org.freedesktop.Platform/x86_64/24.08 app.zen_browser.zen
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: ./core/go.mod
|
||||
|
||||
|
||||
26
.github/workflows/nix-pr-check.yml
vendored
26
.github/workflows/nix-pr-check.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Nix flake and NixOS tests
|
||||
name: Check nix flake
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -9,35 +9,15 @@ on:
|
||||
jobs:
|
||||
check-flake:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Nix
|
||||
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
|
||||
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
|
||||
run: nix flake check
|
||||
|
||||
4
.github/workflows/prek.yml
vendored
4
.github/workflows/prek.yml
vendored
@@ -9,7 +9,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install flatpak
|
||||
run: sudo apt update && sudo apt install -y flatpak
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
run: sudo flatpak install -y org.freedesktop.Platform/x86_64/24.08 app.zen_browser.zen
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: core/go.mod
|
||||
|
||||
|
||||
16
.github/workflows/release.yml
vendored
16
.github/workflows/release.yml
vendored
@@ -32,13 +32,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.tag }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: ./core/go.mod
|
||||
|
||||
@@ -106,7 +106,7 @@ jobs:
|
||||
|
||||
- name: Upload artifacts (${{ matrix.arch }})
|
||||
if: matrix.arch == 'arm64'
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: core-assets-${{ matrix.arch }}
|
||||
path: |
|
||||
@@ -120,7 +120,7 @@ jobs:
|
||||
|
||||
- name: Upload artifacts with completions
|
||||
if: matrix.arch == 'amd64'
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: core-assets-${{ matrix.arch }}
|
||||
path: |
|
||||
@@ -147,7 +147,7 @@ jobs:
|
||||
# private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
# - name: Checkout
|
||||
# uses: actions/checkout@v6
|
||||
# uses: actions/checkout@v4
|
||||
# with:
|
||||
# token: ${{ steps.app_token.outputs.token }}
|
||||
# fetch-depth: 0
|
||||
@@ -181,7 +181,7 @@ jobs:
|
||||
TAG: ${{ inputs.tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.tag }}
|
||||
fetch-depth: 0
|
||||
@@ -192,12 +192,12 @@ jobs:
|
||||
git checkout ${TAG}
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: ./core/go.mod
|
||||
|
||||
- name: Download core artifacts
|
||||
uses: actions/download-artifact@v5
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: core-assets-*
|
||||
merge-multiple: true
|
||||
|
||||
4
.github/workflows/run-copr.yml
vendored
4
.github/workflows/run-copr.yml
vendored
@@ -46,7 +46,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Determine version
|
||||
id: version
|
||||
@@ -134,7 +134,7 @@ jobs:
|
||||
rpm -qpi "$SRPM"
|
||||
|
||||
- name: Upload SRPM artifact
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.package }}-stable-srpm-${{ steps.version.outputs.version }}
|
||||
path: ${{ steps.build.outputs.srpm_path }}
|
||||
|
||||
32
.github/workflows/run-obs.yml
vendored
32
.github/workflows/run-obs.yml
vendored
@@ -9,8 +9,8 @@ on:
|
||||
type: choice
|
||||
options:
|
||||
- dms
|
||||
- dms-greeter
|
||||
- dms-git
|
||||
- dms-greeter
|
||||
- all
|
||||
default: "dms"
|
||||
rebuild_release:
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -119,8 +119,9 @@ jobs:
|
||||
echo "🔄 Manual rebuild requested: $PKG (db$REBUILD)"
|
||||
|
||||
elif [[ "$PKG" == "all" ]]; then
|
||||
# Check each stable package and build list of those needing updates
|
||||
# Check each package and build list of those needing updates
|
||||
PACKAGES_TO_UPDATE=()
|
||||
check_dms_git && PACKAGES_TO_UPDATE+=("dms-git")
|
||||
if check_dms_stable; then
|
||||
PACKAGES_TO_UPDATE+=("dms")
|
||||
if [[ -n "$LATEST_TAG" ]]; then
|
||||
@@ -139,7 +140,7 @@ jobs:
|
||||
else
|
||||
echo "packages=" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=false" >> $GITHUB_OUTPUT
|
||||
echo "✓ Both packages up to date"
|
||||
echo "✓ All packages up to date"
|
||||
fi
|
||||
|
||||
elif [[ "$PKG" == "dms-git" ]]; then
|
||||
@@ -195,13 +196,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Wait before OBS upload
|
||||
run: sleep 3
|
||||
|
||||
- name: Determine packages to update
|
||||
id: packages
|
||||
run: |
|
||||
@@ -247,7 +245,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Update dms-git spec version
|
||||
if: contains(steps.packages.outputs.packages, 'dms-git')
|
||||
if: contains(steps.packages.outputs.packages, 'dms-git') || steps.packages.outputs.packages == 'all'
|
||||
run: |
|
||||
COMMIT_HASH=$(git rev-parse --short=8 HEAD)
|
||||
COMMIT_COUNT=$(git rev-list --count HEAD)
|
||||
@@ -268,7 +266,7 @@ jobs:
|
||||
} > distro/opensuse/dms-git.spec
|
||||
|
||||
- name: Update Debian dms-git changelog version
|
||||
if: contains(steps.packages.outputs.packages, 'dms-git')
|
||||
if: contains(steps.packages.outputs.packages, 'dms-git') || steps.packages.outputs.packages == 'all'
|
||||
run: |
|
||||
COMMIT_HASH=$(git rev-parse --short=8 HEAD)
|
||||
COMMIT_COUNT=$(git rev-list --count HEAD)
|
||||
@@ -347,7 +345,7 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: ./core/go.mod
|
||||
|
||||
@@ -367,16 +365,6 @@ jobs:
|
||||
EOF
|
||||
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
|
||||
id: upload
|
||||
env:
|
||||
@@ -401,7 +389,7 @@ jobs:
|
||||
UPLOADED_PACKAGES=()
|
||||
SKIPPED_PACKAGES=()
|
||||
|
||||
# PACKAGES can be space-separated list (e.g., "dms dms-greeter" from "all" check)
|
||||
# PACKAGES can be space-separated list (e.g., "dms-git dms" from "all" check)
|
||||
# Loop through each package and upload
|
||||
for PKG in $PACKAGES; do
|
||||
echo ""
|
||||
|
||||
272
.github/workflows/run-ppa.yml
vendored
272
.github/workflows/run-ppa.yml
vendored
@@ -4,15 +4,9 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
package:
|
||||
description: "Package to upload"
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- dms
|
||||
- dms-greeter
|
||||
- dms-git
|
||||
- all
|
||||
default: "dms"
|
||||
description: "Package to upload (dms, dms-git, dms-greeter, or all)"
|
||||
required: false
|
||||
default: "dms-git"
|
||||
rebuild_release:
|
||||
description: "Release number for rebuilds (e.g., 2, 3, 4 for ppa2, ppa3, ppa4)"
|
||||
required: false
|
||||
@@ -22,80 +16,147 @@ on:
|
||||
|
||||
jobs:
|
||||
check-updates:
|
||||
name: Check package/series updates
|
||||
name: Check for updates
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
outputs:
|
||||
has_updates: ${{ steps.check.outputs.has_updates }}
|
||||
targets: ${{ steps.check.outputs.targets }}
|
||||
targets_json: ${{ steps.check.outputs.targets_json }}
|
||||
packages: ${{ steps.check.outputs.packages }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y jq curl git
|
||||
|
||||
- name: Check for updates
|
||||
id: check
|
||||
run: |
|
||||
chmod +x distro/scripts/ppa-sync-plan.sh
|
||||
# Helper function to check dms-git commit
|
||||
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
|
||||
PACKAGE="dms-git"
|
||||
# Scheduled run - check dms-git only
|
||||
echo "packages=dms-git" >> $GITHUB_OUTPUT
|
||||
if check_dms_git; then
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
PACKAGE="${{ github.event.inputs.package }}"
|
||||
echo "has_updates=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
REBUILD_RELEASE="${{ github.event.inputs.rebuild_release }}"
|
||||
ARGS=(--package "$PACKAGE" --json)
|
||||
if [[ -n "$REBUILD_RELEASE" ]]; then
|
||||
ARGS+=(--rebuild "$REBUILD_RELEASE")
|
||||
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
|
||||
|
||||
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"
|
||||
elif [[ "$PKG" == "dms-git" ]]; then
|
||||
if check_dms_git; then
|
||||
echo "packages=$PKG" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "has_updates=false" >> "$GITHUB_OUTPUT"
|
||||
echo "targets=" >> "$GITHUB_OUTPUT"
|
||||
echo "targets_json=[]" >> "$GITHUB_OUTPUT"
|
||||
echo "No package/series uploads needed"
|
||||
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
|
||||
# Fallback
|
||||
echo "packages=dms-git" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
upload-ppa:
|
||||
name: Upload ${{ matrix.target }}
|
||||
name: Upload to PPA
|
||||
needs: check-updates
|
||||
runs-on: ubuntu-latest
|
||||
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:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: ./core/go.mod
|
||||
cache: false
|
||||
@@ -110,8 +171,7 @@ jobs:
|
||||
lftp \
|
||||
build-essential \
|
||||
fakeroot \
|
||||
dpkg-dev \
|
||||
openssh-client
|
||||
dpkg-dev
|
||||
|
||||
- name: Configure GPG
|
||||
env:
|
||||
@@ -119,32 +179,102 @@ jobs:
|
||||
run: |
|
||||
echo "$GPG_KEY" | gpg --import
|
||||
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: Upload target
|
||||
env:
|
||||
TARGET: ${{ matrix.target }}
|
||||
LAUNCHPAD_SSH_PRIVATE_KEY: ${{ secrets.LAUNCHPAD_SSH_PRIVATE_KEY }}
|
||||
LAUNCHPAD_SSH_LOGIN: ${{ secrets.LAUNCHPAD_SSH_LOGIN }}
|
||||
- name: Determine packages to upload
|
||||
id: packages
|
||||
run: |
|
||||
IFS=':' read -r PACKAGE UBUNTU_SERIES PPA_NUM <<< "$TARGET"
|
||||
# Use packages determined by check-updates job
|
||||
echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT
|
||||
|
||||
case "$PACKAGE" in
|
||||
dms) PPA_NAME="dms" ;;
|
||||
dms-git) PPA_NAME="dms-git" ;;
|
||||
dms-greeter) PPA_NAME="danklinux" ;;
|
||||
*) echo "::error::Unknown package $PACKAGE"; exit 1 ;;
|
||||
if [[ "${{ github.event_name }}" == "schedule" ]]; then
|
||||
echo "Triggered by schedule: uploading git package"
|
||||
elif [[ -n "${{ github.event.inputs.package }}" ]]; then
|
||||
echo "Manual trigger: ${{ needs.check-updates.outputs.packages }}"
|
||||
fi
|
||||
|
||||
- name: Upload to PPA
|
||||
run: |
|
||||
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" 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 "Uploading $PACKAGE to $PPA_NAME/$UBUNTU_SERIES with ppa$PPA_NUM"
|
||||
bash distro/scripts/ppa-upload.sh "$PACKAGE" "$PPA_NAME" "$UBUNTU_SERIES" "$PPA_NUM"
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "Uploading $PKG to PPA $PPA_NAME..."
|
||||
if [[ -n "$REBUILD_RELEASE" ]]; then
|
||||
echo "🔄 Using rebuild release number: ppa$REBUILD_RELEASE"
|
||||
fi
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
bash distro/scripts/ppa-upload.sh "$PKG" "$PPA_NAME" questing ${REBUILD_RELEASE:+"$REBUILD_RELEASE"}
|
||||
done
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "### PPA Package Upload" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- **Target:** ${{ matrix.target }}" >> "$GITHUB_STEP_SUMMARY"
|
||||
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"
|
||||
echo "- **DankLinux PPA:** https://launchpad.net/~avengemedia/+archive/ubuntu/danklinux/+packages" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "### PPA Package Upload Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
PACKAGES="${{ steps.packages.outputs.packages }}"
|
||||
|
||||
if [[ -z "$PACKAGES" ]]; then
|
||||
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
|
||||
|
||||
2
.github/workflows/update-vendor-hash.yml
vendored
2
.github/workflows/update-vendor-hash.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ steps.app_token.outputs.token }}
|
||||
|
||||
@@ -20,11 +20,3 @@ repos:
|
||||
language: system
|
||||
files: ^core/.*\.(go|mod|sum)$
|
||||
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,13 +1,5 @@
|
||||
This file is more of a quick reference so I know what to account for before next releases.
|
||||
|
||||
# 1.5.0
|
||||
- Overhauled shadows
|
||||
- App ID changed to com.danklinux.dms - breaking for window rules
|
||||
- Greeter stuff
|
||||
- Terminal mux
|
||||
- Locale overrides
|
||||
- new neovim theming
|
||||
|
||||
# 1.4.0
|
||||
|
||||
- Overhauled system monitor, graphs, styling
|
||||
|
||||
@@ -86,9 +86,7 @@ touch .qmlls.ini
|
||||
|
||||
4. Restart dms to generate the `.qmlls.ini` file
|
||||
|
||||
5. Run `make lint-qml` from the repo root to lint QML entrypoints (requires the `.qmlls.ini` generated above). The script needs the **Qt 6** `qmllint`; it checks `qmllint6`, Fedora's `qmllint-qt6`, `/usr/lib/qt6/bin/qmllint`, then `qmllint` in `PATH`. If your Qt 6 binary lives elsewhere, set `QMLLINT=/path/to/qmllint`.
|
||||
|
||||
6. Make your changes, test, and open a pull request.
|
||||
5. Make your changes, test, and open a pull request.
|
||||
|
||||
### I18n/Localization
|
||||
|
||||
|
||||
8
Makefile
8
Makefile
@@ -18,7 +18,7 @@ SHELL_INSTALL_DIR=$(DATA_DIR)/quickshell/dms
|
||||
ASSETS_DIR=assets
|
||||
APPLICATIONS_DIR=$(DATA_DIR)/applications
|
||||
|
||||
.PHONY: all build clean lint-qml install install-bin install-shell install-completions install-systemd install-icon install-desktop uninstall uninstall-bin uninstall-shell uninstall-completions uninstall-systemd uninstall-icon uninstall-desktop help
|
||||
.PHONY: all build clean install install-bin install-shell install-completions install-systemd install-icon install-desktop uninstall uninstall-bin uninstall-shell uninstall-completions uninstall-systemd uninstall-icon uninstall-desktop help
|
||||
|
||||
all: build
|
||||
|
||||
@@ -32,9 +32,6 @@ clean:
|
||||
@$(MAKE) -C $(CORE_DIR) clean
|
||||
@echo "Clean complete"
|
||||
|
||||
lint-qml:
|
||||
@./quickshell/scripts/qmllint-entrypoints.sh
|
||||
|
||||
# Installation targets
|
||||
install-bin:
|
||||
@echo "Installing $(BINARY_NAME) to $(INSTALL_DIR)..."
|
||||
@@ -79,7 +76,7 @@ install-desktop:
|
||||
@update-desktop-database -q $(APPLICATIONS_DIR) 2>/dev/null || true
|
||||
@echo "Desktop entry installed"
|
||||
|
||||
install: install-bin install-shell install-completions install-systemd install-icon install-desktop
|
||||
install: build install-bin install-shell install-completions install-systemd install-icon install-desktop
|
||||
@echo ""
|
||||
@echo "Installation complete!"
|
||||
@echo ""
|
||||
@@ -133,7 +130,6 @@ help:
|
||||
@echo " all (default) - Build the DMS binary"
|
||||
@echo " build - Same as 'all'"
|
||||
@echo " clean - Clean build artifacts"
|
||||
@echo " lint-qml - Run qmllint on shell entrypoints using the Quickshell tooling VFS"
|
||||
@echo ""
|
||||
@echo "Install:"
|
||||
@echo " install - Build and install everything (requires sudo)"
|
||||
|
||||
@@ -13,7 +13,7 @@ Built with [Quickshell](https://quickshell.org/) and [Go](https://go.dev/)
|
||||
[](https://github.com/AvengeMedia/DankMaterialShell/stargazers)
|
||||
[](https://github.com/AvengeMedia/DankMaterialShell/blob/master/LICENSE)
|
||||
[](https://github.com/AvengeMedia/DankMaterialShell/releases)
|
||||
[](https://archlinux.org/packages/extra/x86_64/dms-shell/)
|
||||
[](https://aur.archlinux.org/packages/dms-shell-bin)
|
||||
[>)](https://aur.archlinux.org/packages/dms-shell-git)
|
||||
[](https://ko-fi.com/danklinux)
|
||||
|
||||
|
||||
@@ -28,12 +28,6 @@ packages:
|
||||
outpkg: mocks_brightness
|
||||
interfaces:
|
||||
DBusConn:
|
||||
github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation:
|
||||
config:
|
||||
dir: "internal/mocks/geolocation"
|
||||
outpkg: mocks_geolocation
|
||||
interfaces:
|
||||
Client:
|
||||
github.com/AvengeMedia/DankMaterialShell/core/internal/server/network:
|
||||
config:
|
||||
dir: "internal/mocks/network"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/golangci/golangci-lint
|
||||
rev: v2.10.1
|
||||
rev: v2.9.0
|
||||
hooks:
|
||||
- id: golangci-lint-fmt
|
||||
require_serial: true
|
||||
|
||||
@@ -63,19 +63,19 @@ endif
|
||||
|
||||
build-all: build dankinstall
|
||||
|
||||
install:
|
||||
install: build
|
||||
@echo "Installing $(BINARY_NAME) to $(INSTALL_DIR)..."
|
||||
@install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_DIR)/$(BINARY_NAME)
|
||||
@echo "Installation complete"
|
||||
|
||||
install-all:
|
||||
install-all: build-all
|
||||
@echo "Installing $(BINARY_NAME) to $(INSTALL_DIR)..."
|
||||
@install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_DIR)/$(BINARY_NAME)
|
||||
@echo "Installing $(BINARY_NAME_INSTALL) to $(INSTALL_DIR)..."
|
||||
@install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME_INSTALL) $(INSTALL_DIR)/$(BINARY_NAME_INSTALL)
|
||||
@echo "Installation complete"
|
||||
|
||||
install-dankinstall:
|
||||
install-dankinstall: dankinstall
|
||||
@echo "Installing $(BINARY_NAME_INSTALL) to $(INSTALL_DIR)..."
|
||||
@install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME_INSTALL) $(INSTALL_DIR)/$(BINARY_NAME_INSTALL)
|
||||
@echo "Installation complete"
|
||||
|
||||
@@ -10,7 +10,7 @@ Go-based backend for DankMaterialShell providing system integration, IPC, and in
|
||||
Command-line interface and daemon for shell management and system control.
|
||||
|
||||
**dankinstall**
|
||||
Distribution-aware installer for deploying DMS and compositor configurations on Arch, Fedora, Debian, Ubuntu, openSUSE, and Gentoo. Supports both an interactive TUI and a headless (unattended) mode via CLI flags.
|
||||
Distribution-aware installer with TUI for deploying DMS and compositor configurations on Arch, Fedora, Debian, Ubuntu, openSUSE, and Gentoo.
|
||||
|
||||
## System Integration
|
||||
|
||||
@@ -147,50 +147,10 @@ go-wayland-scanner -i internal/proto/xml/wlr-gamma-control-unstable-v1.xml \
|
||||
|
||||
## Installation via dankinstall
|
||||
|
||||
**Interactive (TUI):**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://install.danklinux.com | sh
|
||||
```
|
||||
|
||||
**Headless (unattended):**
|
||||
|
||||
Headless mode requires cached sudo credentials. Run `sudo -v` first:
|
||||
|
||||
```bash
|
||||
sudo -v && curl -fsSL https://install.danklinux.com | sh -s -- -c niri -t ghostty -y
|
||||
sudo -v && curl -fsSL https://install.danklinux.com | sh -s -- -c hyprland -t kitty --include-deps dms-greeter -y
|
||||
```
|
||||
|
||||
| Flag | Short | Description |
|
||||
|------|-------|-------------|
|
||||
| `--compositor <niri|hyprland>` | `-c` | Compositor/WM to install (required for headless) |
|
||||
| `--term <ghostty|kitty|alacritty>` | `-t` | Terminal emulator (required for headless) |
|
||||
| `--include-deps <name,...>` | | Enable optional dependencies (e.g. `dms-greeter`) |
|
||||
| `--exclude-deps <name,...>` | | Skip specific dependencies |
|
||||
| `--replace-configs <name,...>` | | Replace specific configuration files (mutually exclusive with `--replace-configs-all`) |
|
||||
| `--replace-configs-all` | | Replace all configuration files (mutually exclusive with `--replace-configs`) |
|
||||
| `--yes` | `-y` | Required for headless mode — confirms installation without interactive prompts |
|
||||
|
||||
Headless mode requires `--yes` to proceed; without it, the installer exits with an error.
|
||||
Configuration files are not replaced by default unless `--replace-configs` or `--replace-configs-all` is specified.
|
||||
`dms-greeter` is disabled by default; use `--include-deps dms-greeter` to enable it.
|
||||
|
||||
When no flags are provided, `dankinstall` launches the interactive TUI.
|
||||
|
||||
### Headless mode validation rules
|
||||
|
||||
Headless mode activates when `--compositor` or `--term` is provided.
|
||||
|
||||
- Both `--compositor` and `--term` are required; providing only one results in an error.
|
||||
- Headless-only flags (`--include-deps`, `--exclude-deps`, `--replace-configs`, `--replace-configs-all`, `--yes`) are rejected in TUI mode.
|
||||
- Positional arguments are not accepted.
|
||||
|
||||
### Log file location
|
||||
|
||||
`dankinstall` writes logs to `/tmp` by default.
|
||||
Set the `DANKINSTALL_LOG_DIR` environment variable to override the log directory.
|
||||
|
||||
## Supported Distributions
|
||||
|
||||
Arch, Fedora, Debian, Ubuntu, openSUSE, Gentoo (and derivatives)
|
||||
|
||||
@@ -3,152 +3,20 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/headless"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/tui"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var Version = "dev"
|
||||
|
||||
// Flag variables bound via pflag
|
||||
var (
|
||||
compositor string
|
||||
term string
|
||||
includeDeps []string
|
||||
excludeDeps []string
|
||||
replaceConfigs []string
|
||||
replaceConfigsAll bool
|
||||
yes bool
|
||||
)
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "dankinstall",
|
||||
Short: "Install DankMaterialShell and its dependencies",
|
||||
Long: `dankinstall sets up DankMaterialShell with your chosen compositor and terminal.
|
||||
|
||||
Without flags, it launches an interactive TUI. Providing either --compositor
|
||||
or --term activates headless (unattended) mode, which requires both flags.
|
||||
|
||||
Headless mode requires cached sudo credentials. Run 'sudo -v' beforehand, or
|
||||
configure passwordless sudo for your user.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: runDankinstall,
|
||||
SilenceErrors: true,
|
||||
SilenceUsage: true,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.Flags().StringVarP(&compositor, "compositor", "c", "", "Compositor/WM to install: niri or hyprland (enables headless mode)")
|
||||
rootCmd.Flags().StringVarP(&term, "term", "t", "", "Terminal emulator to install: ghostty, kitty, or alacritty (enables headless mode)")
|
||||
rootCmd.Flags().StringSliceVar(&includeDeps, "include-deps", []string{}, "Optional deps to enable (e.g. dms-greeter)")
|
||||
rootCmd.Flags().StringSliceVar(&excludeDeps, "exclude-deps", []string{}, "Deps to skip during installation")
|
||||
rootCmd.Flags().StringSliceVar(&replaceConfigs, "replace-configs", []string{}, "Deploy only named configs (e.g. niri,ghostty)")
|
||||
rootCmd.Flags().BoolVar(&replaceConfigsAll, "replace-configs-all", false, "Deploy and replace all configurations")
|
||||
rootCmd.Flags().BoolVarP(&yes, "yes", "y", false, "Auto-confirm all prompts")
|
||||
}
|
||||
|
||||
func main() {
|
||||
if os.Getuid() == 0 {
|
||||
fmt.Fprintln(os.Stderr, "Error: dankinstall must not be run as root")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func runDankinstall(cmd *cobra.Command, args []string) error {
|
||||
headlessMode := compositor != "" || term != ""
|
||||
|
||||
if !headlessMode {
|
||||
// Reject headless-only flags when running in TUI mode.
|
||||
headlessOnly := []string{
|
||||
"include-deps",
|
||||
"exclude-deps",
|
||||
"replace-configs",
|
||||
"replace-configs-all",
|
||||
"yes",
|
||||
}
|
||||
var set []string
|
||||
for _, name := range headlessOnly {
|
||||
if cmd.Flags().Changed(name) {
|
||||
set = append(set, "--"+name)
|
||||
}
|
||||
}
|
||||
if len(set) > 0 {
|
||||
return fmt.Errorf("flags %s are only valid in headless mode (requires both --compositor and --term)", strings.Join(set, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
if headlessMode {
|
||||
return runHeadless()
|
||||
}
|
||||
return runTUI()
|
||||
}
|
||||
|
||||
func runHeadless() error {
|
||||
// Validate required flags
|
||||
if compositor == "" {
|
||||
return fmt.Errorf("--compositor is required for headless mode (niri or hyprland)")
|
||||
}
|
||||
if term == "" {
|
||||
return fmt.Errorf("--term is required for headless mode (ghostty, kitty, or alacritty)")
|
||||
}
|
||||
|
||||
cfg := headless.Config{
|
||||
Compositor: compositor,
|
||||
Terminal: term,
|
||||
IncludeDeps: includeDeps,
|
||||
ExcludeDeps: excludeDeps,
|
||||
ReplaceConfigs: replaceConfigs,
|
||||
ReplaceConfigsAll: replaceConfigsAll,
|
||||
Yes: yes,
|
||||
}
|
||||
|
||||
runner := headless.NewRunner(cfg)
|
||||
|
||||
// Set up file logging
|
||||
fileLogger, err := log.NewFileLogger()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: Failed to create log file: %v\n", err)
|
||||
}
|
||||
|
||||
if fileLogger != nil {
|
||||
fmt.Printf("Logging to: %s\n", fileLogger.GetLogPath())
|
||||
fileLogger.StartListening(runner.GetLogChan())
|
||||
defer func() {
|
||||
if err := fileLogger.Close(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: Failed to close log file: %v\n", err)
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
// Drain the log channel to prevent blocking sends from deadlocking
|
||||
// downstream components (distros, config deployer) that write to it.
|
||||
// Use an explicit stop signal because this code does not own the
|
||||
// runner log channel and cannot assume it will be closed.
|
||||
defer drainLogChan(runner.GetLogChan())()
|
||||
}
|
||||
|
||||
if err := runner.Run(); err != nil {
|
||||
if fileLogger != nil {
|
||||
fmt.Fprintf(os.Stderr, "\nFull logs are available at: %s\n", fileLogger.GetLogPath())
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if fileLogger != nil {
|
||||
fmt.Printf("\nFull logs are available at: %s\n", fileLogger.GetLogPath())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runTUI() error {
|
||||
fileLogger, err := log.NewFileLogger()
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: Failed to create log file: %v\n", err)
|
||||
@@ -170,50 +38,18 @@ func runTUI() error {
|
||||
|
||||
if fileLogger != nil {
|
||||
fileLogger.StartListening(model.GetLogChan())
|
||||
} else {
|
||||
// Drain the log channel to prevent blocking sends from deadlocking
|
||||
// downstream components (distros, config deployer) that write to it.
|
||||
// Use an explicit stop signal because this code does not own the
|
||||
// model log channel and cannot assume it will be closed.
|
||||
defer drainLogChan(model.GetLogChan())()
|
||||
}
|
||||
|
||||
p := tea.NewProgram(model, tea.WithAltScreen())
|
||||
if _, err := p.Run(); err != nil {
|
||||
fmt.Printf("Error running program: %v\n", err)
|
||||
if logFilePath != "" {
|
||||
fmt.Fprintf(os.Stderr, "\nFull logs are available at: %s\n", logFilePath)
|
||||
fmt.Printf("\nFull logs are available at: %s\n", logFilePath)
|
||||
}
|
||||
return fmt.Errorf("error running program: %w", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if logFilePath != "" {
|
||||
fmt.Printf("\nFull logs are available at: %s\n", logFilePath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// drainLogChan starts a goroutine that discards all messages from logCh,
|
||||
// preventing blocking sends from deadlocking downstream components. It returns
|
||||
// a cleanup function that signals the goroutine to stop and waits for it to
|
||||
// exit. Callers should defer the returned function.
|
||||
func drainLogChan(logCh <-chan string) func() {
|
||||
drainStop := make(chan struct{})
|
||||
drainDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(drainDone)
|
||||
for {
|
||||
select {
|
||||
case <-drainStop:
|
||||
return
|
||||
case _, ok := <-logCh:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return func() {
|
||||
close(drainStop)
|
||||
<-drainDone
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var authCmd = &cobra.Command{
|
||||
Use: "auth",
|
||||
Short: "Manage DMS authentication sync",
|
||||
Long: "Manage shared PAM/authentication setup for DMS greeter and lock screen",
|
||||
}
|
||||
|
||||
var authSyncCmd = &cobra.Command{
|
||||
Use: "sync",
|
||||
Short: "Sync DMS authentication configuration",
|
||||
Long: "Apply shared PAM/authentication changes for the lock screen and greeter based on current DMS settings",
|
||||
PreRunE: preRunPrivileged,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
yes, _ := cmd.Flags().GetBool("yes")
|
||||
term, _ := cmd.Flags().GetBool("terminal")
|
||||
if term {
|
||||
if err := syncAuthInTerminal(yes); err != nil {
|
||||
log.Fatalf("Error launching auth sync in terminal: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := syncAuth(yes); err != nil {
|
||||
log.Fatalf("Error syncing authentication: %v", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
authSyncCmd.Flags().BoolP("yes", "y", false, "Non-interactive mode: skip prompts")
|
||||
authSyncCmd.Flags().BoolP("terminal", "t", false, "Run auth sync in a new terminal (for entering sudo password)")
|
||||
}
|
||||
|
||||
func syncAuth(nonInteractive bool) error {
|
||||
if !nonInteractive {
|
||||
fmt.Println("=== DMS Authentication Sync ===")
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
logFunc := func(msg string) {
|
||||
fmt.Println(msg)
|
||||
}
|
||||
|
||||
if err := sharedpam.SyncAuthConfig(logFunc, "", sharedpam.SyncAuthOptions{}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !nonInteractive {
|
||||
fmt.Println("\n=== Authentication Sync Complete ===")
|
||||
fmt.Println("\nAuthentication changes have been applied.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncAuthInTerminal(nonInteractive bool) error {
|
||||
syncFlags := make([]string, 0, 1)
|
||||
if nonInteractive {
|
||||
syncFlags = append(syncFlags, "--yes")
|
||||
}
|
||||
|
||||
shellSyncCmd := "dms auth sync"
|
||||
if len(syncFlags) > 0 {
|
||||
shellSyncCmd += " " + strings.Join(syncFlags, " ")
|
||||
}
|
||||
shellCmd := shellSyncCmd + `; echo; echo "Authentication sync finished. Closing in 3 seconds..."; sleep 3`
|
||||
return runCommandInTerminal(shellCmd)
|
||||
}
|
||||
@@ -37,9 +37,6 @@ Output format flags (mutually exclusive, default: --hex):
|
||||
--cmyk - CMYK values (C% M% Y% K%)
|
||||
--json - JSON with all formats
|
||||
|
||||
Optional:
|
||||
--raw - Removes ANSI escape codes and background colors. Use this when piping to other commands
|
||||
|
||||
Examples:
|
||||
dms color pick # Pick color, output as hex
|
||||
dms color pick --rgb # Output as RGB
|
||||
@@ -56,7 +53,6 @@ func init() {
|
||||
colorPickCmd.Flags().Bool("hsv", false, "Output as HSV (H S% V%)")
|
||||
colorPickCmd.Flags().Bool("cmyk", false, "Output as CMYK (C% M% Y% K%)")
|
||||
colorPickCmd.Flags().Bool("json", false, "Output all formats as JSON")
|
||||
colorPickCmd.Flags().Bool("raw", false, "Removes ANSI escape codes and background colors. Use this when piping to other commands")
|
||||
colorPickCmd.Flags().StringVarP(&colorOutputFmt, "output-format", "o", "", "Custom output format template")
|
||||
colorPickCmd.Flags().BoolVarP(&colorAutocopy, "autocopy", "a", false, "Copy result to clipboard")
|
||||
colorPickCmd.Flags().BoolVarP(&colorLowercase, "lowercase", "l", false, "Output hex in lowercase")
|
||||
@@ -117,15 +113,7 @@ func runColorPick(cmd *cobra.Command, args []string) {
|
||||
|
||||
if jsonOutput {
|
||||
fmt.Println(output)
|
||||
return
|
||||
}
|
||||
|
||||
if raw, _ := cmd.Flags().GetBool("raw"); raw {
|
||||
fmt.Printf("%s\n", output)
|
||||
return
|
||||
}
|
||||
|
||||
if color.IsDark() {
|
||||
} else if color.IsDark() {
|
||||
fmt.Printf("\033[48;2;%d;%d;%dm\033[97m %s \033[0m\n", color.R, color.G, color.B, output)
|
||||
} else {
|
||||
fmt.Printf("\033[48;2;%d;%d;%dm\033[30m %s \033[0m\n", color.R, color.G, color.B, output)
|
||||
|
||||
@@ -26,17 +26,6 @@ var runCmd = &cobra.Command{
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
daemon, _ := cmd.Flags().GetBool("daemon")
|
||||
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 {
|
||||
runShellDaemon(session)
|
||||
} else {
|
||||
@@ -537,7 +526,5 @@ func getCommonCommands() []*cobra.Command {
|
||||
dlCmd,
|
||||
randrCmd,
|
||||
blurCmd,
|
||||
trashCmd,
|
||||
systemCmd,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1110,14 +1110,14 @@ func formatResultsPlain(results []checkResult) string {
|
||||
if currentCategory != -1 {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
fmt.Fprintf(&sb, "**%s**\n", r.category.String())
|
||||
sb.WriteString(fmt.Sprintf("**%s**\n", r.category.String()))
|
||||
currentCategory = r.category
|
||||
}
|
||||
|
||||
fmt.Fprintf(&sb, "- [%s] %s: %s\n", r.status, r.name, r.message)
|
||||
sb.WriteString(fmt.Sprintf("- [%s] %s: %s\n", r.status, r.name, r.message))
|
||||
|
||||
if doctorVerbose && r.details != "" {
|
||||
fmt.Fprintf(&sb, " - %s\n", r.details)
|
||||
sb.WriteString(fmt.Sprintf(" - %s\n", r.details))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1127,8 +1127,8 @@ func formatResultsPlain(results []checkResult) string {
|
||||
}
|
||||
|
||||
sb.WriteString("\n---\n")
|
||||
fmt.Fprintf(&sb, "**Summary:** %d error(s), %d warning(s), %d ok\n",
|
||||
ds.ErrorCount(), ds.WarningCount(), ds.OKCount())
|
||||
sb.WriteString(fmt.Sprintf("**Summary:** %d error(s), %d warning(s), %d ok\n",
|
||||
ds.ErrorCount(), ds.WarningCount(), ds.OKCount()))
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/greeter"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -28,11 +27,6 @@ var greeterCmd = &cobra.Command{
|
||||
Long: "Manage DMS greeter (greetd)",
|
||||
}
|
||||
|
||||
var (
|
||||
greeterConfigSyncFn = greeter.SyncDMSConfigs
|
||||
sharedAuthSyncFn = sharedpam.SyncAuthConfig
|
||||
)
|
||||
|
||||
var greeterInstallCmd = &cobra.Command{
|
||||
Use: "install",
|
||||
Short: "Install and configure DMS greeter",
|
||||
@@ -157,16 +151,6 @@ func init() {
|
||||
greeterUninstallCmd.Flags().BoolP("terminal", "t", false, "Run in a new terminal (for entering sudo password)")
|
||||
}
|
||||
|
||||
func syncGreeterConfigsAndAuth(dmsPath, compositor string, logFunc func(string), options sharedpam.SyncAuthOptions, beforeAuth func()) error {
|
||||
if err := greeterConfigSyncFn(dmsPath, compositor, logFunc, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
if beforeAuth != nil {
|
||||
beforeAuth()
|
||||
}
|
||||
return sharedAuthSyncFn(logFunc, "", options)
|
||||
}
|
||||
|
||||
func installGreeter(nonInteractive bool) error {
|
||||
fmt.Println("=== DMS Greeter Installation ===")
|
||||
|
||||
@@ -262,9 +246,7 @@ func installGreeter(nonInteractive bool) error {
|
||||
}
|
||||
|
||||
fmt.Println("\nSynchronizing DMS configurations...")
|
||||
if err := syncGreeterConfigsAndAuth(dmsPath, selectedCompositor, logFunc, sharedpam.SyncAuthOptions{}, func() {
|
||||
fmt.Println("\nConfiguring authentication...")
|
||||
}); err != nil {
|
||||
if err := greeter.SyncDMSConfigs(dmsPath, selectedCompositor, logFunc, "", false); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -299,7 +281,7 @@ func uninstallGreeter(nonInteractive bool) error {
|
||||
}
|
||||
|
||||
if !nonInteractive {
|
||||
fmt.Print("\nThis will:\n • Stop and disable greetd\n • Remove the DMS-managed greeter auth block\n • Remove the DMS AppArmor profile\n • Restore the most recent pre-DMS greetd config (if available)\n\nContinue? [y/N]: ")
|
||||
fmt.Print("\nThis will:\n • Stop and disable greetd\n • Remove the DMS PAM managed block\n • Remove the DMS AppArmor profile\n • Restore the most recent pre-DMS greetd config (if available)\n\nContinue? [y/N]: ")
|
||||
var response string
|
||||
fmt.Scanln(&response)
|
||||
if strings.ToLower(strings.TrimSpace(response)) != "y" {
|
||||
@@ -315,8 +297,8 @@ func uninstallGreeter(nonInteractive bool) error {
|
||||
fmt.Println(" ✓ greetd disabled")
|
||||
}
|
||||
|
||||
fmt.Println("\nRemoving DMS authentication configuration...")
|
||||
if err := sharedpam.RemoveManagedGreeterPamBlock(logFunc, ""); err != nil {
|
||||
fmt.Println("\nRemoving DMS PAM configuration...")
|
||||
if err := greeter.RemoveGreeterPamManagedBlock(logFunc, ""); err != nil {
|
||||
fmt.Printf(" ⚠ PAM cleanup failed: %v\n", err)
|
||||
}
|
||||
|
||||
@@ -543,7 +525,7 @@ func resolveLocalWrapperShell() (string, error) {
|
||||
|
||||
func syncGreeter(nonInteractive bool, forceAuth bool, local bool) error {
|
||||
if !nonInteractive {
|
||||
fmt.Println("=== DMS Greeter Sync ===")
|
||||
fmt.Println("=== DMS Greeter Theme Sync ===")
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
@@ -726,11 +708,7 @@ func syncGreeter(nonInteractive bool, forceAuth bool, local bool) error {
|
||||
}
|
||||
|
||||
fmt.Println("\nSynchronizing DMS configurations...")
|
||||
if err := syncGreeterConfigsAndAuth(dmsPath, compositor, logFunc, sharedpam.SyncAuthOptions{
|
||||
ForceGreeterAuth: forceAuth,
|
||||
}, func() {
|
||||
fmt.Println("\nConfiguring authentication...")
|
||||
}); err != nil {
|
||||
if err := greeter.SyncDMSConfigs(dmsPath, compositor, logFunc, "", forceAuth); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -743,9 +721,8 @@ func syncGreeter(nonInteractive bool, forceAuth bool, local bool) error {
|
||||
|
||||
fmt.Println("\n=== Sync Complete ===")
|
||||
fmt.Println("\nYour theme, settings, and wallpaper configuration have been synced with the greeter.")
|
||||
fmt.Println("Shared authentication settings were also checked and reconciled where needed.")
|
||||
if forceAuth {
|
||||
fmt.Println("Authentication has been configured for fingerprint and U2F (where modules exist).")
|
||||
fmt.Println("PAM has been configured for fingerprint and U2F (where modules exist).")
|
||||
}
|
||||
fmt.Println("The changes will be visible on the next login screen.")
|
||||
|
||||
@@ -1295,7 +1272,39 @@ func extractGreeterPathOverrideFromCommand(command string) string {
|
||||
}
|
||||
|
||||
func parseManagedGreeterPamAuth(pamText string) (managed bool, fingerprint bool, u2f bool, legacy bool) {
|
||||
return sharedpam.ParseManagedGreeterPamAuth(pamText)
|
||||
if pamText == "" {
|
||||
return false, false, false, false
|
||||
}
|
||||
|
||||
lines := strings.Split(pamText, "\n")
|
||||
inManaged := false
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
switch trimmed {
|
||||
case greeter.GreeterPamManagedBlockStart:
|
||||
managed = true
|
||||
inManaged = true
|
||||
continue
|
||||
case greeter.GreeterPamManagedBlockEnd:
|
||||
inManaged = false
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(trimmed, "# DMS greeter fingerprint") || strings.HasPrefix(trimmed, "# DMS greeter U2F") {
|
||||
legacy = true
|
||||
}
|
||||
if !inManaged {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(trimmed, "pam_fprintd") {
|
||||
fingerprint = true
|
||||
}
|
||||
if strings.Contains(trimmed, "pam_u2f") {
|
||||
u2f = true
|
||||
}
|
||||
}
|
||||
|
||||
return managed, fingerprint, u2f, legacy
|
||||
}
|
||||
|
||||
func packageInstallHint() string {
|
||||
@@ -1605,29 +1614,29 @@ func checkGreeterStatus() error {
|
||||
fmt.Println(" ℹ No managed auth block present (DMS-managed fingerprint/U2F lines are disabled)")
|
||||
}
|
||||
if legacyManaged {
|
||||
fmt.Println(" ⚠ Legacy unmanaged DMS PAM lines detected. Run 'dms auth sync' to normalize.")
|
||||
fmt.Println(" ⚠ Legacy unmanaged DMS PAM lines detected. Run 'dms greeter sync' to normalize.")
|
||||
allGood = false
|
||||
}
|
||||
enableFprintToggle, enableU2fToggle := false, false
|
||||
if enableFprint, enableU2f, settingsErr := sharedpam.ReadGreeterAuthToggles(homeDir); settingsErr == nil {
|
||||
if enableFprint, enableU2f, settingsErr := greeter.ReadGreeterAuthToggles(homeDir); settingsErr == nil {
|
||||
enableFprintToggle = enableFprint
|
||||
enableU2fToggle = enableU2f
|
||||
} else {
|
||||
fmt.Printf(" ℹ Could not read greeter auth toggles from settings: %v\n", settingsErr)
|
||||
}
|
||||
|
||||
includedFprintFile := sharedpam.DetectIncludedPamModule(string(pamData), "pam_fprintd.so")
|
||||
includedU2fFile := sharedpam.DetectIncludedPamModule(string(pamData), "pam_u2f.so")
|
||||
fprintAvailableForCurrentUser := sharedpam.FingerprintAuthAvailableForCurrentUser()
|
||||
includedFprintFile := greeter.DetectIncludedPamModule(string(pamData), "pam_fprintd.so")
|
||||
includedU2fFile := greeter.DetectIncludedPamModule(string(pamData), "pam_u2f.so")
|
||||
fprintAvailableForCurrentUser := greeter.FingerprintAuthAvailableForCurrentUser()
|
||||
|
||||
if managedFprint && includedFprintFile != "" {
|
||||
fmt.Printf(" ⚠ pam_fprintd found in both DMS managed block and %s.\n", includedFprintFile)
|
||||
fmt.Println(" Double fingerprint auth detected — run 'dms auth sync' to resolve.")
|
||||
fmt.Println(" Double fingerprint auth detected — run 'dms greeter sync' to resolve.")
|
||||
allGood = false
|
||||
}
|
||||
if managedU2f && includedU2fFile != "" {
|
||||
fmt.Printf(" ⚠ pam_u2f found in both DMS managed block and %s.\n", includedU2fFile)
|
||||
fmt.Println(" Double security-key auth detected — run 'dms auth sync' to resolve.")
|
||||
fmt.Println(" Double security-key auth detected — run 'dms greeter sync' to resolve.")
|
||||
allGood = false
|
||||
}
|
||||
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam"
|
||||
)
|
||||
|
||||
func TestSyncGreeterConfigsAndAuthDelegatesSharedAuth(t *testing.T) {
|
||||
origGreeterConfigSyncFn := greeterConfigSyncFn
|
||||
origSharedAuthSyncFn := sharedAuthSyncFn
|
||||
t.Cleanup(func() {
|
||||
greeterConfigSyncFn = origGreeterConfigSyncFn
|
||||
sharedAuthSyncFn = origSharedAuthSyncFn
|
||||
})
|
||||
|
||||
var calls []string
|
||||
greeterConfigSyncFn = func(dmsPath, compositor string, logFunc func(string), sudoPassword string) error {
|
||||
if dmsPath != "/tmp/dms" {
|
||||
t.Fatalf("unexpected dmsPath %q", dmsPath)
|
||||
}
|
||||
if compositor != "niri" {
|
||||
t.Fatalf("unexpected compositor %q", compositor)
|
||||
}
|
||||
if sudoPassword != "" {
|
||||
t.Fatalf("expected empty sudoPassword, got %q", sudoPassword)
|
||||
}
|
||||
calls = append(calls, "configs")
|
||||
return nil
|
||||
}
|
||||
|
||||
var gotOptions sharedpam.SyncAuthOptions
|
||||
sharedAuthSyncFn = func(logFunc func(string), sudoPassword string, options sharedpam.SyncAuthOptions) error {
|
||||
if sudoPassword != "" {
|
||||
t.Fatalf("expected empty sudoPassword, got %q", sudoPassword)
|
||||
}
|
||||
gotOptions = options
|
||||
calls = append(calls, "auth")
|
||||
return nil
|
||||
}
|
||||
|
||||
err := syncGreeterConfigsAndAuth("/tmp/dms", "niri", func(string) {}, sharedpam.SyncAuthOptions{
|
||||
ForceGreeterAuth: true,
|
||||
}, func() {
|
||||
calls = append(calls, "before-auth")
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("syncGreeterConfigsAndAuth returned error: %v", err)
|
||||
}
|
||||
|
||||
wantCalls := []string{"configs", "before-auth", "auth"}
|
||||
if !reflect.DeepEqual(calls, wantCalls) {
|
||||
t.Fatalf("call order = %v, want %v", calls, wantCalls)
|
||||
}
|
||||
if !gotOptions.ForceGreeterAuth {
|
||||
t.Fatalf("expected ForceGreeterAuth to be true, got %+v", gotOptions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncGreeterConfigsAndAuthStopsOnConfigError(t *testing.T) {
|
||||
origGreeterConfigSyncFn := greeterConfigSyncFn
|
||||
origSharedAuthSyncFn := sharedAuthSyncFn
|
||||
t.Cleanup(func() {
|
||||
greeterConfigSyncFn = origGreeterConfigSyncFn
|
||||
sharedAuthSyncFn = origSharedAuthSyncFn
|
||||
})
|
||||
|
||||
greeterConfigSyncFn = func(string, string, func(string), string) error {
|
||||
return errors.New("config sync failed")
|
||||
}
|
||||
|
||||
authCalled := false
|
||||
sharedAuthSyncFn = func(func(string), string, sharedpam.SyncAuthOptions) error {
|
||||
authCalled = true
|
||||
return nil
|
||||
}
|
||||
|
||||
err := syncGreeterConfigsAndAuth("/tmp/dms", "niri", func(string) {}, sharedpam.SyncAuthOptions{}, nil)
|
||||
if err == nil || err.Error() != "config sync failed" {
|
||||
t.Fatalf("expected config sync error, got %v", err)
|
||||
}
|
||||
if authCalled {
|
||||
t.Fatal("expected auth sync not to run after config sync failure")
|
||||
}
|
||||
}
|
||||
@@ -57,11 +57,10 @@ func init() {
|
||||
cmd.Flags().Bool("sync-mode-with-portal", false, "Sync color scheme with GNOME portal")
|
||||
cmd.Flags().Bool("terminals-always-dark", false, "Force terminal themes to dark variant")
|
||||
cmd.Flags().String("skip-templates", "", "Comma-separated list of templates to skip")
|
||||
cmd.Flags().Float64("contrast", 0, "Contrast value from -1 to 1 (0 = standard)")
|
||||
}
|
||||
|
||||
matugenQueueCmd.Flags().Bool("wait", true, "Wait for completion")
|
||||
matugenQueueCmd.Flags().Duration("timeout", 90*time.Second, "Timeout for waiting")
|
||||
matugenQueueCmd.Flags().Duration("timeout", 30*time.Second, "Timeout for waiting")
|
||||
}
|
||||
|
||||
func buildMatugenOptions(cmd *cobra.Command) matugen.Options {
|
||||
@@ -78,7 +77,6 @@ func buildMatugenOptions(cmd *cobra.Command) matugen.Options {
|
||||
syncModeWithPortal, _ := cmd.Flags().GetBool("sync-mode-with-portal")
|
||||
terminalsAlwaysDark, _ := cmd.Flags().GetBool("terminals-always-dark")
|
||||
skipTemplates, _ := cmd.Flags().GetString("skip-templates")
|
||||
contrast, _ := cmd.Flags().GetFloat64("contrast")
|
||||
|
||||
return matugen.Options{
|
||||
StateDir: stateDir,
|
||||
@@ -89,7 +87,6 @@ func buildMatugenOptions(cmd *cobra.Command) matugen.Options {
|
||||
Mode: matugen.ColorMode(mode),
|
||||
IconTheme: iconTheme,
|
||||
MatugenType: matugenType,
|
||||
Contrast: contrast,
|
||||
RunUserTemplates: runUserTemplates,
|
||||
StockColors: stockColors,
|
||||
SyncModeWithPortal: syncModeWithPortal,
|
||||
@@ -131,7 +128,6 @@ func runMatugenQueue(cmd *cobra.Command, args []string) {
|
||||
"syncModeWithPortal": opts.SyncModeWithPortal,
|
||||
"terminalsAlwaysDark": opts.TerminalsAlwaysDark,
|
||||
"skipTemplates": opts.SkipTemplates,
|
||||
"contrast": opts.Contrast,
|
||||
"wait": wait,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -22,8 +22,6 @@ var (
|
||||
ssNoClipboard bool
|
||||
ssNoFile bool
|
||||
ssNoNotify bool
|
||||
ssNoConfirm bool
|
||||
ssReset bool
|
||||
ssStdout bool
|
||||
)
|
||||
|
||||
@@ -52,10 +50,8 @@ Examples:
|
||||
dms screenshot output -o DP-1 # Specific output
|
||||
dms screenshot window # Focused window (Hyprland)
|
||||
dms screenshot last # Last region (pre-selected)
|
||||
dms screenshot --reset # Reset last region pre-selection
|
||||
dms screenshot --no-clipboard # Save file only
|
||||
dms screenshot --no-file # Clipboard only
|
||||
dms screenshot --no-confirm # Region capture on mouse release
|
||||
dms screenshot --cursor=on # Include cursor
|
||||
dms screenshot -f jpg -q 85 # JPEG with quality 85`,
|
||||
}
|
||||
@@ -123,8 +119,6 @@ func init() {
|
||||
screenshotCmd.PersistentFlags().BoolVar(&ssNoClipboard, "no-clipboard", false, "Don't copy to clipboard")
|
||||
screenshotCmd.PersistentFlags().BoolVar(&ssNoFile, "no-file", false, "Don't save to file")
|
||||
screenshotCmd.PersistentFlags().BoolVar(&ssNoNotify, "no-notify", false, "Don't show notification")
|
||||
screenshotCmd.PersistentFlags().BoolVar(&ssNoConfirm, "no-confirm", false, "Region mode: capture on mouse release without Enter/Space confirmation")
|
||||
screenshotCmd.PersistentFlags().BoolVar(&ssReset, "reset", false, "Reset saved last-region preselection before capturing")
|
||||
screenshotCmd.PersistentFlags().BoolVar(&ssStdout, "stdout", false, "Output image to stdout (for piping to swappy, etc.)")
|
||||
|
||||
screenshotCmd.AddCommand(ssRegionCmd)
|
||||
@@ -148,8 +142,6 @@ func getScreenshotConfig(mode screenshot.Mode) screenshot.Config {
|
||||
config.Clipboard = !ssNoClipboard
|
||||
config.SaveFile = !ssNoFile
|
||||
config.Notify = !ssNoNotify
|
||||
config.NoConfirm = ssNoConfirm
|
||||
config.Reset = ssReset
|
||||
config.Stdout = ssStdout
|
||||
|
||||
if ssOutputDir != "" {
|
||||
|
||||
@@ -1,365 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
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,18 +15,14 @@ func init() {
|
||||
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("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")
|
||||
|
||||
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd)
|
||||
authCmd.AddCommand(authSyncCmd)
|
||||
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
|
||||
updateCmd.AddCommand(updateCheckCmd)
|
||||
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
|
||||
rootCmd.AddCommand(getCommonCommands()...)
|
||||
|
||||
rootCmd.AddCommand(authCmd)
|
||||
rootCmd.AddCommand(updateCmd)
|
||||
|
||||
rootCmd.SetHelpTemplate(getHelpTemplate())
|
||||
|
||||
@@ -15,16 +15,12 @@ func init() {
|
||||
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("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")
|
||||
|
||||
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd)
|
||||
authCmd.AddCommand(authSyncCmd)
|
||||
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
|
||||
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
|
||||
rootCmd.AddCommand(getCommonCommands()...)
|
||||
rootCmd.AddCommand(authCmd)
|
||||
|
||||
rootCmd.SetHelpTemplate(getHelpTemplate())
|
||||
}
|
||||
|
||||
@@ -80,16 +80,6 @@ func getRuntimeDir() string {
|
||||
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 {
|
||||
_, err := exec.LookPath("systemd-run")
|
||||
return err == nil
|
||||
@@ -223,8 +213,6 @@ func runShellInteractive(session bool) {
|
||||
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland;xcb")
|
||||
}
|
||||
|
||||
cmd.Env = appendLogEnv(cmd.Env)
|
||||
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
@@ -444,9 +432,6 @@ func runShellDaemon(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() {
|
||||
cmd.Env = append(cmd.Env, "DMS_DEFAULT_LAUNCH_PREFIX=systemd-run --user --scope")
|
||||
}
|
||||
@@ -468,8 +453,6 @@ func runShellDaemon(session bool) {
|
||||
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)
|
||||
if err != nil {
|
||||
log.Fatalf("Error opening /dev/null: %v", err)
|
||||
|
||||
@@ -15,7 +15,7 @@ func isReadOnlyCommand(args []string) bool {
|
||||
continue
|
||||
}
|
||||
switch arg {
|
||||
case "completion", "help", "__complete", "system":
|
||||
case "completion", "help", "__complete":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
||||
78
core/go.mod
78
core/go.mod
@@ -1,100 +1,76 @@
|
||||
module github.com/AvengeMedia/DankMaterialShell/core
|
||||
|
||||
go 1.26.1
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/Wifx/gonetworkmanager/v2 v2.2.0
|
||||
github.com/alecthomas/chroma/v2 v2.24.1
|
||||
github.com/alecthomas/chroma/v2 v2.23.1
|
||||
github.com/charmbracelet/bubbles v1.0.0
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/charmbracelet/log v1.0.0
|
||||
github.com/fsnotify/fsnotify v1.10.1
|
||||
github.com/charmbracelet/log v0.4.2
|
||||
github.com/fsnotify/fsnotify v1.9.0
|
||||
github.com/godbus/dbus/v5 v5.2.2
|
||||
github.com/holoplot/go-evdev v0.0.0-20260504100651-66d1748fe847
|
||||
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83
|
||||
github.com/pilebones/go-udev v0.9.1
|
||||
github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/yeqown/go-qrcode/v2 v2.2.5
|
||||
github.com/yeqown/go-qrcode/writer/standard v1.3.0
|
||||
github.com/yuin/goldmark v1.8.2
|
||||
github.com/yuin/goldmark v1.7.16
|
||||
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
|
||||
go.etcd.io/bbolt v1.4.3
|
||||
go4.org/mem v0.0.0-20240501181205-ae6ca9944745
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f
|
||||
golang.org/x/image v0.39.0
|
||||
tailscale.com v1.96.5
|
||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a
|
||||
golang.org/x/image v0.36.0
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.4.1 // indirect
|
||||
github.com/akutz/memconn v0.1.0 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.10.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // 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/dblohm7/wingoes v0.0.0-20250822163801-6d8e6105c62d // indirect
|
||||
github.com/dlclark/regexp2 v1.12.0 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/emirpasic/gods v1.18.1 // 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/go-billy/v6 v6.0.0-20260504142752-cb8e9d337266 // indirect
|
||||
github.com/go-json-experiment/json v0.0.0-20260430182902-b6187a392ed4 // indirect
|
||||
github.com/go-git/go-billy/v6 v6.0.0-20260209124918-37866f83c2d3 // indirect
|
||||
github.com/go-logfmt/logfmt v0.6.1 // indirect
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // 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/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/kevinburke/ssh_config v1.6.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.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/pjbgf/sha1cd v0.5.0 // indirect
|
||||
github.com/sergi/go-diff v1.4.0 // 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
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // 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
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.2 // indirect
|
||||
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.7 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/go-git/go-git/v6 v6.0.0-alpha.2
|
||||
github.com/go-git/go-git/v6 v6.0.0-20260216160506-e6a3f881772f
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0
|
||||
github.com/mattn/go-isatty v0.0.22
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.23 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/spf13/afero v1.15.0
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sys v0.43.0
|
||||
golang.org/x/text v0.36.0
|
||||
golang.org/x/sys v0.41.0
|
||||
golang.org/x/text v0.34.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
|
||||
170
core/go.sum
170
core/go.sum
@@ -1,18 +1,14 @@
|
||||
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/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM=
|
||||
github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
|
||||
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
|
||||
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
|
||||
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/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/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.24.1 h1:m5ffpfZbIb++k8AqFEKy9uVgY12xIQtBsQlc6DfZJQM=
|
||||
github.com/alecthomas/chroma/v2 v2.24.1/go.mod h1:l+ohZ9xRXIbGe7cIW+YZgOGbvuVLjMps/FYN/CwuabI=
|
||||
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
|
||||
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
|
||||
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/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
@@ -28,93 +24,68 @@ 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/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
|
||||
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
|
||||
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/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/log v1.0.0 h1:HVVVMmfOorfj3BA9i8X8UL69Hoz9lI0PYwXfJvOdRc4=
|
||||
github.com/charmbracelet/log v1.0.0/go.mod h1:uYgY3SmLpwJWxmlrPwXvzVYujxis1vAKRV/0VQB7yWA=
|
||||
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
|
||||
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
|
||||
github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
|
||||
github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
|
||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||
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/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/cilium/ebpf v0.16.0 h1:+BiEnHL6Z7lXnlGUsXQPPAE7+kenAd4ES8MQ5min0Ok=
|
||||
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/displaywidth v0.10.0 h1:GhBG8WuerxjFQQYeuZAeVTuyxuX+UraiZGD4HJQ3Y8g=
|
||||
github.com/clipperhouse/displaywidth v0.10.0/go.mod h1:XqJajYsaiEwkxOj4bowCTMcT1SgvHo9flfF3jQasdbs=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
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/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/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/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.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.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.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
||||
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||
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/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
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/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
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/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||
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/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs=
|
||||
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-20260504142752-cb8e9d337266/go.mod h1:CdBVp7CXl9l3sOyNEog46cP1Pvx/hjCe9AD0mtaIUYU=
|
||||
github.com/go-git/go-git-fixtures/v6 v6.0.0-20260405195209-b16dd39735e0 h1:XoTsdvaghuVfIr7HpNTmFDLu2nz3I2iGqyn6Uk6MkJc=
|
||||
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-alpha.2 h1:T3loNtDuAixNzXtlQxZhnYiYpaQ3CA4vn9RssAniEeI=
|
||||
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-git/go-billy/v6 v6.0.0-20260209124918-37866f83c2d3 h1:UU7oARtwQ5g85aFiCSwIUA6PBmAshYj0sytl/5CCBgs=
|
||||
github.com/go-git/go-billy/v6 v6.0.0-20260209124918-37866f83c2d3/go.mod h1:ZW9JC5gionMP1kv5uiaOaV23q0FFmNrVOV8VW+y/acc=
|
||||
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/v5 v5.1.2-0.20260122163445-0622d7459a67/go.mod h1:xKt0pNHST9tYHvbiLxSY27CQWFwgIxBJuDrOE0JvbZw=
|
||||
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-20260216160506-e6a3f881772f/go.mod h1:B88nWzfnhTlIikoJ4d84Nc9noKS5mJoA7SgDdkt0aPU=
|
||||
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/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||
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/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/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/holoplot/go-evdev v0.0.0-20260504100651-66d1748fe847 h1:1rQ5UQXFm02DXEtsIpotfA32WJ9KceS6t8w5K8QtFqc=
|
||||
github.com/holoplot/go-evdev v0.0.0-20260504100651-66d1748fe847/go.mod h1:iHAf8OIncO2gcQ8XOjS7CMJ2aPbX2Bs0wl5pZyanEqk=
|
||||
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83 h1:B+A58zGFuDrvEZpPN+yS6swJA0nzqgZvDzgl/OPyefU=
|
||||
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83/go.mod h1:iHAf8OIncO2gcQ8XOjS7CMJ2aPbX2Bs0wl5pZyanEqk=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
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/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
@@ -126,20 +97,14 @@ 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.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.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.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
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-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
|
||||
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/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
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/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
@@ -148,13 +113,10 @@ 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/pilebones/go-udev v0.9.1 h1:uN72M1C1fgzhsVmBGEM8w9RD1JY4iVsPZpr+Z6rb3O8=
|
||||
github.com/pilebones/go-udev v0.9.1/go.mod h1:Bgcl07crebF3JSeS4+nuaRvhWFdCeFoBhXXeAp93XNo=
|
||||
github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
|
||||
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/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
|
||||
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
|
||||
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.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/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
@@ -178,62 +140,40 @@ 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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
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/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/go.mod h1:uHpt9CM0V1HeXLz+Wg5MN50/sI/fQhfkZlOM+cOTHxw=
|
||||
github.com/yeqown/go-qrcode/writer/standard v1.3.0 h1:chdyhEfRtUPgQtuPeaWVGQ/TQx4rE1PqeoW3U+53t34=
|
||||
github.com/yeqown/go-qrcode/writer/standard v1.3.0/go.mod h1:O4MbzsotGCvy8upYPCR91j81dr5XLT7heuljcNXW+oQ=
|
||||
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/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
|
||||
github.com/yuin/goldmark v1.7.16/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/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
|
||||
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
||||
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek=
|
||||
go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
|
||||
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/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
|
||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
|
||||
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
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=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
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-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.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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
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=
|
||||
|
||||
@@ -39,10 +39,11 @@ type LayerSurface struct {
|
||||
wlSurface *client.Surface
|
||||
layerSurf *wlr_layer_shell.ZwlrLayerSurfaceV1
|
||||
viewport *wp_viewporter.WpViewport
|
||||
wlPools [2]*client.ShmPool
|
||||
wlBuffers [2]*client.Buffer
|
||||
slotBusy [2]bool
|
||||
needsRedraw bool
|
||||
wlPool *client.ShmPool
|
||||
wlBuffer *client.Buffer
|
||||
bufferBusy bool
|
||||
oldPool *client.ShmPool
|
||||
oldBuffer *client.Buffer
|
||||
scopyBuffer *client.Buffer
|
||||
configured bool
|
||||
hidden bool
|
||||
@@ -135,7 +136,6 @@ func (p *Picker) Run() (*Color, error) {
|
||||
break
|
||||
}
|
||||
|
||||
p.flushRedraws()
|
||||
p.checkDone()
|
||||
}
|
||||
|
||||
@@ -164,15 +164,6 @@ func (p *Picker) checkDone() {
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Picker) flushRedraws() {
|
||||
for _, ls := range p.surfaces {
|
||||
if !ls.needsRedraw {
|
||||
continue
|
||||
}
|
||||
p.redrawSurface(ls)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Picker) connect() error {
|
||||
display, err := client.Connect("")
|
||||
if err != nil {
|
||||
@@ -516,45 +507,47 @@ func (p *Picker) captureForSurface(ls *LayerSurface) {
|
||||
}
|
||||
|
||||
func (p *Picker) redrawSurface(ls *LayerSurface) {
|
||||
slot := ls.state.FrontIndex()
|
||||
if ls.slotBusy[slot] {
|
||||
ls.needsRedraw = true
|
||||
return
|
||||
}
|
||||
|
||||
var renderBuf *ShmBuffer
|
||||
switch {
|
||||
case ls.hidden:
|
||||
if ls.hidden {
|
||||
renderBuf = ls.state.RedrawScreenOnly()
|
||||
default:
|
||||
} else {
|
||||
renderBuf = ls.state.Redraw()
|
||||
}
|
||||
if renderBuf == nil {
|
||||
return
|
||||
}
|
||||
|
||||
ls.needsRedraw = false
|
||||
if ls.oldBuffer != nil {
|
||||
ls.oldBuffer.Destroy()
|
||||
ls.oldBuffer = nil
|
||||
}
|
||||
if ls.oldPool != nil {
|
||||
ls.oldPool.Destroy()
|
||||
ls.oldPool = nil
|
||||
}
|
||||
|
||||
ls.oldPool = ls.wlPool
|
||||
ls.oldBuffer = ls.wlBuffer
|
||||
ls.wlPool = nil
|
||||
ls.wlBuffer = nil
|
||||
|
||||
if ls.wlPools[slot] == nil {
|
||||
pool, err := p.shm.CreatePool(renderBuf.Fd(), int32(renderBuf.Size()))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ls.wlPools[slot] = pool
|
||||
ls.wlPool = pool
|
||||
|
||||
wlBuffer, err := pool.CreateBuffer(0, int32(renderBuf.Width), int32(renderBuf.Height), int32(renderBuf.Stride), uint32(ls.state.ScreenFormat()))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ls.wlBuffers[slot] = wlBuffer
|
||||
ls.wlBuffer = wlBuffer
|
||||
|
||||
s := slot
|
||||
lsRef := ls
|
||||
wlBuffer.SetReleaseHandler(func(e client.BufferReleaseEvent) {
|
||||
ls.slotBusy[s] = false
|
||||
lsRef.bufferBusy = false
|
||||
})
|
||||
}
|
||||
|
||||
ls.slotBusy[slot] = true
|
||||
ls.bufferBusy = true
|
||||
|
||||
logicalW, logicalH := ls.state.LogicalSize()
|
||||
if logicalW == 0 || logicalH == 0 {
|
||||
@@ -573,7 +566,7 @@ func (p *Picker) redrawSurface(ls *LayerSurface) {
|
||||
}
|
||||
_ = ls.wlSurface.SetBufferScale(bufferScale)
|
||||
}
|
||||
_ = ls.wlSurface.Attach(ls.wlBuffers[slot], 0, 0)
|
||||
_ = ls.wlSurface.Attach(wlBuffer, 0, 0)
|
||||
_ = ls.wlSurface.Damage(0, 0, int32(logicalW), int32(logicalH))
|
||||
_ = ls.wlSurface.Commit()
|
||||
|
||||
@@ -641,7 +634,7 @@ func (p *Picker) setupPointerHandlers() {
|
||||
}
|
||||
|
||||
p.activeSurface.state.OnPointerMotion(e.SurfaceX, e.SurfaceY)
|
||||
p.activeSurface.needsRedraw = true
|
||||
p.redrawSurface(p.activeSurface)
|
||||
})
|
||||
|
||||
p.pointer.SetLeaveHandler(func(e client.PointerLeaveEvent) {
|
||||
@@ -662,7 +655,7 @@ func (p *Picker) setupPointerHandlers() {
|
||||
return
|
||||
}
|
||||
p.activeSurface.state.OnPointerMotion(e.SurfaceX, e.SurfaceY)
|
||||
p.activeSurface.needsRedraw = true
|
||||
p.redrawSurface(p.activeSurface)
|
||||
})
|
||||
|
||||
p.pointer.SetButtonHandler(func(e client.PointerButtonEvent) {
|
||||
@@ -686,13 +679,17 @@ func (p *Picker) cleanup() {
|
||||
if ls.scopyBuffer != nil {
|
||||
ls.scopyBuffer.Destroy()
|
||||
}
|
||||
for i := range ls.wlBuffers {
|
||||
if ls.wlBuffers[i] != nil {
|
||||
ls.wlBuffers[i].Destroy()
|
||||
if ls.oldBuffer != nil {
|
||||
ls.oldBuffer.Destroy()
|
||||
}
|
||||
if ls.wlPools[i] != nil {
|
||||
ls.wlPools[i].Destroy()
|
||||
if ls.oldPool != nil {
|
||||
ls.oldPool.Destroy()
|
||||
}
|
||||
if ls.wlBuffer != nil {
|
||||
ls.wlBuffer.Destroy()
|
||||
}
|
||||
if ls.wlPool != nil {
|
||||
ls.wlPool.Destroy()
|
||||
}
|
||||
if ls.viewport != nil {
|
||||
ls.viewport.Destroy()
|
||||
|
||||
@@ -274,12 +274,6 @@ func (s *SurfaceState) FrontRenderBuffer() *ShmBuffer {
|
||||
return s.renderBufs[s.front]
|
||||
}
|
||||
|
||||
func (s *SurfaceState) FrontIndex() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.front
|
||||
}
|
||||
|
||||
func (s *SurfaceState) SwapBuffers() {
|
||||
s.mu.Lock()
|
||||
s.front ^= 1
|
||||
|
||||
@@ -62,31 +62,12 @@ func (cd *ConfigDeployer) DeployConfigurationsSelectiveWithReinstalls(ctx contex
|
||||
func (cd *ConfigDeployer) deployConfigurationsInternal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal, installedDeps []deps.Dependency, replaceConfigs map[string]bool, reinstallItems map[string]bool, useSystemd bool) ([]DeploymentResult, error) {
|
||||
var results []DeploymentResult
|
||||
|
||||
// Primary config file paths used to detect fresh installs.
|
||||
configPrimaryPaths := map[string]string{
|
||||
"Niri": filepath.Join(os.Getenv("HOME"), ".config", "niri", "config.kdl"),
|
||||
"Hyprland": filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.conf"),
|
||||
"Ghostty": filepath.Join(os.Getenv("HOME"), ".config", "ghostty", "config"),
|
||||
"Kitty": filepath.Join(os.Getenv("HOME"), ".config", "kitty", "kitty.conf"),
|
||||
"Alacritty": filepath.Join(os.Getenv("HOME"), ".config", "alacritty", "alacritty.toml"),
|
||||
}
|
||||
|
||||
shouldReplaceConfig := func(configType string) bool {
|
||||
if replaceConfigs == nil {
|
||||
return true
|
||||
}
|
||||
replace, exists := replaceConfigs[configType]
|
||||
if !exists || replace {
|
||||
return true
|
||||
}
|
||||
// Config is explicitly set to "don't replace" — but still deploy
|
||||
// if the config file doesn't exist yet (fresh install scenario).
|
||||
if primaryPath, ok := configPrimaryPaths[configType]; ok {
|
||||
if _, err := os.Stat(primaryPath); os.IsNotExist(err) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return !exists || replace
|
||||
}
|
||||
|
||||
switch wm {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -625,168 +624,3 @@ func TestAlacrittyConfigDeployment(t *testing.T) {
|
||||
assert.Contains(t, string(newContent), "decorations = \"None\"")
|
||||
})
|
||||
}
|
||||
|
||||
func TestShouldReplaceConfigDeployIfMissing(t *testing.T) {
|
||||
allFalse := map[string]bool{
|
||||
"Niri": false,
|
||||
"Hyprland": false,
|
||||
"Ghostty": false,
|
||||
"Kitty": false,
|
||||
"Alacritty": false,
|
||||
}
|
||||
|
||||
t.Run("replaceConfigs nil deploys config", func(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "dankinstall-replace-nil-test")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
originalHome := os.Getenv("HOME")
|
||||
os.Setenv("HOME", tempDir)
|
||||
defer os.Setenv("HOME", originalHome)
|
||||
|
||||
logChan := make(chan string, 100)
|
||||
cd := NewConfigDeployer(logChan)
|
||||
|
||||
results, err := cd.DeployConfigurationsSelectiveWithReinstalls(
|
||||
context.Background(),
|
||||
deps.WindowManagerNiri,
|
||||
deps.TerminalGhostty,
|
||||
nil, // installedDeps
|
||||
nil, // replaceConfigs
|
||||
nil, // reinstallItems
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
// With replaceConfigs=nil, all configs should be deployed
|
||||
hasDeployed := false
|
||||
for _, r := range results {
|
||||
if r.Deployed {
|
||||
hasDeployed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, hasDeployed, "expected at least one config to be deployed when replaceConfigs is nil")
|
||||
})
|
||||
|
||||
t.Run("replaceConfigs all false and config missing deploys config", func(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "dankinstall-replace-missing-test")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
originalHome := os.Getenv("HOME")
|
||||
os.Setenv("HOME", tempDir)
|
||||
defer os.Setenv("HOME", originalHome)
|
||||
|
||||
logChan := make(chan string, 100)
|
||||
cd := NewConfigDeployer(logChan)
|
||||
|
||||
results, err := cd.DeployConfigurationsSelectiveWithReinstalls(
|
||||
context.Background(),
|
||||
deps.WindowManagerNiri,
|
||||
deps.TerminalGhostty,
|
||||
nil, // installedDeps
|
||||
allFalse, // replaceConfigs — all false
|
||||
nil, // reinstallItems
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Config files don't exist on disk, so they should still be deployed
|
||||
hasDeployed := false
|
||||
for _, r := range results {
|
||||
if r.Deployed {
|
||||
hasDeployed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, hasDeployed, "expected configs to be deployed when files are missing, even with replaceConfigs all false")
|
||||
})
|
||||
|
||||
t.Run("replaceConfigs false and config exists skips config", func(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "dankinstall-replace-exists-test")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
originalHome := os.Getenv("HOME")
|
||||
os.Setenv("HOME", tempDir)
|
||||
defer os.Setenv("HOME", originalHome)
|
||||
|
||||
// Create the Ghostty primary config file so shouldReplaceConfig returns false
|
||||
ghosttyPath := filepath.Join(tempDir, ".config", "ghostty", "config")
|
||||
err = os.MkdirAll(filepath.Dir(ghosttyPath), 0o755)
|
||||
require.NoError(t, err)
|
||||
err = os.WriteFile(ghosttyPath, []byte("# existing ghostty config\n"), 0o644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Also create the Niri primary config file
|
||||
niriPath := filepath.Join(tempDir, ".config", "niri", "config.kdl")
|
||||
err = os.MkdirAll(filepath.Dir(niriPath), 0o755)
|
||||
require.NoError(t, err)
|
||||
err = os.WriteFile(niriPath, []byte("// existing niri config\n"), 0o644)
|
||||
require.NoError(t, err)
|
||||
|
||||
logChan := make(chan string, 100)
|
||||
cd := NewConfigDeployer(logChan)
|
||||
|
||||
results, err := cd.DeployConfigurationsSelectiveWithReinstalls(
|
||||
context.Background(),
|
||||
deps.WindowManagerNiri,
|
||||
deps.TerminalGhostty,
|
||||
nil, // installedDeps
|
||||
allFalse, // replaceConfigs — all false
|
||||
nil, // reinstallItems
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Both Niri and Ghostty config files exist, so with all false they should be skipped
|
||||
for _, r := range results {
|
||||
assert.Fail(t, "expected no configs to be deployed", "got deployed config: %s", r.ConfigType)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("replaceConfigs true and config exists deploys config", func(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "dankinstall-replace-true-test")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
originalHome := os.Getenv("HOME")
|
||||
os.Setenv("HOME", tempDir)
|
||||
defer os.Setenv("HOME", originalHome)
|
||||
|
||||
// Create the Ghostty primary config file
|
||||
ghosttyPath := filepath.Join(tempDir, ".config", "ghostty", "config")
|
||||
err = os.MkdirAll(filepath.Dir(ghosttyPath), 0o755)
|
||||
require.NoError(t, err)
|
||||
err = os.WriteFile(ghosttyPath, []byte("# existing ghostty config\n"), 0o644)
|
||||
require.NoError(t, err)
|
||||
|
||||
logChan := make(chan string, 100)
|
||||
cd := NewConfigDeployer(logChan)
|
||||
|
||||
replaceConfigs := map[string]bool{
|
||||
"Niri": false,
|
||||
"Hyprland": false,
|
||||
"Ghostty": true, // explicitly true
|
||||
"Kitty": false,
|
||||
"Alacritty": false,
|
||||
}
|
||||
|
||||
results, err := cd.DeployConfigurationsSelectiveWithReinstalls(
|
||||
context.Background(),
|
||||
deps.WindowManagerNiri,
|
||||
deps.TerminalGhostty,
|
||||
nil, // installedDeps
|
||||
replaceConfigs, // Ghostty=true, rest=false
|
||||
nil, // reinstallItems
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Ghostty should be deployed because replaceConfigs["Ghostty"]=true
|
||||
foundGhostty := false
|
||||
for _, r := range results {
|
||||
if r.ConfigType == "Ghostty" && r.Deployed {
|
||||
foundGhostty = true
|
||||
}
|
||||
}
|
||||
assert.True(t, foundGhostty, "expected Ghostty config to be deployed when replaceConfigs is true")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -107,6 +107,10 @@ 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 ^(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 ^dms:.*
|
||||
|
||||
|
||||
@@ -250,6 +250,12 @@ window-rule {
|
||||
match app-id="zoom"
|
||||
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 {
|
||||
honor-xdg-activation-with-invalid-serial
|
||||
}
|
||||
|
||||
@@ -232,7 +232,7 @@ func (b *BaseDistribution) detectQuickshell() deps.Dependency {
|
||||
}
|
||||
|
||||
versionStr := string(output)
|
||||
versionRegex := regexp.MustCompile(`(?i)quickshell (\d+\.\d+\.\d+)`)
|
||||
versionRegex := regexp.MustCompile(`quickshell (\d+\.\d+\.\d+)`)
|
||||
matches := versionRegex.FindStringSubmatch(versionStr)
|
||||
|
||||
if len(matches) < 2 {
|
||||
|
||||
@@ -104,6 +104,15 @@ func debianPackageInstalledPrecisely(pkg string) bool {
|
||||
return strings.TrimSpace(string(output)) == "installed"
|
||||
}
|
||||
|
||||
func containsString(values []string, target string) bool {
|
||||
for _, value := range values {
|
||||
if value == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func debianRepoArchitecture(arch string) string {
|
||||
switch arch {
|
||||
case "amd64", "x86_64":
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
||||
@@ -427,7 +426,7 @@ func openSUSENiriRuntimePackages(wm deps.WindowManager, disabledFlags map[string
|
||||
|
||||
func (o *OpenSUSEDistribution) appendMissingSystemPackages(systemPkgs []string, extraPkgs []string) []string {
|
||||
for _, pkg := range extraPkgs {
|
||||
if slices.Contains(systemPkgs, pkg) || o.packageInstalled(pkg) {
|
||||
if containsString(systemPkgs, pkg) || o.packageInstalled(pkg) {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
package geolocation
|
||||
|
||||
import "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
|
||||
func NewClient() Client {
|
||||
geoclueClient, err := newGeoClueClient()
|
||||
if err != nil {
|
||||
log.Warnf("GeoClue2 unavailable: %v", err)
|
||||
return newSeededIpClient()
|
||||
}
|
||||
|
||||
loc, _ := geoclueClient.GetLocation()
|
||||
if loc.Latitude != 0 || loc.Longitude != 0 {
|
||||
log.Info("Using GeoClue2 location")
|
||||
return geoclueClient
|
||||
}
|
||||
|
||||
log.Info("GeoClue2 has no fix yet, seeding with IP location")
|
||||
ipLoc, err := fetchIPLocation()
|
||||
if err != nil {
|
||||
log.Warnf("IP location seed failed: %v", err)
|
||||
return geoclueClient
|
||||
}
|
||||
|
||||
log.Info("Seeded GeoClue2 with IP location")
|
||||
geoclueClient.SeedLocation(Location{Latitude: ipLoc.Latitude, Longitude: ipLoc.Longitude})
|
||||
return geoclueClient
|
||||
}
|
||||
|
||||
func newSeededIpClient() *IpClient {
|
||||
client := newIpClient()
|
||||
ipLoc, err := fetchIPLocation()
|
||||
if err != nil {
|
||||
log.Warnf("IP location also failed: %v", err)
|
||||
return client
|
||||
}
|
||||
|
||||
log.Info("Using IP location")
|
||||
client.currLocation.Latitude = ipLoc.Latitude
|
||||
client.currLocation.Longitude = ipLoc.Longitude
|
||||
return client
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
package geolocation
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/pkg/dbusutil"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
dbusGeoClueService = "org.freedesktop.GeoClue2"
|
||||
dbusGeoCluePath = "/org/freedesktop/GeoClue2"
|
||||
dbusGeoClueInterface = dbusGeoClueService
|
||||
|
||||
dbusGeoClueManagerPath = dbusGeoCluePath + "/Manager"
|
||||
dbusGeoClueManagerInterface = dbusGeoClueInterface + ".Manager"
|
||||
dbusGeoClueManagerGetClient = dbusGeoClueManagerInterface + ".GetClient"
|
||||
|
||||
dbusGeoClueClientInterface = dbusGeoClueInterface + ".Client"
|
||||
dbusGeoClueClientDesktopId = dbusGeoClueClientInterface + ".DesktopId"
|
||||
dbusGeoClueClientTimeThreshold = dbusGeoClueClientInterface + ".TimeThreshold"
|
||||
dbusGeoClueClientTimeStart = dbusGeoClueClientInterface + ".Start"
|
||||
dbusGeoClueClientTimeStop = dbusGeoClueClientInterface + ".Stop"
|
||||
dbusGeoClueClientLocationUpdated = dbusGeoClueClientInterface + ".LocationUpdated"
|
||||
|
||||
dbusGeoClueLocationInterface = dbusGeoClueInterface + ".Location"
|
||||
dbusGeoClueLocationLatitude = dbusGeoClueLocationInterface + ".Latitude"
|
||||
dbusGeoClueLocationLongitude = dbusGeoClueLocationInterface + ".Longitude"
|
||||
)
|
||||
|
||||
type GeoClueClient struct {
|
||||
currLocation *Location
|
||||
locationMutex sync.RWMutex
|
||||
|
||||
dbusConn *dbus.Conn
|
||||
clientPath dbus.ObjectPath
|
||||
signals chan *dbus.Signal
|
||||
|
||||
stopChan chan struct{}
|
||||
sigWG sync.WaitGroup
|
||||
|
||||
subscribers syncmap.Map[string, chan Location]
|
||||
}
|
||||
|
||||
func newGeoClueClient() (*GeoClueClient, error) {
|
||||
dbusConn, err := dbus.ConnectSystemBus()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("system bus connection failed: %w", err)
|
||||
}
|
||||
|
||||
c := &GeoClueClient{
|
||||
dbusConn: dbusConn,
|
||||
stopChan: make(chan struct{}),
|
||||
signals: make(chan *dbus.Signal, 256),
|
||||
|
||||
currLocation: &Location{
|
||||
Latitude: 0.0,
|
||||
Longitude: 0.0,
|
||||
},
|
||||
}
|
||||
|
||||
if err := c.setupClient(); err != nil {
|
||||
dbusConn.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := c.startSignalPump(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *GeoClueClient) Close() {
|
||||
close(c.stopChan)
|
||||
|
||||
c.sigWG.Wait()
|
||||
|
||||
if c.signals != nil {
|
||||
c.dbusConn.RemoveSignal(c.signals)
|
||||
close(c.signals)
|
||||
}
|
||||
|
||||
c.subscribers.Range(func(key string, ch chan Location) bool {
|
||||
close(ch)
|
||||
c.subscribers.Delete(key)
|
||||
return true
|
||||
})
|
||||
|
||||
if c.dbusConn != nil {
|
||||
c.dbusConn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *GeoClueClient) Subscribe(id string) chan Location {
|
||||
ch := make(chan Location, 64)
|
||||
c.subscribers.Store(id, ch)
|
||||
return ch
|
||||
}
|
||||
|
||||
func (c *GeoClueClient) Unsubscribe(id string) {
|
||||
if ch, ok := c.subscribers.LoadAndDelete(id); ok {
|
||||
close(ch)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *GeoClueClient) setupClient() error {
|
||||
managerObj := c.dbusConn.Object(dbusGeoClueService, dbusGeoClueManagerPath)
|
||||
|
||||
if err := managerObj.Call(dbusGeoClueManagerGetClient, 0).Store(&c.clientPath); err != nil {
|
||||
return fmt.Errorf("failed to create GeoClue2 client: %w", err)
|
||||
}
|
||||
|
||||
clientObj := c.dbusConn.Object(dbusGeoClueService, c.clientPath)
|
||||
if err := clientObj.SetProperty(dbusGeoClueClientDesktopId, "dms"); err != nil {
|
||||
return fmt.Errorf("failed to set desktop ID: %w", err)
|
||||
}
|
||||
|
||||
if err := clientObj.SetProperty(dbusGeoClueClientTimeThreshold, uint(10)); err != nil {
|
||||
return fmt.Errorf("failed to set time threshold: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *GeoClueClient) startSignalPump() error {
|
||||
c.dbusConn.Signal(c.signals)
|
||||
|
||||
if err := c.dbusConn.AddMatchSignal(
|
||||
dbus.WithMatchObjectPath(c.clientPath),
|
||||
dbus.WithMatchInterface(dbusGeoClueClientInterface),
|
||||
dbus.WithMatchSender(dbusGeoClueClientLocationUpdated),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.sigWG.Add(1)
|
||||
go func() {
|
||||
defer c.sigWG.Done()
|
||||
|
||||
clientObj := c.dbusConn.Object(dbusGeoClueService, c.clientPath)
|
||||
clientObj.Call(dbusGeoClueClientTimeStart, 0)
|
||||
defer clientObj.Call(dbusGeoClueClientTimeStop, 0)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-c.stopChan:
|
||||
return
|
||||
case sig, ok := <-c.signals:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if sig == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
c.handleSignal(sig)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *GeoClueClient) handleSignal(sig *dbus.Signal) {
|
||||
switch sig.Name {
|
||||
case dbusGeoClueClientLocationUpdated:
|
||||
if len(sig.Body) != 2 {
|
||||
return
|
||||
}
|
||||
|
||||
newLocationPath, ok := sig.Body[1].(dbus.ObjectPath)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if err := c.handleLocationUpdated(newLocationPath); err != nil {
|
||||
log.Warn("GeoClue: Failed to handle location update: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *GeoClueClient) handleLocationUpdated(path dbus.ObjectPath) error {
|
||||
locationObj := c.dbusConn.Object(dbusGeoClueService, path)
|
||||
|
||||
lat, err := locationObj.GetProperty(dbusGeoClueLocationLatitude)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
long, err := locationObj.GetProperty(dbusGeoClueLocationLongitude)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.locationMutex.Lock()
|
||||
c.currLocation.Latitude = dbusutil.AsOr(lat, 0.0)
|
||||
c.currLocation.Longitude = dbusutil.AsOr(long, 0.0)
|
||||
c.locationMutex.Unlock()
|
||||
|
||||
c.notifySubscribers()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *GeoClueClient) notifySubscribers() {
|
||||
currentLocation, err := c.GetLocation()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
c.subscribers.Range(func(key string, ch chan Location) bool {
|
||||
select {
|
||||
case ch <- currentLocation:
|
||||
default:
|
||||
log.Warn("GeoClue: subscriber channel full, dropping update")
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
func (c *GeoClueClient) SeedLocation(loc Location) {
|
||||
c.locationMutex.Lock()
|
||||
defer c.locationMutex.Unlock()
|
||||
c.currLocation.Latitude = loc.Latitude
|
||||
c.currLocation.Longitude = loc.Longitude
|
||||
}
|
||||
|
||||
func (c *GeoClueClient) GetLocation() (Location, error) {
|
||||
c.locationMutex.RLock()
|
||||
defer c.locationMutex.RUnlock()
|
||||
if c.currLocation == nil {
|
||||
return Location{
|
||||
Latitude: 0.0,
|
||||
Longitude: 0.0,
|
||||
}, nil
|
||||
}
|
||||
stateCopy := *c.currLocation
|
||||
return stateCopy, nil
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
package geolocation
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type IpClient struct {
|
||||
currLocation *Location
|
||||
}
|
||||
|
||||
type ipLocationResult struct {
|
||||
Location
|
||||
City string
|
||||
}
|
||||
|
||||
type ipAPIResponse struct {
|
||||
Status string `json:"status"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
City string `json:"city"`
|
||||
}
|
||||
|
||||
func newIpClient() *IpClient {
|
||||
return &IpClient{
|
||||
currLocation: &Location{},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *IpClient) Subscribe(id string) chan Location {
|
||||
ch := make(chan Location, 1)
|
||||
if location, err := c.GetLocation(); err == nil {
|
||||
ch <- location
|
||||
}
|
||||
return ch
|
||||
}
|
||||
|
||||
func (c *IpClient) Unsubscribe(id string) {}
|
||||
|
||||
func (c *IpClient) Close() {}
|
||||
|
||||
func (c *IpClient) GetLocation() (Location, error) {
|
||||
if c.currLocation.Latitude != 0 || c.currLocation.Longitude != 0 {
|
||||
return *c.currLocation, nil
|
||||
}
|
||||
|
||||
result, err := fetchIPLocation()
|
||||
if err != nil {
|
||||
return Location{}, err
|
||||
}
|
||||
|
||||
c.currLocation.Latitude = result.Latitude
|
||||
c.currLocation.Longitude = result.Longitude
|
||||
return *c.currLocation, nil
|
||||
}
|
||||
|
||||
func fetchIPLocation() (ipLocationResult, error) {
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
resp, err := client.Get("http://ip-api.com/json/")
|
||||
if err != nil {
|
||||
return ipLocationResult{}, fmt.Errorf("failed to fetch IP location: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return ipLocationResult{}, fmt.Errorf("ip-api.com returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return ipLocationResult{}, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
var data ipAPIResponse
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return ipLocationResult{}, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if data.Status == "fail" || (data.Lat == 0 && data.Lon == 0) {
|
||||
return ipLocationResult{}, fmt.Errorf("ip-api.com returned no location data")
|
||||
}
|
||||
|
||||
return ipLocationResult{
|
||||
Location: Location{Latitude: data.Lat, Longitude: data.Lon},
|
||||
City: data.City,
|
||||
}, nil
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package geolocation
|
||||
|
||||
type Location struct {
|
||||
Latitude float64
|
||||
Longitude float64
|
||||
}
|
||||
|
||||
type Client interface {
|
||||
GetLocation() (Location, error)
|
||||
|
||||
Subscribe(id string) chan Location
|
||||
Unsubscribe(id string)
|
||||
|
||||
Close()
|
||||
}
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/matugen"
|
||||
sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
"github.com/sblinch/kdl-go"
|
||||
@@ -27,7 +26,26 @@ var appArmorProfileData []byte
|
||||
|
||||
const appArmorProfileDest = "/etc/apparmor.d/usr.bin.dms-greeter"
|
||||
|
||||
const GreeterCacheDir = "/var/cache/dms-greeter"
|
||||
const (
|
||||
GreeterCacheDir = "/var/cache/dms-greeter"
|
||||
|
||||
GreeterPamManagedBlockStart = "# BEGIN DMS GREETER AUTH (managed by dms greeter sync)"
|
||||
GreeterPamManagedBlockEnd = "# END DMS GREETER AUTH"
|
||||
|
||||
legacyGreeterPamFprintComment = "# DMS greeter fingerprint"
|
||||
legacyGreeterPamU2FComment = "# DMS greeter U2F"
|
||||
)
|
||||
|
||||
// Common PAM auth stack names referenced by greetd across supported distros.
|
||||
var includedPamAuthFiles = []string{
|
||||
"system-auth",
|
||||
"common-auth",
|
||||
"password-auth",
|
||||
"system-login",
|
||||
"system-local-login",
|
||||
"common-auth-pc",
|
||||
"login",
|
||||
}
|
||||
|
||||
func DetectDMSPath() (string, error) {
|
||||
return config.LocateDMSConfig()
|
||||
@@ -574,6 +592,7 @@ func EnsureGreeterCacheDir(logFunc func(string), sudoPassword string) error {
|
||||
runtimeDirs := []string{
|
||||
filepath.Join(cacheDir, ".local"),
|
||||
filepath.Join(cacheDir, ".local", "state"),
|
||||
filepath.Join(cacheDir, ".local", "state", "wireplumber"),
|
||||
filepath.Join(cacheDir, ".local", "share"),
|
||||
filepath.Join(cacheDir, ".cache"),
|
||||
}
|
||||
@@ -693,6 +712,49 @@ func InstallAppArmorProfile(logFunc func(string), sudoPassword string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveGreeterPamManagedBlock strips the DMS managed auth block from /etc/pam.d/greetd
|
||||
func RemoveGreeterPamManagedBlock(logFunc func(string), sudoPassword string) error {
|
||||
if IsNixOS() {
|
||||
return nil
|
||||
}
|
||||
const greetdPamPath = "/etc/pam.d/greetd"
|
||||
data, err := os.ReadFile(greetdPamPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to read %s: %w", greetdPamPath, err)
|
||||
}
|
||||
|
||||
stripped, removed := stripManagedGreeterPamBlock(string(data))
|
||||
strippedAgain, removedLegacy := stripLegacyGreeterPamLines(stripped)
|
||||
if !removed && !removedLegacy {
|
||||
return nil
|
||||
}
|
||||
|
||||
tmp, err := os.CreateTemp("", "dms-pam-greetd-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp PAM file: %w", err)
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
if _, err := tmp.WriteString(strippedAgain); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("failed to write temp PAM file: %w", err)
|
||||
}
|
||||
tmp.Close()
|
||||
|
||||
if err := privesc.Run(context.Background(), sudoPassword, "cp", tmpPath, greetdPamPath); err != nil {
|
||||
return fmt.Errorf("failed to write PAM config: %w", err)
|
||||
}
|
||||
if err := privesc.Run(context.Background(), sudoPassword, "chmod", "644", greetdPamPath); err != nil {
|
||||
return fmt.Errorf("failed to set PAM config permissions: %w", err)
|
||||
}
|
||||
logFunc(" ✓ Removed DMS managed PAM block from " + greetdPamPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UninstallAppArmorProfile removes the DMS AppArmor profile and reloads AppArmor.
|
||||
// It is a no-op when AppArmor is not active or the profile does not exist.
|
||||
func UninstallAppArmorProfile(logFunc func(string), sudoPassword string) error {
|
||||
@@ -1190,7 +1252,7 @@ func syncGreeterColorSource(homeDir, cacheDir string, state greeterThemeSyncStat
|
||||
return nil
|
||||
}
|
||||
|
||||
func SyncDMSConfigs(dmsPath, compositor string, logFunc func(string), sudoPassword string) error {
|
||||
func SyncDMSConfigs(dmsPath, compositor string, logFunc func(string), sudoPassword string, forceAuth bool) error {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get user home directory: %w", err)
|
||||
@@ -1255,6 +1317,10 @@ func SyncDMSConfigs(dmsPath, compositor string, logFunc func(string), sudoPasswo
|
||||
return fmt.Errorf("greeter wallpaper override sync failed: %w", err)
|
||||
}
|
||||
|
||||
if err := syncGreeterPamConfig(homeDir, logFunc, sudoPassword, forceAuth); err != nil {
|
||||
return fmt.Errorf("greeter PAM config sync failed: %w", err)
|
||||
}
|
||||
|
||||
if strings.ToLower(compositor) != "niri" {
|
||||
return nil
|
||||
}
|
||||
@@ -1303,6 +1369,378 @@ func syncGreeterWallpaperOverride(cacheDir string, logFunc func(string), sudoPas
|
||||
return nil
|
||||
}
|
||||
|
||||
func pamModuleExists(module string) bool {
|
||||
for _, libDir := range []string{
|
||||
"/usr/lib64/security",
|
||||
"/usr/lib/security",
|
||||
"/lib64/security",
|
||||
"/lib/security",
|
||||
"/lib/x86_64-linux-gnu/security",
|
||||
"/usr/lib/x86_64-linux-gnu/security",
|
||||
"/lib/aarch64-linux-gnu/security",
|
||||
"/usr/lib/aarch64-linux-gnu/security",
|
||||
"/run/current-system/sw/lib64/security",
|
||||
"/run/current-system/sw/lib/security",
|
||||
} {
|
||||
if _, err := os.Stat(filepath.Join(libDir, module)); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func stripManagedGreeterPamBlock(content string) (string, bool) {
|
||||
lines := strings.Split(content, "\n")
|
||||
filtered := make([]string, 0, len(lines))
|
||||
inManagedBlock := false
|
||||
removed := false
|
||||
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == GreeterPamManagedBlockStart {
|
||||
inManagedBlock = true
|
||||
removed = true
|
||||
continue
|
||||
}
|
||||
if trimmed == GreeterPamManagedBlockEnd {
|
||||
inManagedBlock = false
|
||||
removed = true
|
||||
continue
|
||||
}
|
||||
if inManagedBlock {
|
||||
removed = true
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, line)
|
||||
}
|
||||
|
||||
return strings.Join(filtered, "\n"), removed
|
||||
}
|
||||
|
||||
func stripLegacyGreeterPamLines(content string) (string, bool) {
|
||||
lines := strings.Split(content, "\n")
|
||||
filtered := make([]string, 0, len(lines))
|
||||
removed := false
|
||||
|
||||
for i := 0; i < len(lines); i++ {
|
||||
trimmed := strings.TrimSpace(lines[i])
|
||||
if strings.HasPrefix(trimmed, legacyGreeterPamFprintComment) || strings.HasPrefix(trimmed, legacyGreeterPamU2FComment) {
|
||||
removed = true
|
||||
if i+1 < len(lines) {
|
||||
nextLine := strings.TrimSpace(lines[i+1])
|
||||
if strings.HasPrefix(nextLine, "auth") &&
|
||||
(strings.Contains(nextLine, "pam_fprintd") || strings.Contains(nextLine, "pam_u2f")) {
|
||||
i++
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, lines[i])
|
||||
}
|
||||
|
||||
return strings.Join(filtered, "\n"), removed
|
||||
}
|
||||
|
||||
func insertManagedGreeterPamBlock(content string, blockLines []string, greetdPamPath string) (string, error) {
|
||||
lines := strings.Split(content, "\n")
|
||||
for i, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed != "" && !strings.HasPrefix(trimmed, "#") && strings.HasPrefix(trimmed, "auth") {
|
||||
block := strings.Join(blockLines, "\n")
|
||||
prefix := strings.Join(lines[:i], "\n")
|
||||
suffix := strings.Join(lines[i:], "\n")
|
||||
switch {
|
||||
case prefix == "":
|
||||
return block + "\n" + suffix, nil
|
||||
case suffix == "":
|
||||
return prefix + "\n" + block, nil
|
||||
default:
|
||||
return prefix + "\n" + block + "\n" + suffix, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no auth directive found in %s", greetdPamPath)
|
||||
}
|
||||
|
||||
func PamTextIncludesFile(pamText, filename string) bool {
|
||||
lines := strings.Split(pamText, "\n")
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(trimmed, filename) &&
|
||||
(strings.Contains(trimmed, "include") || strings.Contains(trimmed, "substack") || strings.HasPrefix(trimmed, "@include")) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func PamFileHasModule(pamFilePath, module string) bool {
|
||||
data, err := os.ReadFile(pamFilePath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(trimmed, module) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func DetectIncludedPamModule(pamText, module string) string {
|
||||
for _, includedFile := range includedPamAuthFiles {
|
||||
if PamTextIncludesFile(pamText, includedFile) && PamFileHasModule("/etc/pam.d/"+includedFile, module) {
|
||||
return includedFile
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type greeterAuthSettings struct {
|
||||
GreeterEnableFprint bool `json:"greeterEnableFprint"`
|
||||
GreeterEnableU2f bool `json:"greeterEnableU2f"`
|
||||
}
|
||||
|
||||
func readGreeterAuthSettings(homeDir string) (greeterAuthSettings, error) {
|
||||
settingsPath := filepath.Join(homeDir, ".config", "DankMaterialShell", "settings.json")
|
||||
data, err := os.ReadFile(settingsPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return greeterAuthSettings{}, nil
|
||||
}
|
||||
return greeterAuthSettings{}, fmt.Errorf("failed to read settings at %s: %w", settingsPath, err)
|
||||
}
|
||||
if strings.TrimSpace(string(data)) == "" {
|
||||
return greeterAuthSettings{}, nil
|
||||
}
|
||||
var settings greeterAuthSettings
|
||||
if err := json.Unmarshal(data, &settings); err != nil {
|
||||
return greeterAuthSettings{}, fmt.Errorf("failed to parse settings at %s: %w", settingsPath, err)
|
||||
}
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
func ReadGreeterAuthToggles(homeDir string) (enableFprint bool, enableU2f bool, err error) {
|
||||
settings, err := readGreeterAuthSettings(homeDir)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
return settings.GreeterEnableFprint, settings.GreeterEnableU2f, nil
|
||||
}
|
||||
|
||||
func hasEnrolledFingerprintOutput(output string) bool {
|
||||
lower := strings.ToLower(output)
|
||||
if strings.Contains(lower, "no fingers enrolled") ||
|
||||
strings.Contains(lower, "no fingerprints enrolled") ||
|
||||
strings.Contains(lower, "no prints enrolled") {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(lower, "has fingers enrolled") ||
|
||||
strings.Contains(lower, "has fingerprints enrolled") {
|
||||
return true
|
||||
}
|
||||
for _, line := range strings.Split(lower, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "finger:") {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "- ") && strings.Contains(trimmed, "finger") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func FingerprintAuthAvailableForUser(username string) bool {
|
||||
username = strings.TrimSpace(username)
|
||||
if username == "" {
|
||||
return false
|
||||
}
|
||||
if !pamModuleExists("pam_fprintd.so") {
|
||||
return false
|
||||
}
|
||||
if _, err := exec.LookPath("fprintd-list"); err != nil {
|
||||
return false
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
out, err := exec.CommandContext(ctx, "fprintd-list", username).CombinedOutput()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return hasEnrolledFingerprintOutput(string(out))
|
||||
}
|
||||
|
||||
func FingerprintAuthAvailableForCurrentUser() bool {
|
||||
username := strings.TrimSpace(os.Getenv("SUDO_USER"))
|
||||
if username == "" {
|
||||
username = strings.TrimSpace(os.Getenv("USER"))
|
||||
}
|
||||
if username == "" {
|
||||
out, err := exec.Command("id", "-un").Output()
|
||||
if err == nil {
|
||||
username = strings.TrimSpace(string(out))
|
||||
}
|
||||
}
|
||||
return FingerprintAuthAvailableForUser(username)
|
||||
}
|
||||
|
||||
func pamManagerHintForCurrentDistro() string {
|
||||
osInfo, err := distros.GetOSInfo()
|
||||
if err != nil {
|
||||
return "Disable it in your PAM manager (authselect/pam-auth-update) or in the included PAM stack to force password-only greeter login."
|
||||
}
|
||||
config, exists := distros.Registry[osInfo.Distribution.ID]
|
||||
if !exists {
|
||||
return "Disable it in your PAM manager (authselect/pam-auth-update) or in the included PAM stack to force password-only greeter login."
|
||||
}
|
||||
|
||||
switch config.Family {
|
||||
case distros.FamilyFedora:
|
||||
return "Disable it in authselect to force password-only greeter login."
|
||||
case distros.FamilyDebian, distros.FamilyUbuntu:
|
||||
return "Disable it in pam-auth-update to force password-only greeter login."
|
||||
default:
|
||||
return "Disable it in your distro PAM manager (authselect/pam-auth-update) or in the included PAM stack to force password-only greeter login."
|
||||
}
|
||||
}
|
||||
|
||||
func syncGreeterPamConfig(homeDir string, logFunc func(string), sudoPassword string, forceAuth bool) error {
|
||||
var wantFprint, wantU2f bool
|
||||
fprintToggleEnabled := forceAuth
|
||||
u2fToggleEnabled := forceAuth
|
||||
if forceAuth {
|
||||
wantFprint = pamModuleExists("pam_fprintd.so")
|
||||
wantU2f = pamModuleExists("pam_u2f.so")
|
||||
} else {
|
||||
settings, err := readGreeterAuthSettings(homeDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fprintToggleEnabled = settings.GreeterEnableFprint
|
||||
u2fToggleEnabled = settings.GreeterEnableU2f
|
||||
fprintModule := pamModuleExists("pam_fprintd.so")
|
||||
u2fModule := pamModuleExists("pam_u2f.so")
|
||||
wantFprint = settings.GreeterEnableFprint && fprintModule
|
||||
wantU2f = settings.GreeterEnableU2f && u2fModule
|
||||
if settings.GreeterEnableFprint && !fprintModule {
|
||||
logFunc("⚠ Warning: greeter fingerprint toggle is enabled, but pam_fprintd.so was not found.")
|
||||
}
|
||||
if settings.GreeterEnableU2f && !u2fModule {
|
||||
logFunc("⚠ Warning: greeter security key toggle is enabled, but pam_u2f.so was not found.")
|
||||
}
|
||||
}
|
||||
|
||||
if IsNixOS() {
|
||||
logFunc("ℹ NixOS detected: PAM config is managed by NixOS modules. Skipping DMS PAM block write.")
|
||||
logFunc(" Configure fingerprint/U2F auth via your greetd NixOS module options (e.g. security.pam.services.greetd).")
|
||||
return nil
|
||||
}
|
||||
|
||||
greetdPamPath := "/etc/pam.d/greetd"
|
||||
pamData, err := os.ReadFile(greetdPamPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read %s: %w", greetdPamPath, err)
|
||||
}
|
||||
originalContent := string(pamData)
|
||||
content, _ := stripManagedGreeterPamBlock(originalContent)
|
||||
content, _ = stripLegacyGreeterPamLines(content)
|
||||
|
||||
includedFprintFile := DetectIncludedPamModule(content, "pam_fprintd.so")
|
||||
includedU2fFile := DetectIncludedPamModule(content, "pam_u2f.so")
|
||||
fprintAvailableForCurrentUser := FingerprintAuthAvailableForCurrentUser()
|
||||
if wantFprint && includedFprintFile != "" {
|
||||
logFunc("⚠ pam_fprintd already present in included " + includedFprintFile + " (managed by authselect/pam-auth-update). Skipping DMS fprint block to avoid double-fingerprint auth.")
|
||||
wantFprint = false
|
||||
}
|
||||
if wantU2f && includedU2fFile != "" {
|
||||
logFunc("⚠ pam_u2f already present in included " + includedU2fFile + " (managed by authselect/pam-auth-update). Skipping DMS U2F block to avoid double security-key auth.")
|
||||
wantU2f = false
|
||||
}
|
||||
if !wantFprint && includedFprintFile != "" {
|
||||
if fprintToggleEnabled {
|
||||
logFunc("ℹ Fingerprint auth is still enabled via included " + includedFprintFile + ".")
|
||||
if fprintAvailableForCurrentUser {
|
||||
logFunc(" DMS toggle is enabled, and effective auth is provided by the included PAM stack.")
|
||||
} else {
|
||||
logFunc(" No enrolled fingerprints detected for the current user; password auth remains the effective path.")
|
||||
}
|
||||
} else {
|
||||
if fprintAvailableForCurrentUser {
|
||||
logFunc("ℹ Fingerprint auth is active via included " + includedFprintFile + " while DMS fingerprint toggle is off.")
|
||||
logFunc(" Password login will work but may be delayed while the fingerprint module runs first.")
|
||||
logFunc(" To eliminate the delay, " + pamManagerHintForCurrentDistro())
|
||||
} else {
|
||||
logFunc("ℹ pam_fprintd is present via included " + includedFprintFile + ", but no enrolled fingerprints were detected for the current user.")
|
||||
logFunc(" Password auth remains the effective login path.")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !wantU2f && includedU2fFile != "" {
|
||||
if u2fToggleEnabled {
|
||||
logFunc("ℹ Security-key auth is still enabled via included " + includedU2fFile + ".")
|
||||
logFunc(" DMS toggle is enabled, but effective auth is provided by the included PAM stack.")
|
||||
} else {
|
||||
logFunc("⚠ Security-key auth is active via included " + includedU2fFile + " while DMS security-key toggle is off.")
|
||||
logFunc(" " + pamManagerHintForCurrentDistro())
|
||||
}
|
||||
}
|
||||
|
||||
if wantFprint || wantU2f {
|
||||
blockLines := []string{GreeterPamManagedBlockStart}
|
||||
if wantFprint {
|
||||
blockLines = append(blockLines, "auth sufficient pam_fprintd.so max-tries=1 timeout=5")
|
||||
}
|
||||
if wantU2f {
|
||||
blockLines = append(blockLines, "auth sufficient pam_u2f.so cue nouserok timeout=10")
|
||||
}
|
||||
blockLines = append(blockLines, GreeterPamManagedBlockEnd)
|
||||
|
||||
content, err = insertManagedGreeterPamBlock(content, blockLines, greetdPamPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if content == originalContent {
|
||||
return nil
|
||||
}
|
||||
|
||||
tmpFile, err := os.CreateTemp("", "greetd-pam-*.conf")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpPath := tmpFile.Name()
|
||||
defer os.Remove(tmpPath)
|
||||
if _, err := tmpFile.WriteString(content); err != nil {
|
||||
tmpFile.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := privesc.Run(context.Background(), sudoPassword, "cp", tmpPath, greetdPamPath); err != nil {
|
||||
return fmt.Errorf("failed to install updated PAM config at %s: %w", greetdPamPath, err)
|
||||
}
|
||||
if err := privesc.Run(context.Background(), sudoPassword, "chmod", "644", greetdPamPath); err != nil {
|
||||
return fmt.Errorf("failed to set permissions on %s: %w", greetdPamPath, err)
|
||||
}
|
||||
if wantFprint || wantU2f {
|
||||
logFunc("✓ Configured greetd PAM for fingerprint/U2F")
|
||||
} else {
|
||||
logFunc("✓ Cleared DMS-managed greeter PAM auth block")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type niriGreeterSync struct {
|
||||
processed map[string]bool
|
||||
nodes []*document.Node
|
||||
@@ -1710,6 +2148,8 @@ vt = 1
|
||||
|
||||
wrapperCmd := resolveGreeterWrapperPath()
|
||||
|
||||
// Build command based on compositor and dms path
|
||||
// When dmsPath is empty (packaged greeter), omit -p; wrapper finds /usr/share/quickshell/dms-greeter
|
||||
compositorLower := strings.ToLower(compositor)
|
||||
commandValue := fmt.Sprintf("%s --command %s --cache-dir %s", wrapperCmd, compositorLower, GreeterCacheDir)
|
||||
if dmsPath != "" {
|
||||
@@ -1955,15 +2395,10 @@ func AutoSetupGreeter(compositor, sudoPassword string, logFunc func(string)) err
|
||||
}
|
||||
|
||||
logFunc("Synchronizing DMS configurations...")
|
||||
if err := SyncDMSConfigs(dmsPath, compositor, logFunc, sudoPassword); err != nil {
|
||||
if err := SyncDMSConfigs(dmsPath, compositor, logFunc, sudoPassword, false); err != nil {
|
||||
logFunc(fmt.Sprintf("⚠ Warning: config sync error: %v", err))
|
||||
}
|
||||
|
||||
logFunc("Configuring authentication...")
|
||||
if err := sharedpam.SyncAuthConfig(logFunc, sudoPassword, sharedpam.SyncAuthOptions{}); err != nil {
|
||||
return fmt.Errorf("failed to sync authentication: %w", err)
|
||||
}
|
||||
|
||||
logFunc("Checking for conflicting display managers...")
|
||||
if err := DisableConflictingDisplayManagers(sudoPassword, logFunc); err != nil {
|
||||
logFunc(fmt.Sprintf("⚠ Warning: %v", err))
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeTestFile(t *testing.T, path string, content string) {
|
||||
func writeTestJSON(t *testing.T, path string, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("failed to create parent dir for %s: %v", path, err)
|
||||
@@ -70,8 +70,8 @@ func TestResolveGreeterThemeSyncState(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
homeDir := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(homeDir, ".config", "DankMaterialShell", "settings.json"), tt.settingsJSON)
|
||||
writeTestFile(t, filepath.Join(homeDir, ".local", "state", "DankMaterialShell", "session.json"), tt.sessionJSON)
|
||||
writeTestJSON(t, filepath.Join(homeDir, ".config", "DankMaterialShell", "settings.json"), tt.settingsJSON)
|
||||
writeTestJSON(t, filepath.Join(homeDir, ".local", "state", "DankMaterialShell", "session.json"), tt.sessionJSON)
|
||||
|
||||
state, err := resolveGreeterThemeSyncState(homeDir)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,439 +0,0 @@
|
||||
package headless
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/greeter"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||
)
|
||||
|
||||
// ErrConfirmationRequired is returned when --yes is not set and the user
|
||||
// must explicitly confirm the operation.
|
||||
var ErrConfirmationRequired = fmt.Errorf("confirmation required: pass --yes to proceed")
|
||||
|
||||
// validConfigNames maps lowercase CLI input to the deployer key used in
|
||||
// replaceConfigs. Keep in sync with the config types checked by
|
||||
// shouldReplaceConfig in deployer.go.
|
||||
var validConfigNames = map[string]string{
|
||||
"niri": "Niri",
|
||||
"hyprland": "Hyprland",
|
||||
"ghostty": "Ghostty",
|
||||
"kitty": "Kitty",
|
||||
"alacritty": "Alacritty",
|
||||
}
|
||||
|
||||
// orderedConfigNames defines the canonical order for config names in output.
|
||||
// Must be kept in sync with validConfigNames.
|
||||
var orderedConfigNames = []string{"niri", "hyprland", "ghostty", "kitty", "alacritty"}
|
||||
|
||||
// Config holds all CLI parameters for unattended installation.
|
||||
type Config struct {
|
||||
Compositor string // "niri" or "hyprland"
|
||||
Terminal string // "ghostty", "kitty", or "alacritty"
|
||||
IncludeDeps []string
|
||||
ExcludeDeps []string
|
||||
ReplaceConfigs []string // specific configs to deploy (e.g. "niri", "ghostty")
|
||||
ReplaceConfigsAll bool // deploy/replace all configurations
|
||||
Yes bool
|
||||
}
|
||||
|
||||
// Runner orchestrates unattended (headless) installation.
|
||||
type Runner struct {
|
||||
cfg Config
|
||||
logChan chan string
|
||||
}
|
||||
|
||||
// NewRunner creates a new headless runner.
|
||||
func NewRunner(cfg Config) *Runner {
|
||||
return &Runner{
|
||||
cfg: cfg,
|
||||
logChan: make(chan string, 1000),
|
||||
}
|
||||
}
|
||||
|
||||
// GetLogChan returns the log channel for file logging.
|
||||
func (r *Runner) GetLogChan() <-chan string {
|
||||
return r.logChan
|
||||
}
|
||||
|
||||
// Run executes the full unattended installation flow.
|
||||
func (r *Runner) Run() error {
|
||||
r.log("Starting headless installation")
|
||||
|
||||
// 1. Parse compositor and terminal selections
|
||||
wm, err := r.parseWindowManager()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
terminal, err := r.parseTerminal()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 2. Build replace-configs map
|
||||
replaceConfigs, err := r.buildReplaceConfigs()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 3. Detect OS
|
||||
r.log("Detecting operating system...")
|
||||
osInfo, err := distros.GetOSInfo()
|
||||
if err != nil {
|
||||
return fmt.Errorf("OS detection failed: %w", err)
|
||||
}
|
||||
|
||||
if distros.IsUnsupportedDistro(osInfo.Distribution.ID, osInfo.VersionID) {
|
||||
return fmt.Errorf("unsupported distribution: %s %s", osInfo.PrettyName, osInfo.VersionID)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stdout, "Detected: %s (%s)\n", osInfo.PrettyName, osInfo.Architecture)
|
||||
|
||||
// 4. Create distribution instance
|
||||
distro, err := distros.NewDistribution(osInfo.Distribution.ID, r.logChan)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize distribution: %w", err)
|
||||
}
|
||||
|
||||
// 5. Detect dependencies
|
||||
r.log("Detecting dependencies...")
|
||||
fmt.Fprintln(os.Stdout, "Detecting dependencies...")
|
||||
dependencies, err := distro.DetectDependenciesWithTerminal(context.Background(), wm, terminal)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dependency detection failed: %w", err)
|
||||
}
|
||||
|
||||
// 5. Apply include/exclude filters and build the disabled-items map.
|
||||
// Headless mode does not currently collect any explicit reinstall selections,
|
||||
// so keep reinstallItems nil instead of constructing an always-empty map.
|
||||
disabledItems, err := r.buildDisabledItems(dependencies)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var reinstallItems map[string]bool
|
||||
|
||||
// Print dependency summary
|
||||
fmt.Fprintln(os.Stdout, "\nDependencies:")
|
||||
for _, dep := range dependencies {
|
||||
marker := " "
|
||||
status := ""
|
||||
if disabledItems[dep.Name] {
|
||||
marker = " SKIP "
|
||||
status = "(disabled)"
|
||||
} else {
|
||||
switch dep.Status {
|
||||
case deps.StatusInstalled:
|
||||
marker = " OK "
|
||||
status = "(installed)"
|
||||
case deps.StatusMissing:
|
||||
marker = " NEW "
|
||||
status = "(will install)"
|
||||
case deps.StatusNeedsUpdate:
|
||||
marker = " UPD "
|
||||
status = "(will update)"
|
||||
case deps.StatusNeedsReinstall:
|
||||
marker = " RE "
|
||||
status = "(will reinstall)"
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "%s%-30s %s\n", marker, dep.Name, status)
|
||||
}
|
||||
fmt.Fprintln(os.Stdout)
|
||||
|
||||
// 6b. Require explicit confirmation unless --yes is set
|
||||
if !r.cfg.Yes {
|
||||
if replaceConfigs == nil {
|
||||
// --replace-configs-all
|
||||
fmt.Fprintln(os.Stdout, "Packages will be installed and all configurations will be replaced.")
|
||||
fmt.Fprintln(os.Stdout, "Existing config files will be backed up before replacement.")
|
||||
} else if r.anyConfigEnabled(replaceConfigs) {
|
||||
var names []string
|
||||
for _, cliName := range orderedConfigNames {
|
||||
deployerKey := validConfigNames[cliName]
|
||||
if replaceConfigs[deployerKey] {
|
||||
names = append(names, deployerKey)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "Packages will be installed. The following configurations will be replaced (with backups): %s\n", strings.Join(names, ", "))
|
||||
} else {
|
||||
fmt.Fprintln(os.Stdout, "Packages will be installed. No configurations will be deployed.")
|
||||
}
|
||||
fmt.Fprintln(os.Stdout, "Re-run with --yes (-y) to proceed.")
|
||||
r.log("Aborted: --yes not set")
|
||||
return ErrConfirmationRequired
|
||||
}
|
||||
|
||||
// 7. Authenticate sudo
|
||||
sudoPassword, err := r.resolveSudoPassword()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 8. Install packages
|
||||
fmt.Fprintln(os.Stdout, "Installing packages...")
|
||||
r.log("Starting package installation")
|
||||
|
||||
progressChan := make(chan distros.InstallProgressMsg, 100)
|
||||
|
||||
installErr := make(chan error, 1)
|
||||
go func() {
|
||||
defer close(progressChan)
|
||||
installErr <- distro.InstallPackages(
|
||||
context.Background(),
|
||||
dependencies,
|
||||
wm,
|
||||
sudoPassword,
|
||||
reinstallItems,
|
||||
disabledItems,
|
||||
false, // skipGlobalUseFlags
|
||||
progressChan,
|
||||
)
|
||||
}()
|
||||
|
||||
// Consume progress messages and print them
|
||||
for msg := range progressChan {
|
||||
if msg.Error != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", msg.Error)
|
||||
} else if msg.Step != "" {
|
||||
fmt.Fprintf(os.Stdout, " [%3.0f%%] %s\n", msg.Progress*100, msg.Step)
|
||||
}
|
||||
if msg.LogOutput != "" {
|
||||
r.log(msg.LogOutput)
|
||||
fmt.Fprintf(os.Stdout, " %s\n", msg.LogOutput)
|
||||
}
|
||||
}
|
||||
|
||||
if err := <-installErr; err != nil {
|
||||
return fmt.Errorf("package installation failed: %w", err)
|
||||
}
|
||||
|
||||
// 9. Greeter setup (if dms-greeter was included)
|
||||
if !disabledItems["dms-greeter"] && r.depExists(dependencies, "dms-greeter") {
|
||||
compositorName := "niri"
|
||||
if wm == deps.WindowManagerHyprland {
|
||||
compositorName = "Hyprland"
|
||||
}
|
||||
fmt.Fprintln(os.Stdout, "Configuring DMS greeter...")
|
||||
logFunc := func(line string) {
|
||||
r.log(line)
|
||||
fmt.Fprintf(os.Stdout, " greeter: %s\n", line)
|
||||
}
|
||||
if err := greeter.AutoSetupGreeter(compositorName, sudoPassword, logFunc); err != nil {
|
||||
// Non-fatal, matching TUI behavior
|
||||
fmt.Fprintf(os.Stderr, "Warning: greeter setup issue (non-fatal): %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 10. Deploy configurations
|
||||
fmt.Fprintln(os.Stdout, "Deploying configurations...")
|
||||
r.log("Starting configuration deployment")
|
||||
|
||||
deployer := config.NewConfigDeployer(r.logChan)
|
||||
results, err := deployer.DeployConfigurationsSelectiveWithReinstalls(
|
||||
context.Background(),
|
||||
wm,
|
||||
terminal,
|
||||
dependencies,
|
||||
replaceConfigs,
|
||||
reinstallItems,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("configuration deployment failed: %w", err)
|
||||
}
|
||||
|
||||
for _, result := range results {
|
||||
if result.Deployed {
|
||||
msg := fmt.Sprintf(" Deployed: %s", result.ConfigType)
|
||||
if result.BackupPath != "" {
|
||||
msg += fmt.Sprintf(" (backup: %s)", result.BackupPath)
|
||||
}
|
||||
fmt.Fprintln(os.Stdout, msg)
|
||||
}
|
||||
if result.Error != nil {
|
||||
fmt.Fprintf(os.Stderr, " Error deploying %s: %v\n", result.ConfigType, result.Error)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintln(os.Stdout, "\nInstallation complete!")
|
||||
r.log("Headless installation completed successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildDisabledItems computes the set of dependencies that should be skipped
|
||||
// during installation, applying the --include-deps and --exclude-deps filters.
|
||||
// dms-greeter is disabled by default (opt-in), matching TUI behavior.
|
||||
func (r *Runner) buildDisabledItems(dependencies []deps.Dependency) (map[string]bool, error) {
|
||||
disabledItems := make(map[string]bool)
|
||||
|
||||
// dms-greeter is opt-in (disabled by default), matching TUI behavior
|
||||
for i := range dependencies {
|
||||
if dependencies[i].Name == "dms-greeter" {
|
||||
disabledItems["dms-greeter"] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Process --include-deps (enable items that are disabled by default)
|
||||
for _, name := range r.cfg.IncludeDeps {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if !r.depExists(dependencies, name) {
|
||||
return nil, fmt.Errorf("--include-deps: unknown dependency %q", name)
|
||||
}
|
||||
delete(disabledItems, name)
|
||||
}
|
||||
|
||||
// Process --exclude-deps (disable items)
|
||||
for _, name := range r.cfg.ExcludeDeps {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if !r.depExists(dependencies, name) {
|
||||
return nil, fmt.Errorf("--exclude-deps: unknown dependency %q", name)
|
||||
}
|
||||
// Don't allow excluding DMS itself
|
||||
if name == "dms (DankMaterialShell)" {
|
||||
return nil, fmt.Errorf("--exclude-deps: cannot exclude required package %q", name)
|
||||
}
|
||||
disabledItems[name] = true
|
||||
}
|
||||
|
||||
return disabledItems, nil
|
||||
}
|
||||
|
||||
// buildReplaceConfigs converts the --replace-configs / --replace-configs-all
|
||||
// flags into the map[string]bool consumed by the config deployer.
|
||||
//
|
||||
// Returns:
|
||||
// - nil when --replace-configs-all is set (deployer treats nil as "replace all")
|
||||
// - a map with all known configs set to false when neither flag is set (deploy only if config file is missing on disk)
|
||||
// - a map with requested configs true, all others false for --replace-configs
|
||||
// - an error when both flags are set or an invalid config name is given
|
||||
func (r *Runner) buildReplaceConfigs() (map[string]bool, error) {
|
||||
hasSpecific := len(r.cfg.ReplaceConfigs) > 0
|
||||
if hasSpecific && r.cfg.ReplaceConfigsAll {
|
||||
return nil, fmt.Errorf("--replace-configs and --replace-configs-all are mutually exclusive")
|
||||
}
|
||||
|
||||
if r.cfg.ReplaceConfigsAll {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Build a map with all known configs explicitly set to false
|
||||
result := make(map[string]bool, len(validConfigNames))
|
||||
for _, cliName := range orderedConfigNames {
|
||||
result[validConfigNames[cliName]] = false
|
||||
}
|
||||
|
||||
// Enable only the requested configs
|
||||
for _, name := range r.cfg.ReplaceConfigs {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
deployerKey, ok := validConfigNames[strings.ToLower(name)]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("--replace-configs: unknown config %q; valid values: niri, hyprland, ghostty, kitty, alacritty", name)
|
||||
}
|
||||
result[deployerKey] = true
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *Runner) log(message string) {
|
||||
select {
|
||||
case r.logChan <- message:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) parseWindowManager() (deps.WindowManager, error) {
|
||||
switch strings.ToLower(r.cfg.Compositor) {
|
||||
case "niri":
|
||||
return deps.WindowManagerNiri, nil
|
||||
case "hyprland":
|
||||
return deps.WindowManagerHyprland, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("invalid --compositor value %q: must be 'niri' or 'hyprland'", r.cfg.Compositor)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) parseTerminal() (deps.Terminal, error) {
|
||||
switch strings.ToLower(r.cfg.Terminal) {
|
||||
case "ghostty":
|
||||
return deps.TerminalGhostty, nil
|
||||
case "kitty":
|
||||
return deps.TerminalKitty, nil
|
||||
case "alacritty":
|
||||
return deps.TerminalAlacritty, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("invalid --term value %q: must be 'ghostty', 'kitty', or 'alacritty'", r.cfg.Terminal)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) resolveSudoPassword() (string, error) {
|
||||
tool, err := privesc.Detect()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := privesc.CheckCached(context.Background()); err == nil {
|
||||
r.log(fmt.Sprintf("%s cache is valid, no password needed", tool.Name()))
|
||||
fmt.Fprintf(os.Stdout, "%s: using cached credentials\n", tool.Name())
|
||||
return "", nil
|
||||
}
|
||||
|
||||
switch tool {
|
||||
case privesc.ToolSudo:
|
||||
return "", fmt.Errorf(
|
||||
"sudo authentication required but no cached credentials found\n" +
|
||||
"Options:\n" +
|
||||
" 1. Run 'sudo -v' before dankinstall to cache credentials\n" +
|
||||
" 2. Configure passwordless sudo for your user",
|
||||
)
|
||||
case privesc.ToolDoas:
|
||||
return "", fmt.Errorf(
|
||||
"doas authentication required but no cached credentials found\n" +
|
||||
"Options:\n" +
|
||||
" 1. Run 'doas true' before dankinstall to cache credentials (requires 'persist' in /etc/doas.conf)\n" +
|
||||
" 2. Configure a 'nopass' rule in /etc/doas.conf for your user",
|
||||
)
|
||||
case privesc.ToolRun0:
|
||||
return "", fmt.Errorf(
|
||||
"run0 authentication required but no cached credentials found\n" +
|
||||
"Configure a polkit rule granting your user passwordless privilege\n" +
|
||||
"(see `man polkit` for details on rules in /etc/polkit-1/rules.d/)",
|
||||
)
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported privilege tool: %s", tool)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) anyConfigEnabled(m map[string]bool) bool {
|
||||
for _, v := range m {
|
||||
if v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (r *Runner) depExists(dependencies []deps.Dependency, name string) bool {
|
||||
for _, dep := range dependencies {
|
||||
if dep.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,459 +0,0 @@
|
||||
package headless
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
||||
)
|
||||
|
||||
func TestParseWindowManager(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want deps.WindowManager
|
||||
wantErr bool
|
||||
}{
|
||||
{"niri lowercase", "niri", deps.WindowManagerNiri, false},
|
||||
{"niri mixed case", "Niri", deps.WindowManagerNiri, false},
|
||||
{"hyprland lowercase", "hyprland", deps.WindowManagerHyprland, false},
|
||||
{"hyprland mixed case", "Hyprland", deps.WindowManagerHyprland, false},
|
||||
{"invalid", "sway", 0, true},
|
||||
{"empty", "", 0, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := NewRunner(Config{Compositor: tt.input})
|
||||
got, err := r.parseWindowManager()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("parseWindowManager() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !tt.wantErr && got != tt.want {
|
||||
t.Errorf("parseWindowManager() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTerminal(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want deps.Terminal
|
||||
wantErr bool
|
||||
}{
|
||||
{"ghostty lowercase", "ghostty", deps.TerminalGhostty, false},
|
||||
{"ghostty mixed case", "Ghostty", deps.TerminalGhostty, false},
|
||||
{"kitty lowercase", "kitty", deps.TerminalKitty, false},
|
||||
{"alacritty lowercase", "alacritty", deps.TerminalAlacritty, false},
|
||||
{"alacritty uppercase", "ALACRITTY", deps.TerminalAlacritty, false},
|
||||
{"invalid", "wezterm", 0, true},
|
||||
{"empty", "", 0, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := NewRunner(Config{Terminal: tt.input})
|
||||
got, err := r.parseTerminal()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("parseTerminal() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !tt.wantErr && got != tt.want {
|
||||
t.Errorf("parseTerminal() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDepExists(t *testing.T) {
|
||||
dependencies := []deps.Dependency{
|
||||
{Name: "niri", Status: deps.StatusInstalled},
|
||||
{Name: "ghostty", Status: deps.StatusMissing},
|
||||
{Name: "dms (DankMaterialShell)", Status: deps.StatusInstalled},
|
||||
{Name: "dms-greeter", Status: deps.StatusMissing},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
dep string
|
||||
want bool
|
||||
}{
|
||||
{"existing dep", "niri", true},
|
||||
{"existing dep with special chars", "dms (DankMaterialShell)", true},
|
||||
{"existing optional dep", "dms-greeter", true},
|
||||
{"non-existing dep", "firefox", false},
|
||||
{"empty name", "", false},
|
||||
}
|
||||
|
||||
r := NewRunner(Config{})
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := r.depExists(dependencies, tt.dep); got != tt.want {
|
||||
t.Errorf("depExists(%q) = %v, want %v", tt.dep, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRunner(t *testing.T) {
|
||||
cfg := Config{
|
||||
Compositor: "niri",
|
||||
Terminal: "ghostty",
|
||||
IncludeDeps: []string{"dms-greeter"},
|
||||
ExcludeDeps: []string{"some-pkg"},
|
||||
Yes: true,
|
||||
}
|
||||
r := NewRunner(cfg)
|
||||
|
||||
if r == nil {
|
||||
t.Fatal("NewRunner returned nil")
|
||||
}
|
||||
if r.cfg.Compositor != "niri" {
|
||||
t.Errorf("cfg.Compositor = %q, want %q", r.cfg.Compositor, "niri")
|
||||
}
|
||||
if r.cfg.Terminal != "ghostty" {
|
||||
t.Errorf("cfg.Terminal = %q, want %q", r.cfg.Terminal, "ghostty")
|
||||
}
|
||||
if !r.cfg.Yes {
|
||||
t.Error("cfg.Yes = false, want true")
|
||||
}
|
||||
if r.logChan == nil {
|
||||
t.Error("logChan is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLogChan(t *testing.T) {
|
||||
r := NewRunner(Config{})
|
||||
ch := r.GetLogChan()
|
||||
if ch == nil {
|
||||
t.Fatal("GetLogChan returned nil")
|
||||
}
|
||||
|
||||
// Verify the channel is readable by sending a message
|
||||
go func() {
|
||||
r.logChan <- "test message"
|
||||
}()
|
||||
msg := <-ch
|
||||
if msg != "test message" {
|
||||
t.Errorf("received %q, want %q", msg, "test message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLog(t *testing.T) {
|
||||
r := NewRunner(Config{})
|
||||
|
||||
// log should not block even if channel is full
|
||||
for i := 0; i < 1100; i++ {
|
||||
r.log("message")
|
||||
}
|
||||
// If we reach here without hanging, the non-blocking send works
|
||||
}
|
||||
|
||||
func TestRunRequiresYes(t *testing.T) {
|
||||
// Verify that ErrConfirmationRequired is a distinct sentinel error
|
||||
if ErrConfirmationRequired == nil {
|
||||
t.Fatal("ErrConfirmationRequired should not be nil")
|
||||
}
|
||||
expected := "confirmation required: pass --yes to proceed"
|
||||
if ErrConfirmationRequired.Error() != expected {
|
||||
t.Errorf("ErrConfirmationRequired = %q, want %q", ErrConfirmationRequired.Error(), expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigYesStoredCorrectly(t *testing.T) {
|
||||
// Yes=false (default) should be stored
|
||||
rNo := NewRunner(Config{Compositor: "niri", Terminal: "ghostty", Yes: false})
|
||||
if rNo.cfg.Yes {
|
||||
t.Error("cfg.Yes = true, want false")
|
||||
}
|
||||
|
||||
// Yes=true should be stored
|
||||
rYes := NewRunner(Config{Compositor: "niri", Terminal: "ghostty", Yes: true})
|
||||
if !rYes.cfg.Yes {
|
||||
t.Error("cfg.Yes = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidConfigNamesCompleteness(t *testing.T) {
|
||||
// orderedConfigNames and validConfigNames must stay in sync.
|
||||
if len(orderedConfigNames) != len(validConfigNames) {
|
||||
t.Fatalf("orderedConfigNames has %d entries but validConfigNames has %d",
|
||||
len(orderedConfigNames), len(validConfigNames))
|
||||
}
|
||||
|
||||
// Every entry in orderedConfigNames must exist in validConfigNames.
|
||||
for _, name := range orderedConfigNames {
|
||||
if _, ok := validConfigNames[name]; !ok {
|
||||
t.Errorf("orderedConfigNames contains %q which is missing from validConfigNames", name)
|
||||
}
|
||||
}
|
||||
|
||||
// validConfigNames must have no extra keys not in orderedConfigNames.
|
||||
ordered := make(map[string]bool, len(orderedConfigNames))
|
||||
for _, name := range orderedConfigNames {
|
||||
ordered[name] = true
|
||||
}
|
||||
for key := range validConfigNames {
|
||||
if !ordered[key] {
|
||||
t.Errorf("validConfigNames contains %q which is missing from orderedConfigNames", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildReplaceConfigs(t *testing.T) {
|
||||
allDeployerKeys := []string{"Niri", "Hyprland", "Ghostty", "Kitty", "Alacritty"}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
replaceConfigs []string
|
||||
replaceAll bool
|
||||
wantNil bool // expect nil (replace all)
|
||||
wantEnabled []string // deployer keys that should be true
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "neither flag set",
|
||||
wantNil: false,
|
||||
wantEnabled: nil, // all should be false
|
||||
},
|
||||
{
|
||||
name: "replace-configs-all",
|
||||
replaceAll: true,
|
||||
wantNil: true,
|
||||
},
|
||||
{
|
||||
name: "specific configs",
|
||||
replaceConfigs: []string{"niri", "ghostty"},
|
||||
wantNil: false,
|
||||
wantEnabled: []string{"Niri", "Ghostty"},
|
||||
},
|
||||
{
|
||||
name: "both flags set",
|
||||
replaceConfigs: []string{"niri"},
|
||||
replaceAll: true,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid config name",
|
||||
replaceConfigs: []string{"foo"},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "case insensitive",
|
||||
replaceConfigs: []string{"NIRI", "Ghostty"},
|
||||
wantNil: false,
|
||||
wantEnabled: []string{"Niri", "Ghostty"},
|
||||
},
|
||||
{
|
||||
name: "single config",
|
||||
replaceConfigs: []string{"kitty"},
|
||||
wantNil: false,
|
||||
wantEnabled: []string{"Kitty"},
|
||||
},
|
||||
{
|
||||
name: "whitespace entry",
|
||||
replaceConfigs: []string{" ", "niri"},
|
||||
wantNil: false,
|
||||
wantEnabled: []string{"Niri"},
|
||||
},
|
||||
{
|
||||
name: "duplicate entry",
|
||||
replaceConfigs: []string{"niri", "niri"},
|
||||
wantNil: false,
|
||||
wantEnabled: []string{"Niri"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := NewRunner(Config{
|
||||
ReplaceConfigs: tt.replaceConfigs,
|
||||
ReplaceConfigsAll: tt.replaceAll,
|
||||
})
|
||||
got, err := r.buildReplaceConfigs()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("buildReplaceConfigs() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if tt.wantErr {
|
||||
return
|
||||
}
|
||||
if tt.wantNil {
|
||||
if got != nil {
|
||||
t.Fatalf("buildReplaceConfigs() = %v, want nil", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("buildReplaceConfigs() = nil, want non-nil map")
|
||||
}
|
||||
|
||||
// All known deployer keys must be present
|
||||
for _, key := range allDeployerKeys {
|
||||
if _, exists := got[key]; !exists {
|
||||
t.Errorf("missing deployer key %q in result map", key)
|
||||
}
|
||||
}
|
||||
|
||||
// Build enabled set for easy lookup
|
||||
enabledSet := make(map[string]bool)
|
||||
for _, k := range tt.wantEnabled {
|
||||
enabledSet[k] = true
|
||||
}
|
||||
|
||||
for _, key := range allDeployerKeys {
|
||||
want := enabledSet[key]
|
||||
if got[key] != want {
|
||||
t.Errorf("replaceConfigs[%q] = %v, want %v", key, got[key], want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigReplaceConfigsStoredCorrectly(t *testing.T) {
|
||||
r := NewRunner(Config{
|
||||
Compositor: "niri",
|
||||
Terminal: "ghostty",
|
||||
ReplaceConfigs: []string{"niri", "ghostty"},
|
||||
ReplaceConfigsAll: false,
|
||||
})
|
||||
if len(r.cfg.ReplaceConfigs) != 2 {
|
||||
t.Errorf("len(ReplaceConfigs) = %d, want 2", len(r.cfg.ReplaceConfigs))
|
||||
}
|
||||
if r.cfg.ReplaceConfigsAll {
|
||||
t.Error("ReplaceConfigsAll = true, want false")
|
||||
}
|
||||
|
||||
r2 := NewRunner(Config{
|
||||
Compositor: "niri",
|
||||
Terminal: "ghostty",
|
||||
ReplaceConfigsAll: true,
|
||||
})
|
||||
if !r2.cfg.ReplaceConfigsAll {
|
||||
t.Error("ReplaceConfigsAll = false, want true")
|
||||
}
|
||||
if len(r2.cfg.ReplaceConfigs) != 0 {
|
||||
t.Errorf("len(ReplaceConfigs) = %d, want 0", len(r2.cfg.ReplaceConfigs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDisabledItems(t *testing.T) {
|
||||
dependencies := []deps.Dependency{
|
||||
{Name: "niri", Status: deps.StatusInstalled},
|
||||
{Name: "ghostty", Status: deps.StatusMissing},
|
||||
{Name: "dms (DankMaterialShell)", Status: deps.StatusInstalled},
|
||||
{Name: "dms-greeter", Status: deps.StatusMissing},
|
||||
{Name: "waybar", Status: deps.StatusMissing},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
includeDeps []string
|
||||
excludeDeps []string
|
||||
deps []deps.Dependency // nil means use the shared fixture
|
||||
wantErr bool
|
||||
errContains string // substring expected in error message
|
||||
wantDisabled []string // dep names that should be in disabledItems
|
||||
wantEnabled []string // dep names that should NOT be in disabledItems (extra check)
|
||||
}{
|
||||
{
|
||||
name: "no flags set, dms-greeter disabled by default",
|
||||
wantDisabled: []string{"dms-greeter"},
|
||||
wantEnabled: []string{"niri", "ghostty", "waybar"},
|
||||
},
|
||||
{
|
||||
name: "include dms-greeter enables it",
|
||||
includeDeps: []string{"dms-greeter"},
|
||||
wantEnabled: []string{"dms-greeter"},
|
||||
},
|
||||
{
|
||||
name: "exclude a regular dep",
|
||||
excludeDeps: []string{"waybar"},
|
||||
wantDisabled: []string{"dms-greeter", "waybar"},
|
||||
},
|
||||
{
|
||||
name: "include unknown dep returns error",
|
||||
includeDeps: []string{"nonexistent"},
|
||||
wantErr: true,
|
||||
errContains: "--include-deps",
|
||||
},
|
||||
{
|
||||
name: "exclude unknown dep returns error",
|
||||
excludeDeps: []string{"nonexistent"},
|
||||
wantErr: true,
|
||||
errContains: "--exclude-deps",
|
||||
},
|
||||
{
|
||||
name: "exclude DMS itself is forbidden",
|
||||
excludeDeps: []string{"dms (DankMaterialShell)"},
|
||||
wantErr: true,
|
||||
errContains: "cannot exclude required package",
|
||||
},
|
||||
{
|
||||
name: "include and exclude same dep",
|
||||
includeDeps: []string{"dms-greeter"},
|
||||
excludeDeps: []string{"dms-greeter"},
|
||||
wantDisabled: []string{"dms-greeter"},
|
||||
},
|
||||
{
|
||||
name: "whitespace entries are skipped",
|
||||
includeDeps: []string{" ", "dms-greeter"},
|
||||
wantEnabled: []string{"dms-greeter"},
|
||||
},
|
||||
{
|
||||
name: "no dms-greeter in deps, nothing disabled by default",
|
||||
deps: []deps.Dependency{
|
||||
{Name: "niri", Status: deps.StatusInstalled},
|
||||
},
|
||||
wantEnabled: []string{"niri"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := NewRunner(Config{
|
||||
IncludeDeps: tt.includeDeps,
|
||||
ExcludeDeps: tt.excludeDeps,
|
||||
})
|
||||
d := tt.deps
|
||||
if d == nil {
|
||||
d = dependencies
|
||||
}
|
||||
got, err := r.buildDisabledItems(d)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("buildDisabledItems() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if tt.wantErr {
|
||||
if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
|
||||
t.Errorf("error %q does not contain %q", err.Error(), tt.errContains)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("buildDisabledItems() returned nil map, want non-nil")
|
||||
}
|
||||
|
||||
// Check expected disabled items
|
||||
for _, name := range tt.wantDisabled {
|
||||
if !got[name] {
|
||||
t.Errorf("expected %q to be disabled, but it is not", name)
|
||||
}
|
||||
}
|
||||
|
||||
// Check expected enabled items (should not be in the map or be false)
|
||||
for _, name := range tt.wantEnabled {
|
||||
if got[name] {
|
||||
t.Errorf("expected %q to NOT be disabled, but it is", name)
|
||||
}
|
||||
}
|
||||
|
||||
// If wantDisabled is empty, the map should have length 0
|
||||
if len(tt.wantDisabled) == 0 && len(got) != 0 {
|
||||
t.Errorf("expected empty disabledItems map, got %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -22,16 +21,7 @@ type FileLogger struct {
|
||||
|
||||
func NewFileLogger() (*FileLogger, error) {
|
||||
timestamp := time.Now().Unix()
|
||||
|
||||
// Use DANKINSTALL_LOG_DIR if set, otherwise fall back to /tmp.
|
||||
logDir := os.Getenv("DANKINSTALL_LOG_DIR")
|
||||
if logDir == "" {
|
||||
logDir = "/tmp"
|
||||
}
|
||||
if err := os.MkdirAll(logDir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create log directory: %w", err)
|
||||
}
|
||||
logPath := filepath.Join(logDir, fmt.Sprintf("dankinstall-%d.log", timestamp))
|
||||
logPath := fmt.Sprintf("/tmp/dankinstall-%d.log", timestamp)
|
||||
|
||||
file, err := os.Create(logPath)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
cblog "github.com/charmbracelet/log"
|
||||
"github.com/mattn/go-isatty"
|
||||
"github.com/muesli/termenv"
|
||||
)
|
||||
|
||||
// Logger embeds the Charm Logger and adds Printf/Fatalf
|
||||
@@ -25,26 +21,8 @@ func (l *Logger) Fatalf(format string, v ...any) { l.Logger.Fatalf(format, v...)
|
||||
var (
|
||||
logger *Logger
|
||||
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 {
|
||||
switch strings.ToLower(level) {
|
||||
case "debug":
|
||||
@@ -108,7 +86,7 @@ func GetLogger() *Logger {
|
||||
SetString(" DEBUG").
|
||||
Foreground(lipgloss.Color("4"))
|
||||
|
||||
base := cblog.New(logStderr)
|
||||
base := cblog.New(os.Stderr)
|
||||
base.SetStyles(styles)
|
||||
base.SetReportTimestamp(false)
|
||||
|
||||
@@ -120,85 +98,10 @@ func GetLogger() *Logger {
|
||||
base.SetPrefix(" go")
|
||||
|
||||
logger = &Logger{base}
|
||||
|
||||
if path := os.Getenv("DMS_LOG_FILE"); path != "" {
|
||||
_ = SetLogFile(path)
|
||||
}
|
||||
})
|
||||
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
|
||||
|
||||
func Debug(msg any, keyvals ...any) { GetLogger().Debug(msg, keyvals...) }
|
||||
|
||||
@@ -60,7 +60,6 @@ var templateRegistry = []TemplateDef{
|
||||
{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: "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: "ghostty", Commands: []string{"ghostty"}, ConfigFile: "ghostty.toml", Kind: TemplateKindTerminal},
|
||||
{ID: "kitty", Commands: []string{"kitty"}, ConfigFile: "kitty.toml", Kind: TemplateKindTerminal},
|
||||
@@ -100,7 +99,6 @@ type Options struct {
|
||||
Mode ColorMode
|
||||
IconTheme string
|
||||
MatugenType string
|
||||
Contrast float64
|
||||
RunUserTemplates bool
|
||||
ColorsOnly bool
|
||||
StockColors string
|
||||
@@ -230,7 +228,6 @@ func buildOnce(opts *Options) (bool, error) {
|
||||
|
||||
log.Info("Running matugen color hex with stock color overrides")
|
||||
args := []string{"color", "hex", primaryDark, "-m", string(opts.Mode), "-t", opts.MatugenType, "-c", cfgFile.Name()}
|
||||
args = appendContrastArg(args, opts.Contrast)
|
||||
args = append(args, importArgs...)
|
||||
if err := runMatugen(args); err != nil {
|
||||
return false, err
|
||||
@@ -267,7 +264,6 @@ func buildOnce(opts *Options) (bool, error) {
|
||||
args = []string{opts.Kind, opts.Value}
|
||||
}
|
||||
args = append(args, "-m", string(opts.Mode), "-t", opts.MatugenType, "-c", cfgFile.Name())
|
||||
args = appendContrastArg(args, opts.Contrast)
|
||||
args = append(args, importArgs...)
|
||||
if err := runMatugen(args); err != nil {
|
||||
return false, err
|
||||
@@ -303,13 +299,6 @@ func buildOnce(opts *Options) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func appendContrastArg(args []string, contrast float64) []string {
|
||||
if contrast == 0 {
|
||||
return args
|
||||
}
|
||||
return append(args, "--contrast", strconv.FormatFloat(contrast, 'f', -1, 64))
|
||||
}
|
||||
|
||||
func buildMergedConfig(opts *Options, cfgFile *os.File, tmpDir string) error {
|
||||
userConfigPath := filepath.Join(opts.ConfigDir, "matugen", "config.toml")
|
||||
|
||||
@@ -698,7 +687,6 @@ func execDryRun(opts *Options, flags matugenFlags) (string, error) {
|
||||
baseArgs = []string{opts.Kind, opts.Value}
|
||||
}
|
||||
baseArgs = append(baseArgs, "-m", "dark", "-t", opts.MatugenType, "--json", "hex", "--dry-run")
|
||||
baseArgs = appendContrastArg(baseArgs, opts.Contrast)
|
||||
if flags.isV4 {
|
||||
baseArgs = append(baseArgs, "--source-color-index", "0", "--old-json-output")
|
||||
}
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
// Code generated by mockery v2.53.5. DO NOT EDIT.
|
||||
|
||||
package mocks_geolocation
|
||||
|
||||
import (
|
||||
geolocation "github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// MockClient is an autogenerated mock type for the Client type
|
||||
type MockClient struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
type MockClient_Expecter struct {
|
||||
mock *mock.Mock
|
||||
}
|
||||
|
||||
func (_m *MockClient) EXPECT() *MockClient_Expecter {
|
||||
return &MockClient_Expecter{mock: &_m.Mock}
|
||||
}
|
||||
|
||||
// Close provides a mock function with no fields
|
||||
func (_m *MockClient) Close() {
|
||||
_m.Called()
|
||||
}
|
||||
|
||||
// MockClient_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
|
||||
type MockClient_Close_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Close is a helper method to define mock.On call
|
||||
func (_e *MockClient_Expecter) Close() *MockClient_Close_Call {
|
||||
return &MockClient_Close_Call{Call: _e.mock.On("Close")}
|
||||
}
|
||||
|
||||
func (_c *MockClient_Close_Call) Run(run func()) *MockClient_Close_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run()
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockClient_Close_Call) Return() *MockClient_Close_Call {
|
||||
_c.Call.Return()
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockClient_Close_Call) RunAndReturn(run func()) *MockClient_Close_Call {
|
||||
_c.Run(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// GetLocation provides a mock function with no fields
|
||||
func (_m *MockClient) GetLocation() (geolocation.Location, error) {
|
||||
ret := _m.Called()
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for GetLocation")
|
||||
}
|
||||
|
||||
var r0 geolocation.Location
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func() (geolocation.Location, error)); ok {
|
||||
return rf()
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func() geolocation.Location); ok {
|
||||
r0 = rf()
|
||||
} else {
|
||||
r0 = ret.Get(0).(geolocation.Location)
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func() error); ok {
|
||||
r1 = rf()
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// MockClient_GetLocation_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetLocation'
|
||||
type MockClient_GetLocation_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// GetLocation is a helper method to define mock.On call
|
||||
func (_e *MockClient_Expecter) GetLocation() *MockClient_GetLocation_Call {
|
||||
return &MockClient_GetLocation_Call{Call: _e.mock.On("GetLocation")}
|
||||
}
|
||||
|
||||
func (_c *MockClient_GetLocation_Call) Run(run func()) *MockClient_GetLocation_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run()
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockClient_GetLocation_Call) Return(_a0 geolocation.Location, _a1 error) *MockClient_GetLocation_Call {
|
||||
_c.Call.Return(_a0, _a1)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockClient_GetLocation_Call) RunAndReturn(run func() (geolocation.Location, error)) *MockClient_GetLocation_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// Subscribe provides a mock function with given fields: id
|
||||
func (_m *MockClient) Subscribe(id string) chan geolocation.Location {
|
||||
ret := _m.Called(id)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Subscribe")
|
||||
}
|
||||
|
||||
var r0 chan geolocation.Location
|
||||
if rf, ok := ret.Get(0).(func(string) chan geolocation.Location); ok {
|
||||
r0 = rf(id)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(chan geolocation.Location)
|
||||
}
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// MockClient_Subscribe_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Subscribe'
|
||||
type MockClient_Subscribe_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Subscribe is a helper method to define mock.On call
|
||||
// - id string
|
||||
func (_e *MockClient_Expecter) Subscribe(id interface{}) *MockClient_Subscribe_Call {
|
||||
return &MockClient_Subscribe_Call{Call: _e.mock.On("Subscribe", id)}
|
||||
}
|
||||
|
||||
func (_c *MockClient_Subscribe_Call) Run(run func(id string)) *MockClient_Subscribe_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run(args[0].(string))
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockClient_Subscribe_Call) Return(_a0 chan geolocation.Location) *MockClient_Subscribe_Call {
|
||||
_c.Call.Return(_a0)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockClient_Subscribe_Call) RunAndReturn(run func(string) chan geolocation.Location) *MockClient_Subscribe_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// Unsubscribe provides a mock function with given fields: id
|
||||
func (_m *MockClient) Unsubscribe(id string) {
|
||||
_m.Called(id)
|
||||
}
|
||||
|
||||
// MockClient_Unsubscribe_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Unsubscribe'
|
||||
type MockClient_Unsubscribe_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Unsubscribe is a helper method to define mock.On call
|
||||
// - id string
|
||||
func (_e *MockClient_Expecter) Unsubscribe(id interface{}) *MockClient_Unsubscribe_Call {
|
||||
return &MockClient_Unsubscribe_Call{Call: _e.mock.On("Unsubscribe", id)}
|
||||
}
|
||||
|
||||
func (_c *MockClient_Unsubscribe_Call) Run(run func(id string)) *MockClient_Unsubscribe_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run(args[0].(string))
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockClient_Unsubscribe_Call) Return() *MockClient_Unsubscribe_Call {
|
||||
_c.Call.Return()
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockClient_Unsubscribe_Call) RunAndReturn(run func(string)) *MockClient_Unsubscribe_Call {
|
||||
_c.Run(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// NewMockClient creates a new instance of MockClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
||||
// The first argument is typically a *testing.T value.
|
||||
func NewMockClient(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *MockClient {
|
||||
mock := &MockClient{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
@@ -1062,62 +1062,6 @@ func (_c *MockBackend_GetWiFiNetworkDetails_Call) RunAndReturn(run func(string)
|
||||
return _c
|
||||
}
|
||||
|
||||
// GetWiFiQRCodeContent provides a mock function with given fields: ssid
|
||||
func (_m *MockBackend) GetWiFiQRCodeContent(ssid string) (string, error) {
|
||||
ret := _m.Called(ssid)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for GetWiFiQRCodeContent")
|
||||
}
|
||||
|
||||
var r0 string
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(string) (string, error)); ok {
|
||||
return rf(ssid)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(string) string); ok {
|
||||
r0 = rf(ssid)
|
||||
} else {
|
||||
r0 = ret.Get(0).(string)
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(string) error); ok {
|
||||
r1 = rf(ssid)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// MockBackend_GetWiFiQRCodeContent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetWiFiQRCodeContent'
|
||||
type MockBackend_GetWiFiQRCodeContent_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// GetWiFiQRCodeContent is a helper method to define mock.On call
|
||||
// - ssid string
|
||||
func (_e *MockBackend_Expecter) GetWiFiQRCodeContent(ssid interface{}) *MockBackend_GetWiFiQRCodeContent_Call {
|
||||
return &MockBackend_GetWiFiQRCodeContent_Call{Call: _e.mock.On("GetWiFiQRCodeContent", ssid)}
|
||||
}
|
||||
|
||||
func (_c *MockBackend_GetWiFiQRCodeContent_Call) Run(run func(ssid string)) *MockBackend_GetWiFiQRCodeContent_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run(args[0].(string))
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockBackend_GetWiFiQRCodeContent_Call) Return(_a0 string, _a1 error) *MockBackend_GetWiFiQRCodeContent_Call {
|
||||
_c.Call.Return(_a0, _a1)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockBackend_GetWiFiQRCodeContent_Call) RunAndReturn(run func(string) (string, error)) *MockBackend_GetWiFiQRCodeContent_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// GetWiredConnections provides a mock function with no fields
|
||||
func (_m *MockBackend) GetWiredConnections() ([]network.WiredConnection, error) {
|
||||
ret := _m.Called()
|
||||
|
||||
@@ -1,874 +0,0 @@
|
||||
package pam
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||
)
|
||||
|
||||
const (
|
||||
GreeterPamManagedBlockStart = "# BEGIN DMS GREETER AUTH (managed by dms greeter sync)"
|
||||
GreeterPamManagedBlockEnd = "# END DMS GREETER AUTH"
|
||||
|
||||
LockscreenPamManagedBlockStart = "# BEGIN DMS LOCKSCREEN AUTH (managed by dms greeter sync)"
|
||||
LockscreenPamManagedBlockEnd = "# END DMS LOCKSCREEN AUTH"
|
||||
|
||||
LockscreenU2FPamManagedBlockStart = "# BEGIN DMS LOCKSCREEN U2F AUTH (managed by dms auth sync)"
|
||||
LockscreenU2FPamManagedBlockEnd = "# END DMS LOCKSCREEN U2F AUTH"
|
||||
|
||||
legacyGreeterPamFprintComment = "# DMS greeter fingerprint"
|
||||
legacyGreeterPamU2FComment = "# DMS greeter U2F"
|
||||
|
||||
GreetdPamPath = "/etc/pam.d/greetd"
|
||||
DankshellPamPath = "/etc/pam.d/dankshell"
|
||||
DankshellU2FPamPath = "/etc/pam.d/dankshell-u2f"
|
||||
)
|
||||
|
||||
var includedPamAuthFiles = []string{
|
||||
"system-auth",
|
||||
"common-auth",
|
||||
"password-auth",
|
||||
"system-login",
|
||||
"system-local-login",
|
||||
"common-auth-pc",
|
||||
"login",
|
||||
}
|
||||
|
||||
type AuthSettings struct {
|
||||
EnableFprint bool `json:"enableFprint"`
|
||||
EnableU2f bool `json:"enableU2f"`
|
||||
GreeterEnableFprint bool `json:"greeterEnableFprint"`
|
||||
GreeterEnableU2f bool `json:"greeterEnableU2f"`
|
||||
}
|
||||
|
||||
type SyncAuthOptions struct {
|
||||
HomeDir string
|
||||
ForceGreeterAuth bool
|
||||
}
|
||||
|
||||
type syncDeps struct {
|
||||
pamDir string
|
||||
greetdPath string
|
||||
dankshellPath string
|
||||
dankshellU2fPath string
|
||||
isNixOS func() bool
|
||||
readFile func(string) ([]byte, error)
|
||||
stat func(string) (os.FileInfo, error)
|
||||
createTemp func(string, string) (*os.File, error)
|
||||
removeFile func(string) error
|
||||
runSudoCmd func(string, string, ...string) error
|
||||
pamModuleExists func(string) bool
|
||||
fingerprintAvailableForCurrentUser func() bool
|
||||
}
|
||||
|
||||
type lockscreenPamIncludeDirective struct {
|
||||
target string
|
||||
filterType string
|
||||
}
|
||||
|
||||
type lockscreenPamResolver struct {
|
||||
pamDir string
|
||||
readFile func(string) ([]byte, error)
|
||||
}
|
||||
|
||||
func defaultSyncDeps() syncDeps {
|
||||
return syncDeps{
|
||||
pamDir: "/etc/pam.d",
|
||||
greetdPath: GreetdPamPath,
|
||||
dankshellPath: DankshellPamPath,
|
||||
dankshellU2fPath: DankshellU2FPamPath,
|
||||
isNixOS: IsNixOS,
|
||||
readFile: os.ReadFile,
|
||||
stat: os.Stat,
|
||||
createTemp: os.CreateTemp,
|
||||
removeFile: os.Remove,
|
||||
runSudoCmd: func(password, command string, args ...string) error {
|
||||
return privesc.Run(context.Background(), password, append([]string{command}, args...)...)
|
||||
},
|
||||
pamModuleExists: pamModuleExists,
|
||||
fingerprintAvailableForCurrentUser: FingerprintAuthAvailableForCurrentUser,
|
||||
}
|
||||
}
|
||||
|
||||
func IsNixOS() bool {
|
||||
_, err := os.Stat("/etc/NIXOS")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func ReadAuthSettings(homeDir string) (AuthSettings, error) {
|
||||
settingsPath := filepath.Join(homeDir, ".config", "DankMaterialShell", "settings.json")
|
||||
data, err := os.ReadFile(settingsPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return AuthSettings{}, nil
|
||||
}
|
||||
return AuthSettings{}, fmt.Errorf("failed to read settings at %s: %w", settingsPath, err)
|
||||
}
|
||||
if strings.TrimSpace(string(data)) == "" {
|
||||
return AuthSettings{}, nil
|
||||
}
|
||||
|
||||
var settings AuthSettings
|
||||
if err := json.Unmarshal(data, &settings); err != nil {
|
||||
return AuthSettings{}, fmt.Errorf("failed to parse settings at %s: %w", settingsPath, err)
|
||||
}
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
func ReadGreeterAuthToggles(homeDir string) (enableFprint bool, enableU2f bool, err error) {
|
||||
settings, err := ReadAuthSettings(homeDir)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
return settings.GreeterEnableFprint, settings.GreeterEnableU2f, nil
|
||||
}
|
||||
|
||||
func SyncAuthConfig(logFunc func(string), sudoPassword string, options SyncAuthOptions) error {
|
||||
return syncAuthConfigWithDeps(logFunc, sudoPassword, options, defaultSyncDeps())
|
||||
}
|
||||
|
||||
func RemoveManagedGreeterPamBlock(logFunc func(string), sudoPassword string) error {
|
||||
return removeManagedGreeterPamBlockWithDeps(logFunc, sudoPassword, defaultSyncDeps())
|
||||
}
|
||||
|
||||
func syncAuthConfigWithDeps(logFunc func(string), sudoPassword string, options SyncAuthOptions, deps syncDeps) error {
|
||||
homeDir := strings.TrimSpace(options.HomeDir)
|
||||
if homeDir == "" {
|
||||
var err error
|
||||
homeDir, err = os.UserHomeDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get user home directory: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
settings, err := ReadAuthSettings(homeDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := syncLockscreenPamConfigWithDeps(logFunc, sudoPassword, deps); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := syncLockscreenU2FPamConfigWithDeps(logFunc, sudoPassword, settings.EnableU2f, deps); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := deps.stat(deps.greetdPath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
logFunc("ℹ /etc/pam.d/greetd not found. Skipping greeter PAM sync.")
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to inspect %s: %w", deps.greetdPath, err)
|
||||
}
|
||||
|
||||
if err := syncGreeterPamConfigWithDeps(logFunc, sudoPassword, settings, options.ForceGreeterAuth, deps); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeManagedGreeterPamBlockWithDeps(logFunc func(string), sudoPassword string, deps syncDeps) error {
|
||||
if deps.isNixOS() {
|
||||
return nil
|
||||
}
|
||||
|
||||
data, err := deps.readFile(deps.greetdPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to read %s: %w", deps.greetdPath, err)
|
||||
}
|
||||
|
||||
originalContent := string(data)
|
||||
stripped, removed := stripManagedGreeterPamBlock(originalContent)
|
||||
strippedAgain, removedLegacy := stripLegacyGreeterPamLines(stripped)
|
||||
if !removed && !removedLegacy {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := writeManagedPamFile(strippedAgain, deps.greetdPath, sudoPassword, deps); err != nil {
|
||||
return fmt.Errorf("failed to write %s: %w", deps.greetdPath, err)
|
||||
}
|
||||
|
||||
logFunc("✓ Removed DMS managed PAM block from " + deps.greetdPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func ParseManagedGreeterPamAuth(pamText string) (managed bool, fingerprint bool, u2f bool, legacy bool) {
|
||||
if pamText == "" {
|
||||
return false, false, false, false
|
||||
}
|
||||
|
||||
lines := strings.Split(pamText, "\n")
|
||||
inManaged := false
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
switch trimmed {
|
||||
case GreeterPamManagedBlockStart:
|
||||
managed = true
|
||||
inManaged = true
|
||||
continue
|
||||
case GreeterPamManagedBlockEnd:
|
||||
inManaged = false
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(trimmed, legacyGreeterPamFprintComment) || strings.HasPrefix(trimmed, legacyGreeterPamU2FComment) {
|
||||
legacy = true
|
||||
}
|
||||
if !inManaged {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(trimmed, "pam_fprintd") {
|
||||
fingerprint = true
|
||||
}
|
||||
if strings.Contains(trimmed, "pam_u2f") {
|
||||
u2f = true
|
||||
}
|
||||
}
|
||||
|
||||
return managed, fingerprint, u2f, legacy
|
||||
}
|
||||
|
||||
func StripManagedGreeterPamContent(pamText string) (string, bool) {
|
||||
stripped, removed := stripManagedGreeterPamBlock(pamText)
|
||||
stripped, removedLegacy := stripLegacyGreeterPamLines(stripped)
|
||||
return stripped, removed || removedLegacy
|
||||
}
|
||||
|
||||
func PamTextIncludesFile(pamText, filename string) bool {
|
||||
lines := strings.Split(pamText, "\n")
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(trimmed, filename) &&
|
||||
(strings.Contains(trimmed, "include") || strings.Contains(trimmed, "substack") || strings.HasPrefix(trimmed, "@include")) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func PamFileHasModule(pamFilePath, module string) bool {
|
||||
data, err := os.ReadFile(pamFilePath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return pamContentHasModule(string(data), module)
|
||||
}
|
||||
|
||||
func DetectIncludedPamModule(pamText, module string) string {
|
||||
return detectIncludedPamModule(pamText, module, defaultSyncDeps())
|
||||
}
|
||||
|
||||
func detectIncludedPamModule(pamText, module string, deps syncDeps) string {
|
||||
for _, includedFile := range includedPamAuthFiles {
|
||||
if !PamTextIncludesFile(pamText, includedFile) {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(deps.pamDir, includedFile)
|
||||
data, err := deps.readFile(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if pamContentHasModule(string(data), module) {
|
||||
return includedFile
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func pamContentHasModule(content, module string) bool {
|
||||
lines := strings.Split(content, "\n")
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(trimmed, module) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasManagedLockscreenPamFile(content string) bool {
|
||||
return strings.Contains(content, LockscreenPamManagedBlockStart) &&
|
||||
strings.Contains(content, LockscreenPamManagedBlockEnd)
|
||||
}
|
||||
|
||||
func hasManagedLockscreenU2FPamFile(content string) bool {
|
||||
return strings.Contains(content, LockscreenU2FPamManagedBlockStart) &&
|
||||
strings.Contains(content, LockscreenU2FPamManagedBlockEnd)
|
||||
}
|
||||
|
||||
func pamDirectiveType(line string) string {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
directiveType := strings.TrimPrefix(fields[0], "-")
|
||||
switch directiveType {
|
||||
case "auth", "account", "password", "session":
|
||||
return directiveType
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func isExcludedLockscreenPamLine(line string) bool {
|
||||
for _, field := range strings.Fields(line) {
|
||||
if strings.HasPrefix(field, "#") {
|
||||
break
|
||||
}
|
||||
if strings.Contains(field, "pam_u2f") || strings.Contains(field, "pam_fprintd") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parseLockscreenPamIncludeDirective(trimmed string, inheritedFilter string) (lockscreenPamIncludeDirective, bool) {
|
||||
fields := strings.Fields(trimmed)
|
||||
if len(fields) >= 2 && fields[0] == "@include" {
|
||||
return lockscreenPamIncludeDirective{
|
||||
target: fields[1],
|
||||
filterType: inheritedFilter,
|
||||
}, true
|
||||
}
|
||||
|
||||
if len(fields) >= 3 && (fields[1] == "include" || fields[1] == "substack") {
|
||||
lineType := pamDirectiveType(trimmed)
|
||||
if lineType == "" {
|
||||
return lockscreenPamIncludeDirective{}, false
|
||||
}
|
||||
return lockscreenPamIncludeDirective{
|
||||
target: fields[2],
|
||||
filterType: lineType,
|
||||
}, true
|
||||
}
|
||||
|
||||
if len(fields) >= 3 && fields[1] == "@include" {
|
||||
lineType := pamDirectiveType(trimmed)
|
||||
if lineType == "" {
|
||||
return lockscreenPamIncludeDirective{}, false
|
||||
}
|
||||
return lockscreenPamIncludeDirective{
|
||||
target: fields[2],
|
||||
filterType: lineType,
|
||||
}, true
|
||||
}
|
||||
|
||||
return lockscreenPamIncludeDirective{}, false
|
||||
}
|
||||
|
||||
func resolveLockscreenPamIncludePath(pamDir, target string) (string, error) {
|
||||
if strings.TrimSpace(target) == "" {
|
||||
return "", fmt.Errorf("empty PAM include target")
|
||||
}
|
||||
|
||||
cleanPamDir := filepath.Clean(pamDir)
|
||||
if filepath.IsAbs(target) {
|
||||
cleanTarget := filepath.Clean(target)
|
||||
if filepath.Dir(cleanTarget) != cleanPamDir {
|
||||
return "", fmt.Errorf("unsupported PAM include outside %s: %s", cleanPamDir, target)
|
||||
}
|
||||
return cleanTarget, nil
|
||||
}
|
||||
|
||||
cleanTarget := filepath.Clean(target)
|
||||
if cleanTarget == "." || cleanTarget == ".." || strings.HasPrefix(cleanTarget, ".."+string(os.PathSeparator)) {
|
||||
return "", fmt.Errorf("invalid PAM include target: %s", target)
|
||||
}
|
||||
|
||||
return filepath.Join(cleanPamDir, cleanTarget), nil
|
||||
}
|
||||
|
||||
func (r lockscreenPamResolver) resolveService(serviceName string, filterType string, stack []string) ([]string, error) {
|
||||
path, err := resolveLockscreenPamIncludePath(r.pamDir, serviceName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, seen := range stack {
|
||||
if seen == path {
|
||||
chain := append(append([]string{}, stack...), path)
|
||||
display := make([]string, 0, len(chain))
|
||||
for _, item := range chain {
|
||||
display = append(display, filepath.Base(item))
|
||||
}
|
||||
return nil, fmt.Errorf("cyclic PAM include detected: %s", strings.Join(display, " -> "))
|
||||
}
|
||||
}
|
||||
|
||||
data, err := r.readFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read PAM file %s: %w", path, err)
|
||||
}
|
||||
|
||||
var resolved []string
|
||||
for _, rawLine := range strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n") {
|
||||
rawLine = strings.TrimRight(rawLine, "\r")
|
||||
trimmed := strings.TrimSpace(rawLine)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") || trimmed == "#%PAM-1.0" {
|
||||
continue
|
||||
}
|
||||
|
||||
if include, ok := parseLockscreenPamIncludeDirective(trimmed, filterType); ok {
|
||||
lineType := pamDirectiveType(trimmed)
|
||||
if filterType != "" && lineType != "" && lineType != filterType {
|
||||
continue
|
||||
}
|
||||
|
||||
nested, err := r.resolveService(include.target, include.filterType, append(stack, path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resolved = append(resolved, nested...)
|
||||
continue
|
||||
}
|
||||
|
||||
lineType := pamDirectiveType(trimmed)
|
||||
if lineType == "" {
|
||||
return nil, fmt.Errorf("unsupported PAM directive in %s: %s", filepath.Base(path), trimmed)
|
||||
}
|
||||
if filterType != "" && lineType != filterType {
|
||||
continue
|
||||
}
|
||||
if isExcludedLockscreenPamLine(trimmed) {
|
||||
continue
|
||||
}
|
||||
|
||||
resolved = append(resolved, rawLine)
|
||||
}
|
||||
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
func buildManagedLockscreenPamContent(pamDir string, readFile func(string) ([]byte, error)) (string, error) {
|
||||
resolver := lockscreenPamResolver{
|
||||
pamDir: pamDir,
|
||||
readFile: readFile,
|
||||
}
|
||||
|
||||
resolvedLines, err := resolver.resolveService("login", "", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(resolvedLines) == 0 {
|
||||
return "", fmt.Errorf("no auth directives remained after filtering %s", filepath.Join(pamDir, "login"))
|
||||
}
|
||||
|
||||
hasAuth := false
|
||||
for _, line := range resolvedLines {
|
||||
if pamDirectiveType(strings.TrimSpace(line)) == "auth" {
|
||||
hasAuth = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasAuth {
|
||||
return "", fmt.Errorf("no auth directives remained after filtering %s", filepath.Join(pamDir, "login"))
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("#%PAM-1.0\n")
|
||||
b.WriteString(LockscreenPamManagedBlockStart + "\n")
|
||||
for _, line := range resolvedLines {
|
||||
b.WriteString(line)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
b.WriteString(LockscreenPamManagedBlockEnd + "\n")
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
func buildManagedLockscreenU2FPamContent() string {
|
||||
var b strings.Builder
|
||||
b.WriteString("#%PAM-1.0\n")
|
||||
b.WriteString(LockscreenU2FPamManagedBlockStart + "\n")
|
||||
b.WriteString("auth required pam_u2f.so cue nouserok timeout=10\n")
|
||||
b.WriteString(LockscreenU2FPamManagedBlockEnd + "\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func syncLockscreenPamConfigWithDeps(logFunc func(string), sudoPassword string, deps syncDeps) error {
|
||||
if deps.isNixOS() {
|
||||
logFunc("ℹ NixOS detected. DMS continues to use /etc/pam.d/login for lock screen password auth on NixOS unless you declare security.pam.services.dankshell yourself. U2F and fingerprint are handled separately and should not be included in dankshell.")
|
||||
return nil
|
||||
}
|
||||
|
||||
existingData, err := deps.readFile(deps.dankshellPath)
|
||||
if err == nil {
|
||||
if !hasManagedLockscreenPamFile(string(existingData)) {
|
||||
logFunc("ℹ Custom /etc/pam.d/dankshell found (no DMS block). Skipping.")
|
||||
return nil
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return fmt.Errorf("failed to read %s: %w", deps.dankshellPath, err)
|
||||
}
|
||||
|
||||
content, err := buildManagedLockscreenPamContent(deps.pamDir, deps.readFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to build %s from %s: %w", deps.dankshellPath, filepath.Join(deps.pamDir, "login"), err)
|
||||
}
|
||||
|
||||
if err := writeManagedPamFile(content, deps.dankshellPath, sudoPassword, deps); err != nil {
|
||||
return fmt.Errorf("failed to write %s: %w", deps.dankshellPath, err)
|
||||
}
|
||||
|
||||
logFunc("✓ Created or updated /etc/pam.d/dankshell for lock screen authentication")
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncLockscreenU2FPamConfigWithDeps(logFunc func(string), sudoPassword string, enabled bool, deps syncDeps) error {
|
||||
if deps.isNixOS() {
|
||||
logFunc("ℹ NixOS detected. DMS does not manage /etc/pam.d/dankshell-u2f on NixOS. Keep using the bundled U2F helper or configure a custom PAM service yourself.")
|
||||
return nil
|
||||
}
|
||||
|
||||
existingData, err := deps.readFile(deps.dankshellU2fPath)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("failed to read %s: %w", deps.dankshellU2fPath, err)
|
||||
}
|
||||
|
||||
if enabled {
|
||||
if err == nil && !hasManagedLockscreenU2FPamFile(string(existingData)) {
|
||||
logFunc("ℹ Custom /etc/pam.d/dankshell-u2f found (no DMS block). Skipping.")
|
||||
return nil
|
||||
}
|
||||
if err := writeManagedPamFile(buildManagedLockscreenU2FPamContent(), deps.dankshellU2fPath, sudoPassword, deps); err != nil {
|
||||
return fmt.Errorf("failed to write %s: %w", deps.dankshellU2fPath, err)
|
||||
}
|
||||
logFunc("✓ Created or updated /etc/pam.d/dankshell-u2f for lock screen security-key authentication")
|
||||
return nil
|
||||
}
|
||||
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err == nil && !hasManagedLockscreenU2FPamFile(string(existingData)) {
|
||||
logFunc("ℹ Custom /etc/pam.d/dankshell-u2f found (no DMS block). Leaving it untouched.")
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := deps.runSudoCmd(sudoPassword, "rm", "-f", deps.dankshellU2fPath); err != nil {
|
||||
return fmt.Errorf("failed to remove %s: %w", deps.dankshellU2fPath, err)
|
||||
}
|
||||
logFunc("✓ Removed DMS-managed /etc/pam.d/dankshell-u2f")
|
||||
return nil
|
||||
}
|
||||
|
||||
func stripManagedGreeterPamBlock(content string) (string, bool) {
|
||||
lines := strings.Split(content, "\n")
|
||||
filtered := make([]string, 0, len(lines))
|
||||
inManagedBlock := false
|
||||
removed := false
|
||||
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == GreeterPamManagedBlockStart {
|
||||
inManagedBlock = true
|
||||
removed = true
|
||||
continue
|
||||
}
|
||||
if trimmed == GreeterPamManagedBlockEnd {
|
||||
inManagedBlock = false
|
||||
removed = true
|
||||
continue
|
||||
}
|
||||
if inManagedBlock {
|
||||
removed = true
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, line)
|
||||
}
|
||||
|
||||
return strings.Join(filtered, "\n"), removed
|
||||
}
|
||||
|
||||
func stripLegacyGreeterPamLines(content string) (string, bool) {
|
||||
lines := strings.Split(content, "\n")
|
||||
filtered := make([]string, 0, len(lines))
|
||||
removed := false
|
||||
|
||||
for i := 0; i < len(lines); i++ {
|
||||
trimmed := strings.TrimSpace(lines[i])
|
||||
if strings.HasPrefix(trimmed, legacyGreeterPamFprintComment) || strings.HasPrefix(trimmed, legacyGreeterPamU2FComment) {
|
||||
removed = true
|
||||
if i+1 < len(lines) {
|
||||
nextLine := strings.TrimSpace(lines[i+1])
|
||||
if strings.HasPrefix(nextLine, "auth") &&
|
||||
(strings.Contains(nextLine, "pam_fprintd") || strings.Contains(nextLine, "pam_u2f")) {
|
||||
i++
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, lines[i])
|
||||
}
|
||||
|
||||
return strings.Join(filtered, "\n"), removed
|
||||
}
|
||||
|
||||
func insertManagedGreeterPamBlock(content string, blockLines []string, greetdPamPath string) (string, error) {
|
||||
lines := strings.Split(content, "\n")
|
||||
for i, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed != "" && !strings.HasPrefix(trimmed, "#") && strings.HasPrefix(trimmed, "auth") {
|
||||
block := strings.Join(blockLines, "\n")
|
||||
prefix := strings.Join(lines[:i], "\n")
|
||||
suffix := strings.Join(lines[i:], "\n")
|
||||
switch {
|
||||
case prefix == "":
|
||||
return block + "\n" + suffix, nil
|
||||
case suffix == "":
|
||||
return prefix + "\n" + block, nil
|
||||
default:
|
||||
return prefix + "\n" + block + "\n" + suffix, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no auth directive found in %s", greetdPamPath)
|
||||
}
|
||||
|
||||
func syncGreeterPamConfigWithDeps(logFunc func(string), sudoPassword string, settings AuthSettings, forceAuth bool, deps syncDeps) error {
|
||||
var wantFprint, wantU2f bool
|
||||
fprintToggleEnabled := forceAuth
|
||||
u2fToggleEnabled := forceAuth
|
||||
if forceAuth {
|
||||
wantFprint = deps.pamModuleExists("pam_fprintd.so")
|
||||
wantU2f = deps.pamModuleExists("pam_u2f.so")
|
||||
} else {
|
||||
fprintToggleEnabled = settings.GreeterEnableFprint
|
||||
u2fToggleEnabled = settings.GreeterEnableU2f
|
||||
fprintModule := deps.pamModuleExists("pam_fprintd.so")
|
||||
u2fModule := deps.pamModuleExists("pam_u2f.so")
|
||||
wantFprint = settings.GreeterEnableFprint && fprintModule
|
||||
wantU2f = settings.GreeterEnableU2f && u2fModule
|
||||
if settings.GreeterEnableFprint && !fprintModule {
|
||||
logFunc("⚠ Warning: greeter fingerprint toggle is enabled, but pam_fprintd.so was not found.")
|
||||
}
|
||||
if settings.GreeterEnableU2f && !u2fModule {
|
||||
logFunc("⚠ Warning: greeter security key toggle is enabled, but pam_u2f.so was not found.")
|
||||
}
|
||||
}
|
||||
|
||||
if deps.isNixOS() {
|
||||
logFunc("ℹ NixOS detected: PAM config is managed by NixOS modules. Skipping DMS PAM block write.")
|
||||
logFunc(" Configure fingerprint/U2F auth via your greetd NixOS module options (e.g. security.pam.services.greetd).")
|
||||
return nil
|
||||
}
|
||||
|
||||
pamData, err := deps.readFile(deps.greetdPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read %s: %w", deps.greetdPath, err)
|
||||
}
|
||||
originalContent := string(pamData)
|
||||
content, _ := stripManagedGreeterPamBlock(originalContent)
|
||||
content, _ = stripLegacyGreeterPamLines(content)
|
||||
|
||||
includedFprintFile := detectIncludedPamModule(content, "pam_fprintd.so", deps)
|
||||
includedU2fFile := detectIncludedPamModule(content, "pam_u2f.so", deps)
|
||||
fprintAvailableForCurrentUser := deps.fingerprintAvailableForCurrentUser()
|
||||
if wantFprint && includedFprintFile != "" {
|
||||
logFunc("⚠ pam_fprintd already present in included " + includedFprintFile + " (managed by authselect/pam-auth-update). Skipping DMS fprint block to avoid double-fingerprint auth.")
|
||||
wantFprint = false
|
||||
}
|
||||
if wantU2f && includedU2fFile != "" {
|
||||
logFunc("⚠ pam_u2f already present in included " + includedU2fFile + " (managed by authselect/pam-auth-update). Skipping DMS U2F block to avoid double security-key auth.")
|
||||
wantU2f = false
|
||||
}
|
||||
if !wantFprint && includedFprintFile != "" {
|
||||
if fprintToggleEnabled {
|
||||
logFunc("ℹ Fingerprint auth is still enabled via included " + includedFprintFile + ".")
|
||||
if fprintAvailableForCurrentUser {
|
||||
logFunc(" DMS toggle is enabled, and effective auth is provided by the included PAM stack.")
|
||||
} else {
|
||||
logFunc(" No enrolled fingerprints detected for the current user; password auth remains the effective path.")
|
||||
}
|
||||
} else {
|
||||
if fprintAvailableForCurrentUser {
|
||||
logFunc("ℹ Fingerprint auth is active via included " + includedFprintFile + " while DMS fingerprint toggle is off.")
|
||||
logFunc(" Password login will work but may be delayed while the fingerprint module runs first.")
|
||||
logFunc(" To eliminate the delay, " + pamManagerHintForCurrentDistro())
|
||||
} else {
|
||||
logFunc("ℹ pam_fprintd is present via included " + includedFprintFile + ", but no enrolled fingerprints were detected for the current user.")
|
||||
logFunc(" Password auth remains the effective login path.")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !wantU2f && includedU2fFile != "" {
|
||||
if u2fToggleEnabled {
|
||||
logFunc("ℹ Security-key auth is still enabled via included " + includedU2fFile + ".")
|
||||
logFunc(" DMS toggle is enabled, but effective auth is provided by the included PAM stack.")
|
||||
} else {
|
||||
logFunc("⚠ Security-key auth is active via included " + includedU2fFile + " while DMS security-key toggle is off.")
|
||||
logFunc(" " + pamManagerHintForCurrentDistro())
|
||||
}
|
||||
}
|
||||
|
||||
if wantFprint || wantU2f {
|
||||
blockLines := []string{GreeterPamManagedBlockStart}
|
||||
if wantFprint {
|
||||
blockLines = append(blockLines, "auth sufficient pam_fprintd.so max-tries=1 timeout=5")
|
||||
}
|
||||
if wantU2f {
|
||||
blockLines = append(blockLines, "auth sufficient pam_u2f.so cue nouserok timeout=10")
|
||||
}
|
||||
blockLines = append(blockLines, GreeterPamManagedBlockEnd)
|
||||
|
||||
content, err = insertManagedGreeterPamBlock(content, blockLines, deps.greetdPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if content == originalContent {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := writeManagedPamFile(content, deps.greetdPath, sudoPassword, deps); err != nil {
|
||||
return fmt.Errorf("failed to install updated PAM config at %s: %w", deps.greetdPath, err)
|
||||
}
|
||||
if wantFprint || wantU2f {
|
||||
logFunc("✓ Configured greetd PAM for fingerprint/U2F")
|
||||
} else {
|
||||
logFunc("✓ Cleared DMS-managed greeter PAM auth block")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeManagedPamFile(content string, destPath string, sudoPassword string, deps syncDeps) error {
|
||||
tmpFile, err := deps.createTemp("", "dms-pam-*.conf")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpPath := tmpFile.Name()
|
||||
defer func() {
|
||||
_ = deps.removeFile(tmpPath)
|
||||
}()
|
||||
|
||||
if _, err := tmpFile.WriteString(content); err != nil {
|
||||
tmpFile.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := deps.runSudoCmd(sudoPassword, "cp", tmpPath, destPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := deps.runSudoCmd(sudoPassword, "chmod", "644", destPath); err != nil {
|
||||
return fmt.Errorf("failed to set permissions on %s: %w", destPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pamManagerHintForCurrentDistro() string {
|
||||
osInfo, err := distros.GetOSInfo()
|
||||
if err != nil {
|
||||
return "Disable it in your PAM manager (authselect/pam-auth-update) or in the included PAM stack to force password-only greeter login."
|
||||
}
|
||||
config, exists := distros.Registry[osInfo.Distribution.ID]
|
||||
if !exists {
|
||||
return "Disable it in your PAM manager (authselect/pam-auth-update) or in the included PAM stack to force password-only greeter login."
|
||||
}
|
||||
|
||||
switch config.Family {
|
||||
case distros.FamilyFedora:
|
||||
return "Disable it in authselect to force password-only greeter login."
|
||||
case distros.FamilyDebian, distros.FamilyUbuntu:
|
||||
return "Disable it in pam-auth-update to force password-only greeter login."
|
||||
default:
|
||||
return "Disable it in your distro PAM manager (authselect/pam-auth-update) or in the included PAM stack to force password-only greeter login."
|
||||
}
|
||||
}
|
||||
|
||||
func pamModuleExists(module string) bool {
|
||||
for _, libDir := range []string{
|
||||
"/usr/lib64/security",
|
||||
"/usr/lib/security",
|
||||
"/lib64/security",
|
||||
"/lib/security",
|
||||
"/lib/x86_64-linux-gnu/security",
|
||||
"/usr/lib/x86_64-linux-gnu/security",
|
||||
"/lib/aarch64-linux-gnu/security",
|
||||
"/usr/lib/aarch64-linux-gnu/security",
|
||||
"/run/current-system/sw/lib64/security",
|
||||
"/run/current-system/sw/lib/security",
|
||||
} {
|
||||
if _, err := os.Stat(filepath.Join(libDir, module)); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasEnrolledFingerprintOutput(output string) bool {
|
||||
lower := strings.ToLower(output)
|
||||
if strings.Contains(lower, "no fingers enrolled") ||
|
||||
strings.Contains(lower, "no fingerprints enrolled") ||
|
||||
strings.Contains(lower, "no prints enrolled") {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(lower, "has fingers enrolled") ||
|
||||
strings.Contains(lower, "has fingerprints enrolled") {
|
||||
return true
|
||||
}
|
||||
for _, line := range strings.Split(lower, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "finger:") {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "- ") && strings.Contains(trimmed, "finger") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func FingerprintAuthAvailableForCurrentUser() bool {
|
||||
username := strings.TrimSpace(os.Getenv("SUDO_USER"))
|
||||
if username == "" {
|
||||
username = strings.TrimSpace(os.Getenv("USER"))
|
||||
}
|
||||
if username == "" {
|
||||
out, err := exec.Command("id", "-un").Output()
|
||||
if err == nil {
|
||||
username = strings.TrimSpace(string(out))
|
||||
}
|
||||
}
|
||||
return fingerprintAuthAvailableForUser(username)
|
||||
}
|
||||
|
||||
func fingerprintAuthAvailableForUser(username string) bool {
|
||||
username = strings.TrimSpace(username)
|
||||
if username == "" {
|
||||
return false
|
||||
}
|
||||
if !pamModuleExists("pam_fprintd.so") {
|
||||
return false
|
||||
}
|
||||
if _, err := exec.LookPath("fprintd-list"); err != nil {
|
||||
return false
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
out, err := exec.CommandContext(ctx, "fprintd-list", username).CombinedOutput()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return hasEnrolledFingerprintOutput(string(out))
|
||||
}
|
||||
@@ -1,671 +0,0 @@
|
||||
package pam
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeTestFile(t *testing.T, path string, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("failed to create parent dir for %s: %v", path, err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("failed to write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
type pamTestEnv struct {
|
||||
pamDir string
|
||||
greetdPath string
|
||||
dankshellPath string
|
||||
dankshellU2fPath string
|
||||
tmpDir string
|
||||
homeDir string
|
||||
availableModules map[string]bool
|
||||
fingerprintAvailable bool
|
||||
}
|
||||
|
||||
func newPamTestEnv(t *testing.T) *pamTestEnv {
|
||||
t.Helper()
|
||||
|
||||
root := t.TempDir()
|
||||
pamDir := filepath.Join(root, "pam.d")
|
||||
tmpDir := filepath.Join(root, "tmp")
|
||||
homeDir := filepath.Join(root, "home")
|
||||
|
||||
for _, dir := range []string{pamDir, tmpDir, homeDir} {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("failed to create %s: %v", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
return &pamTestEnv{
|
||||
pamDir: pamDir,
|
||||
greetdPath: filepath.Join(pamDir, "greetd"),
|
||||
dankshellPath: filepath.Join(pamDir, "dankshell"),
|
||||
dankshellU2fPath: filepath.Join(pamDir, "dankshell-u2f"),
|
||||
tmpDir: tmpDir,
|
||||
homeDir: homeDir,
|
||||
availableModules: map[string]bool{},
|
||||
}
|
||||
}
|
||||
|
||||
func (e *pamTestEnv) writePamFile(t *testing.T, name string, content string) {
|
||||
t.Helper()
|
||||
writeTestFile(t, filepath.Join(e.pamDir, name), content)
|
||||
}
|
||||
|
||||
func (e *pamTestEnv) writeSettings(t *testing.T, content string) {
|
||||
t.Helper()
|
||||
writeTestFile(t, filepath.Join(e.homeDir, ".config", "DankMaterialShell", "settings.json"), content)
|
||||
}
|
||||
|
||||
func (e *pamTestEnv) deps(isNixOS bool) syncDeps {
|
||||
return syncDeps{
|
||||
pamDir: e.pamDir,
|
||||
greetdPath: e.greetdPath,
|
||||
dankshellPath: e.dankshellPath,
|
||||
dankshellU2fPath: e.dankshellU2fPath,
|
||||
isNixOS: func() bool { return isNixOS },
|
||||
readFile: os.ReadFile,
|
||||
stat: os.Stat,
|
||||
createTemp: func(_ string, pattern string) (*os.File, error) {
|
||||
return os.CreateTemp(e.tmpDir, pattern)
|
||||
},
|
||||
removeFile: os.Remove,
|
||||
runSudoCmd: func(_ string, command string, args ...string) error {
|
||||
switch command {
|
||||
case "cp":
|
||||
if len(args) != 2 {
|
||||
return fmt.Errorf("unexpected cp args: %v", args)
|
||||
}
|
||||
data, err := os.ReadFile(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(args[1]), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(args[1], data, 0o644)
|
||||
case "chmod":
|
||||
if len(args) != 2 {
|
||||
return fmt.Errorf("unexpected chmod args: %v", args)
|
||||
}
|
||||
return nil
|
||||
case "rm":
|
||||
if len(args) != 2 || args[0] != "-f" {
|
||||
return fmt.Errorf("unexpected rm args: %v", args)
|
||||
}
|
||||
if err := os.Remove(args[1]); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unexpected sudo command: %s %v", command, args)
|
||||
}
|
||||
},
|
||||
pamModuleExists: func(module string) bool {
|
||||
return e.availableModules[module]
|
||||
},
|
||||
fingerprintAvailableForCurrentUser: func() bool {
|
||||
return e.fingerprintAvailable
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func readFileString(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read %s: %v", path, err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func TestHasManagedLockscreenPamFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "both markers present",
|
||||
content: "#%PAM-1.0\n" +
|
||||
LockscreenPamManagedBlockStart + "\n" +
|
||||
"auth sufficient pam_unix.so\n" +
|
||||
LockscreenPamManagedBlockEnd + "\n",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "missing end marker is not managed",
|
||||
content: "#%PAM-1.0\n" +
|
||||
LockscreenPamManagedBlockStart + "\n" +
|
||||
"auth sufficient pam_unix.so\n",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "custom file is not managed",
|
||||
content: "#%PAM-1.0\nauth sufficient pam_unix.so\n",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := hasManagedLockscreenPamFile(tt.content); got != tt.want {
|
||||
t.Fatalf("hasManagedLockscreenPamFile() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildManagedLockscreenPamContent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
files map[string]string
|
||||
wantContains []string
|
||||
wantNotContains []string
|
||||
wantCounts map[string]int
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "preserves custom modules and strips direct u2f and fprint directives",
|
||||
files: map[string]string{
|
||||
"login": "#%PAM-1.0\n" +
|
||||
"auth include system-auth\n" +
|
||||
"account include system-auth\n" +
|
||||
"session include system-auth\n",
|
||||
"system-auth": "auth requisite pam_nologin.so\n" +
|
||||
"auth sufficient pam_unix.so try_first_pass nullok\n" +
|
||||
"auth sufficient pam_u2f.so cue\n" +
|
||||
"auth sufficient pam_fprintd.so max-tries=1\n" +
|
||||
"auth required pam_radius_auth.so conf=/etc/raddb/server\n" +
|
||||
"account required pam_access.so\n" +
|
||||
"session optional pam_lastlog.so silent\n",
|
||||
},
|
||||
wantContains: []string{
|
||||
"#%PAM-1.0",
|
||||
LockscreenPamManagedBlockStart,
|
||||
LockscreenPamManagedBlockEnd,
|
||||
"auth requisite pam_nologin.so",
|
||||
"auth sufficient pam_unix.so try_first_pass nullok",
|
||||
"auth required pam_radius_auth.so conf=/etc/raddb/server",
|
||||
"account required pam_access.so",
|
||||
"session optional pam_lastlog.so silent",
|
||||
},
|
||||
wantNotContains: []string{
|
||||
"pam_u2f",
|
||||
"pam_fprintd",
|
||||
},
|
||||
wantCounts: map[string]int{
|
||||
"auth required pam_radius_auth.so conf=/etc/raddb/server": 1,
|
||||
"account required pam_access.so": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "resolves nested include substack and @include transitively",
|
||||
files: map[string]string{
|
||||
"login": "#%PAM-1.0\n" +
|
||||
"auth include system-auth\n" +
|
||||
"account include system-auth\n" +
|
||||
"password include system-auth\n" +
|
||||
"session include system-auth\n",
|
||||
"system-auth": "auth substack custom-auth\n" +
|
||||
"account include custom-auth\n" +
|
||||
"password include custom-auth\n" +
|
||||
"session @include common-session\n",
|
||||
"custom-auth": "auth required pam_custom.so one=two\n" +
|
||||
"account required pam_custom_account.so\n" +
|
||||
"password required pam_custom_password.so\n",
|
||||
"common-session": "session optional pam_fprintd.so max-tries=1\n" +
|
||||
"session optional pam_lastlog.so silent\n",
|
||||
},
|
||||
wantContains: []string{
|
||||
"auth required pam_custom.so one=two",
|
||||
"account required pam_custom_account.so",
|
||||
"password required pam_custom_password.so",
|
||||
"session optional pam_lastlog.so silent",
|
||||
},
|
||||
wantNotContains: []string{
|
||||
"pam_fprintd",
|
||||
},
|
||||
wantCounts: map[string]int{
|
||||
"auth required pam_custom.so one=two": 1,
|
||||
"account required pam_custom_account.so": 1,
|
||||
"password required pam_custom_password.so": 1,
|
||||
"session optional pam_lastlog.so silent": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing include fails",
|
||||
files: map[string]string{
|
||||
"login": "#%PAM-1.0\nauth include missing-auth\n",
|
||||
},
|
||||
wantErr: "failed to read PAM file",
|
||||
},
|
||||
{
|
||||
name: "cyclic include fails",
|
||||
files: map[string]string{
|
||||
"login": "#%PAM-1.0\nauth include system-auth\n",
|
||||
"system-auth": "auth include login\n",
|
||||
},
|
||||
wantErr: "cyclic PAM include detected",
|
||||
},
|
||||
{
|
||||
name: "no auth directives remain after filtering fails",
|
||||
files: map[string]string{
|
||||
"login": "#%PAM-1.0\nauth include system-auth\n",
|
||||
"system-auth": "auth sufficient pam_u2f.so cue\n",
|
||||
},
|
||||
wantErr: "no auth directives remained after filtering",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newPamTestEnv(t)
|
||||
for name, content := range tt.files {
|
||||
env.writePamFile(t, name, content)
|
||||
}
|
||||
|
||||
content, err := buildManagedLockscreenPamContent(env.pamDir, os.ReadFile)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("buildManagedLockscreenPamContent returned error: %v", err)
|
||||
}
|
||||
|
||||
for _, want := range tt.wantContains {
|
||||
if !strings.Contains(content, want) {
|
||||
t.Errorf("missing expected string %q in output:\n%s", want, content)
|
||||
}
|
||||
}
|
||||
for _, notWant := range tt.wantNotContains {
|
||||
if strings.Contains(content, notWant) {
|
||||
t.Errorf("unexpected string %q found in output:\n%s", notWant, content)
|
||||
}
|
||||
}
|
||||
for want, wantCount := range tt.wantCounts {
|
||||
if gotCount := strings.Count(content, want); gotCount != wantCount {
|
||||
t.Errorf("count for %q = %d, want %d\noutput:\n%s", want, gotCount, wantCount, content)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncLockscreenPamConfigWithDeps(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("custom dankshell file is skipped untouched", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newPamTestEnv(t)
|
||||
customContent := "#%PAM-1.0\nauth required pam_unix.so\n"
|
||||
env.writePamFile(t, "dankshell", customContent)
|
||||
|
||||
var logs []string
|
||||
err := syncLockscreenPamConfigWithDeps(func(msg string) {
|
||||
logs = append(logs, msg)
|
||||
}, "", env.deps(false))
|
||||
if err != nil {
|
||||
t.Fatalf("syncLockscreenPamConfigWithDeps returned error: %v", err)
|
||||
}
|
||||
|
||||
if got := readFileString(t, env.dankshellPath); got != customContent {
|
||||
t.Fatalf("custom dankshell content changed\ngot:\n%s\nwant:\n%s", got, customContent)
|
||||
}
|
||||
if len(logs) == 0 || !strings.Contains(logs[0], "Custom /etc/pam.d/dankshell found") {
|
||||
t.Fatalf("expected custom-file skip log, got %v", logs)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("managed dankshell file is rewritten from resolved login stack", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newPamTestEnv(t)
|
||||
env.writePamFile(t, "login", "#%PAM-1.0\nauth include system-auth\naccount include system-auth\n")
|
||||
env.writePamFile(t, "system-auth", "auth sufficient pam_unix.so try_first_pass nullok\nauth sufficient pam_u2f.so cue\naccount required pam_access.so\n")
|
||||
env.writePamFile(t, "dankshell", "#%PAM-1.0\n"+LockscreenPamManagedBlockStart+"\nauth required pam_env.so\n"+LockscreenPamManagedBlockEnd+"\n")
|
||||
|
||||
var logs []string
|
||||
err := syncLockscreenPamConfigWithDeps(func(msg string) {
|
||||
logs = append(logs, msg)
|
||||
}, "", env.deps(false))
|
||||
if err != nil {
|
||||
t.Fatalf("syncLockscreenPamConfigWithDeps returned error: %v", err)
|
||||
}
|
||||
|
||||
output := readFileString(t, env.dankshellPath)
|
||||
for _, want := range []string{
|
||||
LockscreenPamManagedBlockStart,
|
||||
"auth sufficient pam_unix.so try_first_pass nullok",
|
||||
"account required pam_access.so",
|
||||
LockscreenPamManagedBlockEnd,
|
||||
} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Errorf("missing expected string %q in rewritten dankshell:\n%s", want, output)
|
||||
}
|
||||
}
|
||||
if strings.Contains(output, "pam_u2f") {
|
||||
t.Errorf("rewritten dankshell still contains pam_u2f:\n%s", output)
|
||||
}
|
||||
if len(logs) == 0 || !strings.Contains(logs[len(logs)-1], "Created or updated /etc/pam.d/dankshell") {
|
||||
t.Fatalf("expected success log, got %v", logs)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("mutable systems fail when login stack cannot be converted safely", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newPamTestEnv(t)
|
||||
err := syncLockscreenPamConfigWithDeps(func(string) {}, "", env.deps(false))
|
||||
if err == nil {
|
||||
t.Fatal("expected error when login PAM file is missing, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to build") {
|
||||
t.Fatalf("error = %q, want substring %q", err.Error(), "failed to build")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("NixOS remains informational and does not write dankshell", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newPamTestEnv(t)
|
||||
var logs []string
|
||||
|
||||
err := syncLockscreenPamConfigWithDeps(func(msg string) {
|
||||
logs = append(logs, msg)
|
||||
}, "", env.deps(true))
|
||||
if err != nil {
|
||||
t.Fatalf("syncLockscreenPamConfigWithDeps returned error on NixOS path: %v", err)
|
||||
}
|
||||
if len(logs) == 0 || !strings.Contains(logs[0], "NixOS detected") || !strings.Contains(logs[0], "/etc/pam.d/login") {
|
||||
t.Fatalf("expected NixOS informational log mentioning /etc/pam.d/login, got %v", logs)
|
||||
}
|
||||
if _, err := os.Stat(env.dankshellPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected no dankshell file to be written on NixOS path, stat err = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSyncLockscreenU2FPamConfigWithDeps(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("enabled creates managed file", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newPamTestEnv(t)
|
||||
var logs []string
|
||||
|
||||
err := syncLockscreenU2FPamConfigWithDeps(func(msg string) {
|
||||
logs = append(logs, msg)
|
||||
}, "", true, env.deps(false))
|
||||
if err != nil {
|
||||
t.Fatalf("syncLockscreenU2FPamConfigWithDeps returned error: %v", err)
|
||||
}
|
||||
|
||||
got := readFileString(t, env.dankshellU2fPath)
|
||||
if got != buildManagedLockscreenU2FPamContent() {
|
||||
t.Fatalf("unexpected managed dankshell-u2f content:\n%s", got)
|
||||
}
|
||||
if len(logs) == 0 || !strings.Contains(logs[len(logs)-1], "Created or updated /etc/pam.d/dankshell-u2f") {
|
||||
t.Fatalf("expected create log, got %v", logs)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("enabled rewrites existing managed file", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newPamTestEnv(t)
|
||||
env.writePamFile(t, "dankshell-u2f", "#%PAM-1.0\n"+LockscreenU2FPamManagedBlockStart+"\nauth required pam_u2f.so old\n"+LockscreenU2FPamManagedBlockEnd+"\n")
|
||||
|
||||
if err := syncLockscreenU2FPamConfigWithDeps(func(string) {}, "", true, env.deps(false)); err != nil {
|
||||
t.Fatalf("syncLockscreenU2FPamConfigWithDeps returned error: %v", err)
|
||||
}
|
||||
if got := readFileString(t, env.dankshellU2fPath); got != buildManagedLockscreenU2FPamContent() {
|
||||
t.Fatalf("managed dankshell-u2f was not rewritten:\n%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("disabled removes DMS-managed file", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newPamTestEnv(t)
|
||||
env.writePamFile(t, "dankshell-u2f", buildManagedLockscreenU2FPamContent())
|
||||
|
||||
var logs []string
|
||||
err := syncLockscreenU2FPamConfigWithDeps(func(msg string) {
|
||||
logs = append(logs, msg)
|
||||
}, "", false, env.deps(false))
|
||||
if err != nil {
|
||||
t.Fatalf("syncLockscreenU2FPamConfigWithDeps returned error: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(env.dankshellU2fPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected managed dankshell-u2f to be removed, stat err = %v", err)
|
||||
}
|
||||
if len(logs) == 0 || !strings.Contains(logs[len(logs)-1], "Removed DMS-managed /etc/pam.d/dankshell-u2f") {
|
||||
t.Fatalf("expected removal log, got %v", logs)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("disabled preserves custom file", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newPamTestEnv(t)
|
||||
customContent := "#%PAM-1.0\nauth required pam_u2f.so cue\n"
|
||||
env.writePamFile(t, "dankshell-u2f", customContent)
|
||||
|
||||
var logs []string
|
||||
err := syncLockscreenU2FPamConfigWithDeps(func(msg string) {
|
||||
logs = append(logs, msg)
|
||||
}, "", false, env.deps(false))
|
||||
if err != nil {
|
||||
t.Fatalf("syncLockscreenU2FPamConfigWithDeps returned error: %v", err)
|
||||
}
|
||||
if got := readFileString(t, env.dankshellU2fPath); got != customContent {
|
||||
t.Fatalf("custom dankshell-u2f content changed\ngot:\n%s\nwant:\n%s", got, customContent)
|
||||
}
|
||||
if len(logs) == 0 || !strings.Contains(logs[0], "Custom /etc/pam.d/dankshell-u2f found") {
|
||||
t.Fatalf("expected custom-file log, got %v", logs)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSyncGreeterPamConfigWithDeps(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("adds managed block for enabled auth modules", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newPamTestEnv(t)
|
||||
env.availableModules["pam_fprintd.so"] = true
|
||||
env.availableModules["pam_u2f.so"] = true
|
||||
env.writePamFile(t, "greetd", "#%PAM-1.0\nauth include system-auth\naccount include system-auth\n")
|
||||
env.writePamFile(t, "system-auth", "auth sufficient pam_unix.so\naccount required pam_unix.so\n")
|
||||
|
||||
settings := AuthSettings{GreeterEnableFprint: true, GreeterEnableU2f: true}
|
||||
if err := syncGreeterPamConfigWithDeps(func(string) {}, "", settings, false, env.deps(false)); err != nil {
|
||||
t.Fatalf("syncGreeterPamConfigWithDeps returned error: %v", err)
|
||||
}
|
||||
|
||||
got := readFileString(t, env.greetdPath)
|
||||
for _, want := range []string{
|
||||
GreeterPamManagedBlockStart,
|
||||
"auth sufficient pam_fprintd.so max-tries=1 timeout=5",
|
||||
"auth sufficient pam_u2f.so cue nouserok timeout=10",
|
||||
GreeterPamManagedBlockEnd,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing expected string %q in greetd PAM:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Index(got, GreeterPamManagedBlockStart) > strings.Index(got, "auth include system-auth") {
|
||||
t.Fatalf("managed block was not inserted before first auth line:\n%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("avoids duplicate fingerprint when included stack already provides it", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newPamTestEnv(t)
|
||||
env.availableModules["pam_fprintd.so"] = true
|
||||
env.fingerprintAvailable = true
|
||||
original := "#%PAM-1.0\nauth include system-auth\naccount include system-auth\n"
|
||||
env.writePamFile(t, "greetd", original)
|
||||
env.writePamFile(t, "system-auth", "auth sufficient pam_fprintd.so max-tries=1\nauth sufficient pam_unix.so\n")
|
||||
|
||||
settings := AuthSettings{GreeterEnableFprint: true}
|
||||
if err := syncGreeterPamConfigWithDeps(func(string) {}, "", settings, false, env.deps(false)); err != nil {
|
||||
t.Fatalf("syncGreeterPamConfigWithDeps returned error: %v", err)
|
||||
}
|
||||
|
||||
got := readFileString(t, env.greetdPath)
|
||||
if got != original {
|
||||
t.Fatalf("greetd PAM changed despite included pam_fprintd stack\ngot:\n%s\nwant:\n%s", got, original)
|
||||
}
|
||||
if strings.Contains(got, GreeterPamManagedBlockStart) {
|
||||
t.Fatalf("managed block should not be inserted when included stack already has pam_fprintd:\n%s", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRemoveManagedGreeterPamBlockWithDeps(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newPamTestEnv(t)
|
||||
env.writePamFile(t, "greetd", "#%PAM-1.0\n"+
|
||||
legacyGreeterPamFprintComment+"\n"+
|
||||
"auth sufficient pam_fprintd.so max-tries=1\n"+
|
||||
GreeterPamManagedBlockStart+"\n"+
|
||||
"auth sufficient pam_u2f.so cue nouserok timeout=10\n"+
|
||||
GreeterPamManagedBlockEnd+"\n"+
|
||||
"auth include system-auth\n")
|
||||
|
||||
if err := removeManagedGreeterPamBlockWithDeps(func(string) {}, "", env.deps(false)); err != nil {
|
||||
t.Fatalf("removeManagedGreeterPamBlockWithDeps returned error: %v", err)
|
||||
}
|
||||
|
||||
got := readFileString(t, env.greetdPath)
|
||||
if strings.Contains(got, GreeterPamManagedBlockStart) || strings.Contains(got, legacyGreeterPamFprintComment) {
|
||||
t.Fatalf("managed or legacy DMS auth lines remained in greetd PAM:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "auth include system-auth") {
|
||||
t.Fatalf("expected non-DMS greetd auth lines to remain:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncAuthConfigWithDeps(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("creates lockscreen targets and skips greetd when greeter is not installed", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newPamTestEnv(t)
|
||||
env.writeSettings(t, `{"enableU2f":true}`)
|
||||
env.writePamFile(t, "login", "#%PAM-1.0\nauth include system-auth\naccount include system-auth\n")
|
||||
env.writePamFile(t, "system-auth", "auth sufficient pam_unix.so try_first_pass nullok\naccount required pam_access.so\n")
|
||||
|
||||
var logs []string
|
||||
err := syncAuthConfigWithDeps(func(msg string) {
|
||||
logs = append(logs, msg)
|
||||
}, "", SyncAuthOptions{HomeDir: env.homeDir}, env.deps(false))
|
||||
if err != nil {
|
||||
t.Fatalf("syncAuthConfigWithDeps returned error: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(env.dankshellPath); err != nil {
|
||||
t.Fatalf("expected dankshell to be created: %v", err)
|
||||
}
|
||||
if got := readFileString(t, env.dankshellU2fPath); got != buildManagedLockscreenU2FPamContent() {
|
||||
t.Fatalf("unexpected dankshell-u2f content:\n%s", got)
|
||||
}
|
||||
if len(logs) == 0 || !strings.Contains(logs[len(logs)-1], "greetd not found") {
|
||||
t.Fatalf("expected greetd skip log, got %v", logs)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("separate greeter and lockscreen toggles are respected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newPamTestEnv(t)
|
||||
env.availableModules["pam_fprintd.so"] = true
|
||||
env.writeSettings(t, `{"enableU2f":false,"greeterEnableFprint":true,"greeterEnableU2f":false}`)
|
||||
env.writePamFile(t, "login", "#%PAM-1.0\nauth include system-auth\naccount include system-auth\n")
|
||||
env.writePamFile(t, "system-auth", "auth sufficient pam_unix.so try_first_pass nullok\naccount required pam_access.so\n")
|
||||
env.writePamFile(t, "greetd", "#%PAM-1.0\nauth include system-auth\naccount include system-auth\n")
|
||||
|
||||
err := syncAuthConfigWithDeps(func(string) {}, "", SyncAuthOptions{HomeDir: env.homeDir}, env.deps(false))
|
||||
if err != nil {
|
||||
t.Fatalf("syncAuthConfigWithDeps returned error: %v", err)
|
||||
}
|
||||
|
||||
dankshell := readFileString(t, env.dankshellPath)
|
||||
if strings.Contains(dankshell, "pam_fprintd") || strings.Contains(dankshell, "pam_u2f") {
|
||||
t.Fatalf("lockscreen PAM should strip fingerprint and U2F modules:\n%s", dankshell)
|
||||
}
|
||||
if _, err := os.Stat(env.dankshellU2fPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected dankshell-u2f to remain absent when enableU2f is false, stat err = %v", err)
|
||||
}
|
||||
|
||||
greetd := readFileString(t, env.greetdPath)
|
||||
if !strings.Contains(greetd, "auth sufficient pam_fprintd.so max-tries=1 timeout=5") {
|
||||
t.Fatalf("expected greetd PAM to receive fingerprint auth block:\n%s", greetd)
|
||||
}
|
||||
if strings.Contains(greetd, "auth sufficient pam_u2f.so cue nouserok timeout=10") {
|
||||
t.Fatalf("did not expect greetd PAM to receive U2F auth block:\n%s", greetd)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("NixOS remains informational and non-mutating", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newPamTestEnv(t)
|
||||
env.availableModules["pam_fprintd.so"] = true
|
||||
env.availableModules["pam_u2f.so"] = true
|
||||
env.writeSettings(t, `{"enableU2f":true,"greeterEnableFprint":true,"greeterEnableU2f":true}`)
|
||||
originalGreetd := "#%PAM-1.0\nauth include system-auth\naccount include system-auth\n"
|
||||
env.writePamFile(t, "greetd", originalGreetd)
|
||||
|
||||
var logs []string
|
||||
err := syncAuthConfigWithDeps(func(msg string) {
|
||||
logs = append(logs, msg)
|
||||
}, "", SyncAuthOptions{HomeDir: env.homeDir}, env.deps(true))
|
||||
if err != nil {
|
||||
t.Fatalf("syncAuthConfigWithDeps returned error: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(env.dankshellPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected dankshell to remain absent on NixOS path, stat err = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(env.dankshellU2fPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected dankshell-u2f to remain absent on NixOS path, stat err = %v", err)
|
||||
}
|
||||
if got := readFileString(t, env.greetdPath); got != originalGreetd {
|
||||
t.Fatalf("expected greetd PAM to remain unchanged on NixOS path\ngot:\n%s\nwant:\n%s", got, originalGreetd)
|
||||
}
|
||||
if len(logs) < 2 || !strings.Contains(strings.Join(logs, "\n"), "NixOS detected") {
|
||||
t.Fatalf("expected informational NixOS logs, got %v", logs)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -9,8 +9,10 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
)
|
||||
|
||||
@@ -103,7 +105,7 @@ func GetOutputDir() string {
|
||||
return dir
|
||||
}
|
||||
|
||||
if xdgPics := utils.XDGPicturesDir(); xdgPics != "" {
|
||||
if xdgPics := getXDGPicturesDir(); xdgPics != "" {
|
||||
screenshotDir := filepath.Join(xdgPics, "Screenshots")
|
||||
if err := os.MkdirAll(screenshotDir, 0o755); err == nil {
|
||||
return screenshotDir
|
||||
@@ -111,12 +113,42 @@ func GetOutputDir() string {
|
||||
return xdgPics
|
||||
}
|
||||
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
if home := os.Getenv("HOME"); home != "" {
|
||||
return home
|
||||
}
|
||||
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 {
|
||||
return WriteToFileWithFormat(buf, path, format, quality, uint32(FormatARGB8888))
|
||||
}
|
||||
|
||||
@@ -64,13 +64,12 @@ func SendNotification(result NotifyResult) {
|
||||
|
||||
summary := "Screenshot captured"
|
||||
body := ""
|
||||
switch {
|
||||
case result.FilePath != "" && result.Clipboard:
|
||||
body = fmt.Sprintf("%s\nCopied to clipboard", filepath.Base(result.FilePath))
|
||||
case result.FilePath != "":
|
||||
body = filepath.Base(result.FilePath)
|
||||
case result.Clipboard:
|
||||
if result.Clipboard && result.FilePath != "" {
|
||||
body = fmt.Sprintf("Copied to clipboard\n%s", filepath.Base(result.FilePath))
|
||||
} else if result.Clipboard {
|
||||
body = "Copied to clipboard"
|
||||
} else if result.FilePath != "" {
|
||||
body = filepath.Base(result.FilePath)
|
||||
}
|
||||
|
||||
obj := conn.Object(notifyDest, notifyPath)
|
||||
|
||||
@@ -113,11 +113,7 @@ func NewRegionSelector(s *Screenshoter) *RegionSelector {
|
||||
}
|
||||
|
||||
func (r *RegionSelector) Run() (*CaptureResult, bool, error) {
|
||||
if r.screenshoter != nil && r.screenshoter.config.Reset {
|
||||
r.preSelect = Region{}
|
||||
} else {
|
||||
r.preSelect = GetLastRegion()
|
||||
}
|
||||
|
||||
if err := r.connect(); err != nil {
|
||||
return nil, false, fmt.Errorf("wayland connect: %w", err)
|
||||
|
||||
@@ -114,9 +114,6 @@ func (r *RegionSelector) setupPointerHandlers() {
|
||||
for _, os := range r.surfaces {
|
||||
r.redrawSurface(os)
|
||||
}
|
||||
if r.screenshoter != nil && r.screenshoter.config.NoConfirm && r.selection.hasSelection {
|
||||
r.finishSelection()
|
||||
}
|
||||
}
|
||||
default:
|
||||
r.cancelled = true
|
||||
|
||||
@@ -138,13 +138,9 @@ func (r *RegionSelector) drawHUD(data []byte, stride, bufW, bufH int, format uin
|
||||
if !r.showCapturedCursor {
|
||||
cursorLabel = "show"
|
||||
}
|
||||
captureKey := "Space/Enter"
|
||||
if r.screenshoter != nil && r.screenshoter.config.NoConfirm {
|
||||
captureKey = "Drag+Release"
|
||||
}
|
||||
|
||||
items := []struct{ key, desc string }{
|
||||
{captureKey, "capture"},
|
||||
{"Space/Enter", "capture"},
|
||||
{"P", cursorLabel + " cursor"},
|
||||
{"Esc", "cancel"},
|
||||
}
|
||||
|
||||
@@ -107,12 +107,6 @@ func (s *Screenshoter) captureLastRegion() (*CaptureResult, error) {
|
||||
}
|
||||
|
||||
func (s *Screenshoter) captureRegion() (*CaptureResult, error) {
|
||||
if s.config.Reset {
|
||||
if err := SaveLastRegion(Region{}); err != nil {
|
||||
log.Debug("failed to reset last region", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
selector := NewRegionSelector(s)
|
||||
result, cancelled, err := selector.Run()
|
||||
if err != nil {
|
||||
|
||||
@@ -52,8 +52,6 @@ type Config struct {
|
||||
Mode Mode
|
||||
OutputName string
|
||||
Cursor CursorMode
|
||||
NoConfirm bool
|
||||
Reset bool
|
||||
Format Format
|
||||
Quality int
|
||||
OutputDir string
|
||||
@@ -68,8 +66,6 @@ func DefaultConfig() Config {
|
||||
return Config{
|
||||
Mode: ModeRegion,
|
||||
Cursor: CursorOff,
|
||||
NoConfirm: false,
|
||||
Reset: false,
|
||||
Format: FormatPNG,
|
||||
Quality: 90,
|
||||
OutputDir: "",
|
||||
|
||||
@@ -6,20 +6,12 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/pilebones/go-udev/netlink"
|
||||
)
|
||||
|
||||
const (
|
||||
udevRecvBufSize = 8 * 1024 * 1024
|
||||
udevMaxRetries = 5
|
||||
udevBaseDelay = 2 * time.Second
|
||||
udevMaxDelay = 60 * time.Second
|
||||
)
|
||||
|
||||
type UdevMonitor struct {
|
||||
stop chan struct{}
|
||||
rescanMutex sync.Mutex
|
||||
@@ -37,6 +29,13 @@ func NewUdevMonitor(manager *Manager) *UdevMonitor {
|
||||
}
|
||||
|
||||
func (m *UdevMonitor) run(manager *Manager) {
|
||||
conn := &netlink.UEventConn{}
|
||||
if err := conn.Connect(netlink.UdevEvent); err != nil {
|
||||
log.Errorf("Failed to connect to udev netlink: %v", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
matcher := &netlink.RuleDefinitions{
|
||||
Rules: []netlink.RuleDefinition{
|
||||
{Env: map[string]string{"SUBSYSTEM": "backlight"}},
|
||||
@@ -49,46 +48,6 @@ func (m *UdevMonitor) run(manager *Manager) {
|
||||
return
|
||||
}
|
||||
|
||||
failures := 0
|
||||
for {
|
||||
if err := m.monitorLoop(manager, matcher); err != nil {
|
||||
log.Errorf("Udev monitor error: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-m.stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
failures++
|
||||
if failures > udevMaxRetries {
|
||||
log.Errorf("Udev monitor exceeded %d retries, giving up", udevMaxRetries)
|
||||
return
|
||||
}
|
||||
|
||||
delay := min(udevBaseDelay*time.Duration(1<<(failures-1)), udevMaxDelay)
|
||||
log.Infof("Udev monitor reconnecting in %v (attempt %d/%d)", delay, failures, udevMaxRetries)
|
||||
|
||||
select {
|
||||
case <-m.stop:
|
||||
return
|
||||
case <-time.After(delay):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *UdevMonitor) monitorLoop(manager *Manager, matcher *netlink.RuleDefinitions) error {
|
||||
conn := &netlink.UEventConn{}
|
||||
if err := conn.Connect(netlink.UdevEvent); err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if err := syscall.SetsockoptInt(conn.Fd, syscall.SOL_SOCKET, syscall.SO_RCVBUF, udevRecvBufSize); err != nil {
|
||||
log.Warnf("Failed to set udev socket receive buffer: %v", err)
|
||||
}
|
||||
|
||||
events := make(chan netlink.UEvent)
|
||||
errs := make(chan error)
|
||||
conn.Monitor(events, errs, matcher)
|
||||
@@ -98,9 +57,10 @@ func (m *UdevMonitor) monitorLoop(manager *Manager, matcher *netlink.RuleDefinit
|
||||
for {
|
||||
select {
|
||||
case <-m.stop:
|
||||
return nil
|
||||
return
|
||||
case err := <-errs:
|
||||
return err
|
||||
log.Errorf("Udev monitor error: %v", err)
|
||||
return
|
||||
case event := <-events:
|
||||
m.handleEvent(manager, event)
|
||||
}
|
||||
|
||||
@@ -2,10 +2,8 @@ package cups
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -277,43 +275,14 @@ func (m *Manager) GetClasses() ([]PrinterClass, error) {
|
||||
return classes, nil
|
||||
}
|
||||
|
||||
func createPrinterViaLpadmin(name, deviceURI, ppd, information, location string) error {
|
||||
args := []string{"-p", name, "-E", "-v", deviceURI, "-m", ppd}
|
||||
if information != "" {
|
||||
args = append(args, "-D", information)
|
||||
}
|
||||
if location != "" {
|
||||
args = append(args, "-L", location)
|
||||
}
|
||||
out, err := exec.Command("lpadmin", args...).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("lpadmin failed: %s: %w", strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func deletePrinterViaLpadmin(name string) error {
|
||||
out, err := exec.Command("lpadmin", "-x", name).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("lpadmin failed: %s: %w", strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) CreatePrinter(name, deviceURI, ppd string, shared bool, errorPolicy, information, location string) error {
|
||||
usedPkHelper := false
|
||||
|
||||
err := m.client.CreatePrinter(name, deviceURI, ppd, shared, errorPolicy, information, location)
|
||||
if isAuthError(err) && m.pkHelper != nil {
|
||||
if err = m.pkHelper.PrinterAdd(name, deviceURI, ppd, information, location); err != nil {
|
||||
// pkHelper failed (e.g., no polkit agent), try lpadmin as last resort.
|
||||
// lpadmin -E enables the printer, so no further setup needed.
|
||||
if lpadminErr := createPrinterViaLpadmin(name, deviceURI, ppd, information, location); lpadminErr != nil {
|
||||
return err
|
||||
}
|
||||
m.RefreshState()
|
||||
return nil
|
||||
}
|
||||
usedPkHelper = true
|
||||
} else if err != nil {
|
||||
return err
|
||||
@@ -339,12 +308,6 @@ func (m *Manager) DeletePrinter(printerName string) error {
|
||||
err := m.client.DeletePrinter(printerName)
|
||||
if isAuthError(err) && m.pkHelper != nil {
|
||||
err = m.pkHelper.PrinterDelete(printerName)
|
||||
if err != nil {
|
||||
// pkHelper failed, try lpadmin as last resort
|
||||
if lpadminErr := deletePrinterViaLpadmin(printerName); lpadminErr == nil {
|
||||
err = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
m.RefreshState()
|
||||
|
||||
@@ -70,8 +70,6 @@ func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
|
||||
handleRestartJob(conn, req, manager)
|
||||
case "cups.holdJob":
|
||||
handleHoldJob(conn, req, manager)
|
||||
case "cups.testConnection":
|
||||
handleTestConnection(conn, req, manager)
|
||||
default:
|
||||
models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
|
||||
}
|
||||
@@ -466,22 +464,3 @@ func handleHoldJob(conn net.Conn, req models.Request, manager *Manager) {
|
||||
}
|
||||
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "job held"})
|
||||
}
|
||||
|
||||
func handleTestConnection(conn net.Conn, req models.Request, manager *Manager) {
|
||||
host, err := params.StringNonEmpty(req.Params, "host")
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
port := params.IntOpt(req.Params, "port", 631)
|
||||
protocol := params.StringOpt(req.Params, "protocol", "ipp")
|
||||
|
||||
result, err := manager.TestRemotePrinter(host, port, protocol)
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
models.Respond(conn, req.ID, result)
|
||||
}
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
package cups
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/pkg/ipp"
|
||||
)
|
||||
|
||||
var validProtocols = map[string]bool{
|
||||
"ipp": true,
|
||||
"ipps": true,
|
||||
"lpd": true,
|
||||
"socket": true,
|
||||
}
|
||||
|
||||
func validateTestConnectionParams(host string, port int, protocol string) error {
|
||||
if host == "" {
|
||||
return errors.New("host is required")
|
||||
}
|
||||
if strings.ContainsAny(host, " \t\n\r/\\") {
|
||||
return errors.New("host contains invalid characters")
|
||||
}
|
||||
if port < 1 || port > 65535 {
|
||||
return errors.New("port must be between 1 and 65535")
|
||||
}
|
||||
if protocol != "" && !validProtocols[protocol] {
|
||||
return errors.New("protocol must be one of: ipp, ipps, lpd, socket")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const probeTimeout = 10 * time.Second
|
||||
|
||||
func probeRemotePrinter(host string, port int, useTLS bool) (*RemotePrinterInfo, error) {
|
||||
addr := net.JoinHostPort(host, fmt.Sprintf("%d", port))
|
||||
|
||||
// Fast fail: TCP reachability check
|
||||
conn, err := net.DialTimeout("tcp", addr, probeTimeout)
|
||||
if err != nil {
|
||||
return &RemotePrinterInfo{
|
||||
Reachable: false,
|
||||
Error: fmt.Sprintf("cannot reach %s: %s", addr, err.Error()),
|
||||
}, nil
|
||||
}
|
||||
conn.Close()
|
||||
|
||||
// Create a temporary IPP client pointing at the remote host.
|
||||
// The TCP dial above provides fast-fail for unreachable hosts.
|
||||
// The IPP adapter's ResponseHeaderTimeout (90s) bounds stalling servers.
|
||||
client := ipp.NewIPPClient(host, port, "", "", useTLS)
|
||||
|
||||
// Try /ipp/print first (modern driverless printers), then / (legacy)
|
||||
info, err := probeIPPEndpoint(client, host, port, useTLS, "/ipp/print")
|
||||
if err != nil {
|
||||
// If we got an auth error, the printer exists but requires credentials.
|
||||
// Report it as reachable with the URI that triggered the auth challenge.
|
||||
if isAuthError(err) {
|
||||
proto := "ipp"
|
||||
if useTLS {
|
||||
proto = "ipps"
|
||||
}
|
||||
return &RemotePrinterInfo{
|
||||
Reachable: true,
|
||||
URI: fmt.Sprintf("%s://%s:%d/ipp/print", proto, host, port),
|
||||
Info: "authentication required",
|
||||
}, nil
|
||||
}
|
||||
info, err = probeIPPEndpoint(client, host, port, useTLS, "/")
|
||||
}
|
||||
if err != nil {
|
||||
if isAuthError(err) {
|
||||
proto := "ipp"
|
||||
if useTLS {
|
||||
proto = "ipps"
|
||||
}
|
||||
return &RemotePrinterInfo{
|
||||
Reachable: true,
|
||||
URI: fmt.Sprintf("%s://%s:%d/", proto, host, port),
|
||||
Info: "authentication required",
|
||||
}, nil
|
||||
}
|
||||
// TCP reachable but not an IPP printer
|
||||
return &RemotePrinterInfo{
|
||||
Reachable: true,
|
||||
Error: fmt.Sprintf("host is reachable but does not appear to be an IPP printer: %s", err.Error()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func probeIPPEndpoint(client *ipp.IPPClient, host string, port int, useTLS bool, resourcePath string) (*RemotePrinterInfo, error) {
|
||||
proto := "ipp"
|
||||
if useTLS {
|
||||
proto = "ipps"
|
||||
}
|
||||
printerURI := fmt.Sprintf("%s://%s:%d%s", proto, host, port, resourcePath)
|
||||
|
||||
httpProto := "http"
|
||||
if useTLS {
|
||||
httpProto = "https"
|
||||
}
|
||||
httpURL := fmt.Sprintf("%s://%s:%d%s", httpProto, host, port, resourcePath)
|
||||
|
||||
req := ipp.NewRequest(ipp.OperationGetPrinterAttributes, 1)
|
||||
req.OperationAttributes[ipp.AttributePrinterURI] = printerURI
|
||||
req.OperationAttributes[ipp.AttributeRequestedAttributes] = []string{
|
||||
ipp.AttributePrinterName,
|
||||
ipp.AttributePrinterMakeAndModel,
|
||||
ipp.AttributePrinterState,
|
||||
ipp.AttributePrinterInfo,
|
||||
ipp.AttributePrinterUriSupported,
|
||||
}
|
||||
|
||||
resp, err := client.SendRequest(httpURL, req, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(resp.PrinterAttributes) == 0 {
|
||||
return nil, errors.New("no printer attributes returned")
|
||||
}
|
||||
|
||||
attrs := resp.PrinterAttributes[0]
|
||||
|
||||
return &RemotePrinterInfo{
|
||||
Reachable: true,
|
||||
MakeModel: getStringAttr(attrs, ipp.AttributePrinterMakeAndModel),
|
||||
Name: getStringAttr(attrs, ipp.AttributePrinterName),
|
||||
Info: getStringAttr(attrs, ipp.AttributePrinterInfo),
|
||||
State: parsePrinterState(attrs),
|
||||
URI: printerURI,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// TestRemotePrinter validates inputs and probes a remote printer via IPP.
|
||||
// For lpd/socket protocols, only TCP reachability is tested.
|
||||
func (m *Manager) TestRemotePrinter(host string, port int, protocol string) (*RemotePrinterInfo, error) {
|
||||
if protocol == "" {
|
||||
protocol = "ipp"
|
||||
}
|
||||
|
||||
if err := validateTestConnectionParams(host, port, protocol); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// For non-IPP protocols, only check TCP reachability
|
||||
if protocol == "lpd" || protocol == "socket" {
|
||||
addr := net.JoinHostPort(host, fmt.Sprintf("%d", port))
|
||||
conn, err := net.DialTimeout("tcp", addr, probeTimeout)
|
||||
if err != nil {
|
||||
return &RemotePrinterInfo{
|
||||
Reachable: false,
|
||||
Error: fmt.Sprintf("cannot reach %s: %s", addr, err.Error()),
|
||||
}, nil
|
||||
}
|
||||
conn.Close()
|
||||
return &RemotePrinterInfo{
|
||||
Reachable: true,
|
||||
URI: fmt.Sprintf("%s://%s:%d", protocol, host, port),
|
||||
}, nil
|
||||
}
|
||||
|
||||
useTLS := protocol == "ipps"
|
||||
|
||||
probeFn := m.probeRemoteFn
|
||||
if probeFn == nil {
|
||||
probeFn = probeRemotePrinter
|
||||
}
|
||||
|
||||
return probeFn(host, port, useTLS)
|
||||
}
|
||||
@@ -1,397 +0,0 @@
|
||||
package cups
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/pkg/ipp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestValidateTestConnectionParams(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
host string
|
||||
port int
|
||||
protocol string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "valid ipp",
|
||||
host: "192.168.0.5",
|
||||
port: 631,
|
||||
protocol: "ipp",
|
||||
wantErr: "",
|
||||
},
|
||||
{
|
||||
name: "valid ipps",
|
||||
host: "printer.local",
|
||||
port: 443,
|
||||
protocol: "ipps",
|
||||
wantErr: "",
|
||||
},
|
||||
{
|
||||
name: "valid lpd",
|
||||
host: "10.0.0.1",
|
||||
port: 515,
|
||||
protocol: "lpd",
|
||||
wantErr: "",
|
||||
},
|
||||
{
|
||||
name: "valid socket",
|
||||
host: "10.0.0.1",
|
||||
port: 9100,
|
||||
protocol: "socket",
|
||||
wantErr: "",
|
||||
},
|
||||
{
|
||||
name: "empty host",
|
||||
host: "",
|
||||
port: 631,
|
||||
protocol: "ipp",
|
||||
wantErr: "host is required",
|
||||
},
|
||||
{
|
||||
name: "port too low",
|
||||
host: "192.168.0.5",
|
||||
port: 0,
|
||||
protocol: "ipp",
|
||||
wantErr: "port must be between 1 and 65535",
|
||||
},
|
||||
{
|
||||
name: "port too high",
|
||||
host: "192.168.0.5",
|
||||
port: 70000,
|
||||
protocol: "ipp",
|
||||
wantErr: "port must be between 1 and 65535",
|
||||
},
|
||||
{
|
||||
name: "invalid protocol",
|
||||
host: "192.168.0.5",
|
||||
port: 631,
|
||||
protocol: "ftp",
|
||||
wantErr: "protocol must be one of: ipp, ipps, lpd, socket",
|
||||
},
|
||||
{
|
||||
name: "empty protocol treated as ipp",
|
||||
host: "192.168.0.5",
|
||||
port: 631,
|
||||
protocol: "",
|
||||
wantErr: "",
|
||||
},
|
||||
{
|
||||
name: "host with slash",
|
||||
host: "192.168.0.5/admin",
|
||||
port: 631,
|
||||
protocol: "ipp",
|
||||
wantErr: "host contains invalid characters",
|
||||
},
|
||||
{
|
||||
name: "host with space",
|
||||
host: "192.168.0.5 ",
|
||||
port: 631,
|
||||
protocol: "ipp",
|
||||
wantErr: "host contains invalid characters",
|
||||
},
|
||||
{
|
||||
name: "host with newline",
|
||||
host: "192.168.0.5\n",
|
||||
port: 631,
|
||||
protocol: "ipp",
|
||||
wantErr: "host contains invalid characters",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateTestConnectionParams(tt.host, tt.port, tt.protocol)
|
||||
if tt.wantErr == "" {
|
||||
assert.NoError(t, err)
|
||||
} else {
|
||||
assert.EqualError(t, err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_TestRemotePrinter_Validation(t *testing.T) {
|
||||
m := NewTestManager(nil, nil)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
host string
|
||||
port int
|
||||
protocol string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "empty host returns error",
|
||||
host: "",
|
||||
port: 631,
|
||||
protocol: "ipp",
|
||||
wantErr: "host is required",
|
||||
},
|
||||
{
|
||||
name: "invalid port returns error",
|
||||
host: "192.168.0.5",
|
||||
port: 0,
|
||||
protocol: "ipp",
|
||||
wantErr: "port must be between 1 and 65535",
|
||||
},
|
||||
{
|
||||
name: "invalid protocol returns error",
|
||||
host: "192.168.0.5",
|
||||
port: 631,
|
||||
protocol: "ftp",
|
||||
wantErr: "protocol must be one of: ipp, ipps, lpd, socket",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := m.TestRemotePrinter(tt.host, tt.port, tt.protocol)
|
||||
assert.EqualError(t, err, tt.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_TestRemotePrinter_IPP(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
protocol string
|
||||
probeRet *RemotePrinterInfo
|
||||
probeErr error
|
||||
wantTLS bool
|
||||
wantReach bool
|
||||
wantModel string
|
||||
}{
|
||||
{
|
||||
name: "successful ipp probe",
|
||||
protocol: "ipp",
|
||||
probeRet: &RemotePrinterInfo{
|
||||
Reachable: true,
|
||||
MakeModel: "HP OfficeJet 8010",
|
||||
Name: "OfficeJet",
|
||||
State: "idle",
|
||||
URI: "ipp://192.168.0.5:631/ipp/print",
|
||||
},
|
||||
wantTLS: false,
|
||||
wantReach: true,
|
||||
wantModel: "HP OfficeJet 8010",
|
||||
},
|
||||
{
|
||||
name: "successful ipps probe",
|
||||
protocol: "ipps",
|
||||
probeRet: &RemotePrinterInfo{
|
||||
Reachable: true,
|
||||
MakeModel: "HP OfficeJet 8010",
|
||||
URI: "ipps://192.168.0.5:631/ipp/print",
|
||||
},
|
||||
wantTLS: true,
|
||||
wantReach: true,
|
||||
wantModel: "HP OfficeJet 8010",
|
||||
},
|
||||
{
|
||||
name: "unreachable host",
|
||||
protocol: "ipp",
|
||||
probeRet: &RemotePrinterInfo{
|
||||
Reachable: false,
|
||||
Error: "cannot reach 192.168.0.5:631: connection refused",
|
||||
},
|
||||
wantReach: false,
|
||||
},
|
||||
{
|
||||
name: "empty protocol defaults to ipp",
|
||||
protocol: "",
|
||||
probeRet: &RemotePrinterInfo{
|
||||
Reachable: true,
|
||||
MakeModel: "Test Printer",
|
||||
},
|
||||
wantTLS: false,
|
||||
wantReach: true,
|
||||
wantModel: "Test Printer",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var capturedTLS bool
|
||||
m := NewTestManager(nil, nil)
|
||||
m.probeRemoteFn = func(host string, port int, useTLS bool) (*RemotePrinterInfo, error) {
|
||||
capturedTLS = useTLS
|
||||
return tt.probeRet, tt.probeErr
|
||||
}
|
||||
|
||||
result, err := m.TestRemotePrinter("192.168.0.5", 631, tt.protocol)
|
||||
if tt.probeErr != nil {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.wantReach, result.Reachable)
|
||||
assert.Equal(t, tt.wantModel, result.MakeModel)
|
||||
assert.Equal(t, tt.wantTLS, capturedTLS)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_TestRemotePrinter_AuthRequired(t *testing.T) {
|
||||
m := NewTestManager(nil, nil)
|
||||
m.probeRemoteFn = func(host string, port int, useTLS bool) (*RemotePrinterInfo, error) {
|
||||
// Simulate what happens when the printer returns HTTP 401
|
||||
return probeRemotePrinterWithAuthError(host, port, useTLS)
|
||||
}
|
||||
|
||||
result, err := m.TestRemotePrinter("192.168.0.107", 631, "ipp")
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, result.Reachable)
|
||||
assert.Equal(t, "authentication required", result.Info)
|
||||
assert.Contains(t, result.URI, "ipp://192.168.0.107:631")
|
||||
}
|
||||
|
||||
// probeRemotePrinterWithAuthError simulates a probe where the printer
|
||||
// returns HTTP 401 on both endpoints.
|
||||
func probeRemotePrinterWithAuthError(host string, port int, useTLS bool) (*RemotePrinterInfo, error) {
|
||||
// This simulates what probeRemotePrinter does when both endpoints
|
||||
// return auth errors. We test the auth detection logic directly.
|
||||
err := ipp.HTTPError{Code: 401}
|
||||
if isAuthError(err) {
|
||||
proto := "ipp"
|
||||
if useTLS {
|
||||
proto = "ipps"
|
||||
}
|
||||
return &RemotePrinterInfo{
|
||||
Reachable: true,
|
||||
URI: fmt.Sprintf("%s://%s:%d/ipp/print", proto, host, port),
|
||||
Info: "authentication required",
|
||||
}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func TestManager_TestRemotePrinter_NonIPPProtocol(t *testing.T) {
|
||||
m := NewTestManager(nil, nil)
|
||||
probeCalled := false
|
||||
m.probeRemoteFn = func(host string, port int, useTLS bool) (*RemotePrinterInfo, error) {
|
||||
probeCalled = true
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// These will fail at TCP dial (no real server), but the important
|
||||
// thing is that probeRemoteFn is NOT called for lpd/socket.
|
||||
m.TestRemotePrinter("192.168.0.5", 9100, "socket")
|
||||
assert.False(t, probeCalled, "probe function should not be called for socket protocol")
|
||||
|
||||
m.TestRemotePrinter("192.168.0.5", 515, "lpd")
|
||||
assert.False(t, probeCalled, "probe function should not be called for lpd protocol")
|
||||
}
|
||||
|
||||
func TestHandleTestConnection_Success(t *testing.T) {
|
||||
m := NewTestManager(nil, nil)
|
||||
m.probeRemoteFn = func(host string, port int, useTLS bool) (*RemotePrinterInfo, error) {
|
||||
return &RemotePrinterInfo{
|
||||
Reachable: true,
|
||||
MakeModel: "HP OfficeJet 8010",
|
||||
Name: "OfficeJet",
|
||||
State: "idle",
|
||||
URI: "ipp://192.168.0.5:631/ipp/print",
|
||||
}, nil
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
conn := &mockConn{Buffer: buf}
|
||||
|
||||
req := models.Request{
|
||||
ID: 1,
|
||||
Method: "cups.testConnection",
|
||||
Params: map[string]any{
|
||||
"host": "192.168.0.5",
|
||||
},
|
||||
}
|
||||
|
||||
handleTestConnection(conn, req, m)
|
||||
|
||||
var resp models.Response[RemotePrinterInfo]
|
||||
err := json.NewDecoder(buf).Decode(&resp)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, resp.Result)
|
||||
assert.True(t, resp.Result.Reachable)
|
||||
assert.Equal(t, "HP OfficeJet 8010", resp.Result.MakeModel)
|
||||
}
|
||||
|
||||
func TestHandleTestConnection_MissingHost(t *testing.T) {
|
||||
m := NewTestManager(nil, nil)
|
||||
buf := &bytes.Buffer{}
|
||||
conn := &mockConn{Buffer: buf}
|
||||
|
||||
req := models.Request{
|
||||
ID: 1,
|
||||
Method: "cups.testConnection",
|
||||
Params: map[string]any{},
|
||||
}
|
||||
|
||||
handleTestConnection(conn, req, m)
|
||||
|
||||
var resp models.Response[any]
|
||||
err := json.NewDecoder(buf).Decode(&resp)
|
||||
assert.NoError(t, err)
|
||||
assert.Nil(t, resp.Result)
|
||||
assert.NotNil(t, resp.Error)
|
||||
}
|
||||
|
||||
func TestHandleTestConnection_CustomPortAndProtocol(t *testing.T) {
|
||||
m := NewTestManager(nil, nil)
|
||||
m.probeRemoteFn = func(host string, port int, useTLS bool) (*RemotePrinterInfo, error) {
|
||||
assert.Equal(t, 9631, port)
|
||||
assert.True(t, useTLS)
|
||||
return &RemotePrinterInfo{Reachable: true, URI: "ipps://192.168.0.5:9631/ipp/print"}, nil
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
conn := &mockConn{Buffer: buf}
|
||||
|
||||
req := models.Request{
|
||||
ID: 1,
|
||||
Method: "cups.testConnection",
|
||||
Params: map[string]any{
|
||||
"host": "192.168.0.5",
|
||||
"port": float64(9631),
|
||||
"protocol": "ipps",
|
||||
},
|
||||
}
|
||||
|
||||
handleTestConnection(conn, req, m)
|
||||
|
||||
var resp models.Response[RemotePrinterInfo]
|
||||
err := json.NewDecoder(buf).Decode(&resp)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, resp.Result)
|
||||
assert.True(t, resp.Result.Reachable)
|
||||
}
|
||||
|
||||
func TestHandleRequest_TestConnection(t *testing.T) {
|
||||
m := NewTestManager(nil, nil)
|
||||
m.probeRemoteFn = func(host string, port int, useTLS bool) (*RemotePrinterInfo, error) {
|
||||
return &RemotePrinterInfo{Reachable: true}, nil
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
conn := &mockConn{Buffer: buf}
|
||||
|
||||
req := models.Request{
|
||||
ID: 1,
|
||||
Method: "cups.testConnection",
|
||||
Params: map[string]any{"host": "192.168.0.5"},
|
||||
}
|
||||
|
||||
HandleRequest(conn, req, m)
|
||||
|
||||
var resp models.Response[RemotePrinterInfo]
|
||||
err := json.NewDecoder(buf).Decode(&resp)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, resp.Result)
|
||||
assert.True(t, resp.Result.Reachable)
|
||||
}
|
||||
@@ -55,16 +55,6 @@ type PPD struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type RemotePrinterInfo struct {
|
||||
Reachable bool `json:"reachable"`
|
||||
MakeModel string `json:"makeModel"`
|
||||
Name string `json:"name"`
|
||||
Info string `json:"info"`
|
||||
State string `json:"state"`
|
||||
URI string `json:"uri"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type PrinterClass struct {
|
||||
Name string `json:"name"`
|
||||
URI string `json:"uri"`
|
||||
@@ -87,7 +77,6 @@ type Manager struct {
|
||||
notifierWg sync.WaitGroup
|
||||
lastNotifiedState *CUPSState
|
||||
baseURL string
|
||||
probeRemoteFn func(host string, port int, useTLS bool) (*RemotePrinterInfo, error)
|
||||
}
|
||||
|
||||
type SubscriptionManagerInterface interface {
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
package location
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||
)
|
||||
|
||||
type LocationEvent struct {
|
||||
Type string `json:"type"`
|
||||
Data State `json:"data"`
|
||||
}
|
||||
|
||||
func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
|
||||
switch req.Method {
|
||||
case "location.getState":
|
||||
handleGetState(conn, req, manager)
|
||||
case "location.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) {
|
||||
models.Respond(conn, req.ID, manager.GetState())
|
||||
}
|
||||
|
||||
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()
|
||||
event := LocationEvent{
|
||||
Type: "state_changed",
|
||||
Data: initialState,
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(conn).Encode(models.Response[LocationEvent]{
|
||||
ID: req.ID,
|
||||
Result: &event,
|
||||
}); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for state := range stateChan {
|
||||
event := LocationEvent{
|
||||
Type: "state_changed",
|
||||
Data: state,
|
||||
}
|
||||
if err := json.NewEncoder(conn).Encode(models.Response[LocationEvent]{
|
||||
Result: &event,
|
||||
}); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
package location
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
)
|
||||
|
||||
func NewManager(client geolocation.Client) (*Manager, error) {
|
||||
currLocation, err := client.GetLocation()
|
||||
if err != nil {
|
||||
log.Warnf("Failed to get initial location: %v", err)
|
||||
}
|
||||
|
||||
m := &Manager{
|
||||
client: client,
|
||||
dirty: make(chan struct{}),
|
||||
stopChan: make(chan struct{}),
|
||||
|
||||
state: &State{
|
||||
Latitude: currLocation.Latitude,
|
||||
Longitude: currLocation.Longitude,
|
||||
},
|
||||
}
|
||||
|
||||
if err := m.startSignalPump(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m.notifierWg.Add(1)
|
||||
go m.notifier()
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Close() {
|
||||
close(m.stopChan)
|
||||
m.notifierWg.Wait()
|
||||
|
||||
m.sigWG.Wait()
|
||||
|
||||
m.subscribers.Range(func(key string, ch chan State) bool {
|
||||
close(ch)
|
||||
m.subscribers.Delete(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
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) startSignalPump() error {
|
||||
m.sigWG.Add(1)
|
||||
go func() {
|
||||
defer m.sigWG.Done()
|
||||
|
||||
subscription := m.client.Subscribe("locationManager")
|
||||
defer m.client.Unsubscribe("locationManager")
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-m.stopChan:
|
||||
return
|
||||
case location, ok := <-subscription:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
m.handleLocationChange(location)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) handleLocationChange(location geolocation.Location) {
|
||||
m.stateMutex.Lock()
|
||||
defer m.stateMutex.Unlock()
|
||||
|
||||
m.state.Latitude = location.Latitude
|
||||
m.state.Longitude = location.Longitude
|
||||
|
||||
m.notifySubscribers()
|
||||
}
|
||||
|
||||
func (m *Manager) notifySubscribers() {
|
||||
select {
|
||||
case m.dirty <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) GetState() State {
|
||||
m.stateMutex.RLock()
|
||||
defer m.stateMutex.RUnlock()
|
||||
if m.state == nil {
|
||||
return State{
|
||||
Latitude: 0.0,
|
||||
Longitude: 0.0,
|
||||
}
|
||||
}
|
||||
stateCopy := *m.state
|
||||
return stateCopy
|
||||
}
|
||||
|
||||
func (m *Manager) notifier() {
|
||||
defer m.notifierWg.Done()
|
||||
const minGap = 200 * 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("Location: subscriber channel full, dropping update")
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
stateCopy := currentState
|
||||
m.lastNotified = &stateCopy
|
||||
pending = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stateChanged(old, new *State) bool {
|
||||
if old == nil || new == nil {
|
||||
return true
|
||||
}
|
||||
if old.Latitude != new.Latitude {
|
||||
return true
|
||||
}
|
||||
if old.Longitude != new.Longitude {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package location
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
|
||||
)
|
||||
|
||||
type State struct {
|
||||
Latitude float64 `json:"latitude"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
state *State
|
||||
stateMutex sync.RWMutex
|
||||
|
||||
client geolocation.Client
|
||||
|
||||
stopChan chan struct{}
|
||||
sigWG sync.WaitGroup
|
||||
|
||||
subscribers syncmap.Map[string, chan State]
|
||||
dirty chan struct{}
|
||||
notifierWg sync.WaitGroup
|
||||
lastNotified *State
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user