mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-06-10 05:03:28 -04:00
Compare commits
186 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aed731efb0 | |||
| cf0632c077 | |||
| e92da4a15f | |||
| 8abdff3220 | |||
| 584d57a8de | |||
| afb5e59c29 | |||
| d9525908f1 | |||
| 6093c37b41 | |||
| bb05cbb6c5 | |||
| 4d4af8f549 | |||
| 0b55fbcb15 | |||
| 7476a220b5 | |||
| aaff1ab61e | |||
| 39622eb62a | |||
| eea039f575 | |||
| ef5de19f6b | |||
| f0c31bd7b3 | |||
| 7ddd0ca90d | |||
| b84e5abc4a | |||
| fb9ec8e721 | |||
| 078c9b4890 | |||
| 37c98220a9 | |||
| fc07611b3b | |||
| a923308c09 | |||
| 0990b43a43 | |||
| 548c2305fb | |||
| 4634763840 | |||
| cdc1102092 | |||
| 4845299cc2 | |||
| 81a1bb1cd7 | |||
| 4528552610 | |||
| 0b55bf5dac | |||
| 8dd891f93a | |||
| 9bd68d44a1 | |||
| 90ea136379 | |||
| 2f4a39f9eb | |||
| 5e558660c3 | |||
| c923c43322 | |||
| 9f2ae6241e | |||
| 05c7a77c8b | |||
| a8ab0b55f3 | |||
| fbcc28785a | |||
| 1c1ab1c7d5 | |||
| 0a892a4a9e | |||
| e5cd9caba1 | |||
| 9c4aa06664 | |||
| 66e38c5efe | |||
| 018795125e | |||
| be4ea71756 | |||
| 39b90bb140 | |||
| fb5198fd0b | |||
| 71438530a8 | |||
| 79fe956058 | |||
| a33c7e0250 | |||
| 459ec47310 | |||
| 4bb3dd8310 | |||
| 9a630fad92 | |||
| 5bde54fa89 | |||
| 022f47a580 | |||
| 0dfa95ffe4 | |||
| e6da762870 | |||
| 8f958658dc | |||
| 392eaea5fc | |||
| 0aea542e8f | |||
| 7a566e1088 | |||
| 4bc62cc6ec | |||
| 9b68fc8213 | |||
| c878ffb7f9 | |||
| 3fc805ba53 | |||
| 371a7d0cd1 | |||
| 189b7c84ce | |||
| b8f4c350a8 | |||
| 3989c7f801 | |||
| 2690305724 | |||
| 676219bc09 | |||
| b192b5f779 | |||
| a5352623fd | |||
| 2b6ae58bff | |||
| b12511481d | |||
| c7d44cfb12 | |||
| 4193cf51ff | |||
| 1ec0311086 | |||
| c6a1473d2f | |||
| ee16047e15 | |||
| 4968b80268 | |||
| e28b0c695e | |||
| 7f6486b3e7 | |||
| faa30c4d48 | |||
| cf641b4e08 | |||
| b75453c7d6 | |||
| 10872b5fc8 | |||
| 80c853f16c | |||
| 6167f22837 | |||
| d8835f2bc6 | |||
| 1c01774fde | |||
| 0d3eb774e2 | |||
| 7fb4b6e0d9 | |||
| 5df2b5fc33 | |||
| d49c49cd99 | |||
| b209827f38 | |||
| 1b9d1c667c | |||
| 04d961ad0b | |||
| e60caf8028 | |||
| 1e67927f8a | |||
| e6e343dacb | |||
| f87ad3d2ca | |||
| a6ba4b1c79 | |||
| 7cf718cd50 | |||
| d223a74740 | |||
| 408beb202c | |||
| cfe6e6867e | |||
| 7c991bc4e3 | |||
| 50f0cbb122 | |||
| 7ee0879103 | |||
| 56ef186ce8 | |||
| 5b507136e3 | |||
| 19c561da14 | |||
| cc47703d48 | |||
| 31e60a3df5 | |||
| 082de6f1f0 | |||
| fd24b4a36d | |||
| dd668469d7 | |||
| 434490e100 | |||
| d2f6cb3ae4 | |||
| c1cbd0994f | |||
| c81645bacb | |||
| cdc4ca7e1f | |||
| 7d92842ff2 | |||
| d8bf3bdfe8 | |||
| 23ed795e85 | |||
| 2877c63c97 | |||
| 86096db26b | |||
| f76724f7cd | |||
| 3b96c6ab22 | |||
| 1467f5dba9 | |||
| baaa30c94e | |||
| 24a3cd5a3d | |||
| 65151dbfd7 | |||
| 7bd9574868 | |||
| a4cfdf4a59 | |||
| fd651dc943 | |||
| 919b09fc96 | |||
| aeb3fdd637 | |||
| dc5636bed5 | |||
| 36a7692da7 | |||
| c9b38023d5 | |||
| 536e654b5e | |||
| e805f6b5ac | |||
| 94f4b6d4a9 | |||
| 28f68ac702 | |||
| 441ec42ee0 | |||
| 5415444e15 | |||
| bd5276b40d | |||
| dd3f17f51e | |||
| a459b7d1b4 | |||
| 0f71c29776 | |||
| 4a32739d3f | |||
| 1abb221024 | |||
| b2668a2ffc | |||
| f4c11bc2ff | |||
| 97fa86d8f0 | |||
| b87c36d29e | |||
| c6ed64b24e | |||
| cf382c0322 | |||
| 9139fd2fb1 | |||
| da3df9bb77 | |||
| e7834c981a | |||
| 316428b14a | |||
| 6a9de8b423 | |||
| f1e3452307 | |||
| 4c2c193766 | |||
| 112f2165f3 | |||
| 40e3a22b99 | |||
| 7ced91ede1 | |||
| c6e8067a22 | |||
| d7fb75f7f9 | |||
| cf0fa7da6b | |||
| 787d213722 | |||
| 2138fbf8b7 | |||
| 722b3fd1e8 | |||
| 2728296cbd | |||
| fe1fd92953 | |||
| 0ab9b1e4e9 | |||
| 6d0953de68 | |||
| bc6bbdbe9d | |||
| eff728fdf5 |
@@ -0,0 +1,104 @@
|
|||||||
|
# Agent Skills
|
||||||
|
|
||||||
|
This directory contains agent skills following the [Agent Skills](https://agentskills.io) open standard - a portable, version-controlled format for giving AI agents specialized capabilities.
|
||||||
|
|
||||||
|
Each skill is a directory with a `SKILL.md` entrypoint, optional reference docs, scripts, and templates. Agents load skills progressively: metadata at startup, full instructions on activation, and supporting files on demand.
|
||||||
|
|
||||||
|
## Available Skills
|
||||||
|
|
||||||
|
| Skill | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| [dms-plugin-dev](dms-plugin-dev/) | Develop plugins for DankMaterialShell - covers all 4 plugin types (widget, daemon, launcher, desktop), manifest creation, QML components, settings UI, data persistence, theme integration, and PopoutService usage. |
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
The `.agents/skills/` directory at the project root is the standard location defined by the agentskills.io spec. Many agents discover skills from this path automatically. Some agents use their own directory conventions and need a symlink or copy.
|
||||||
|
|
||||||
|
### Claude Code
|
||||||
|
|
||||||
|
Claude Code discovers skills from `.claude/skills/` (project-level) or `~/.claude/skills/` (personal). To make skills from `.agents/skills/` available, symlink them into the Claude Code skills directory:
|
||||||
|
|
||||||
|
**Project-level** (this repo only):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p .claude/skills
|
||||||
|
ln -s ../../.agents/skills/dms-plugin-dev .claude/skills/dms-plugin-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
**Personal** (all your projects):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ln -s /path/to/DankMaterialShell/.agents/skills/dms-plugin-dev ~/.claude/skills/dms-plugin-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
After linking, the skill appears in Claude Code's `/` menu as `/dms-plugin-dev`, and Claude loads it automatically when you ask about DMS plugin development.
|
||||||
|
|
||||||
|
See the [Claude Code skills docs](https://code.claude.com/docs/en/skills) for more on skill configuration, invocation control, and frontmatter options.
|
||||||
|
|
||||||
|
### Cursor
|
||||||
|
|
||||||
|
Cursor discovers skills from `.cursor/skills/` in the project root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p .cursor/skills
|
||||||
|
ln -s ../../.agents/skills/dms-plugin-dev .cursor/skills/dms-plugin-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
See [Cursor skills docs](https://cursor.com/docs/context/skills) for details.
|
||||||
|
|
||||||
|
### VS Code (Copilot)
|
||||||
|
|
||||||
|
VS Code Copilot discovers skills from `.github/skills/` or `.vscode/skills/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p .github/skills
|
||||||
|
ln -s ../../.agents/skills/dms-plugin-dev .github/skills/dms-plugin-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
See [VS Code skills docs](https://code.visualstudio.com/docs/copilot/customization/agent-skills) for details.
|
||||||
|
|
||||||
|
### Gemini CLI
|
||||||
|
|
||||||
|
Gemini CLI discovers skills from `.gemini/skills/` in the project root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p .gemini/skills
|
||||||
|
ln -s ../../.agents/skills/dms-plugin-dev .gemini/skills/dms-plugin-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
See [Gemini CLI skills docs](https://geminicli.com/docs/cli/skills/) for details.
|
||||||
|
|
||||||
|
### OpenAI Codex
|
||||||
|
|
||||||
|
Codex discovers skills from `.codex/skills/` in the project root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p .codex/skills
|
||||||
|
ln -s ../../.agents/skills/dms-plugin-dev .codex/skills/dms-plugin-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
See [Codex skills docs](https://developers.openai.com/codex/skills/) for details.
|
||||||
|
|
||||||
|
### Other Agents
|
||||||
|
|
||||||
|
The Agent Skills standard is supported by 30+ tools including Goose, Roo Code, JetBrains Junie, Amp, OpenCode, OpenHands, Kiro, and more. Most discover skills from a dot-directory at the project root (e.g., `.goose/skills/`, `.roo/skills/`). Some read `.agents/skills/` directly.
|
||||||
|
|
||||||
|
Check the [Agent Skills client showcase](https://agentskills.io/clients) for setup instructions specific to your agent.
|
||||||
|
|
||||||
|
The general pattern is:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p .<agent>/skills
|
||||||
|
ln -s ../../.agents/skills/dms-plugin-dev .<agent>/skills/dms-plugin-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adding New Skills
|
||||||
|
|
||||||
|
To add a new skill to this directory:
|
||||||
|
|
||||||
|
1. Create a subdirectory named with lowercase letters, numbers, and hyphens (e.g., `my-new-skill/`)
|
||||||
|
2. Add a `SKILL.md` file with YAML frontmatter (`name`, `description`) and markdown instructions
|
||||||
|
3. Optionally add `references/`, `scripts/`, and `assets/` subdirectories
|
||||||
|
4. Keep `SKILL.md` under 500 lines - move detailed content to reference files
|
||||||
|
|
||||||
|
See the [Agent Skills specification](https://agentskills.io/specification) for the full format.
|
||||||
@@ -0,0 +1,497 @@
|
|||||||
|
---
|
||||||
|
name: dms-plugin-dev
|
||||||
|
description: >
|
||||||
|
Develop plugins for DankMaterialShell (DMS), a QML-based Linux desktop shell built on
|
||||||
|
Quickshell. Supports four plugin types: widget (bar + Control Center), daemon (background
|
||||||
|
service), launcher (search + actions), and desktop (draggable desktop widgets). Covers
|
||||||
|
manifest creation, QML component development, settings UI, data persistence, theme
|
||||||
|
integration, PopoutService usage, and external command execution. Use when the user wants
|
||||||
|
to create, modify, or debug a DMS plugin, or asks about the DMS plugin API.
|
||||||
|
compatibility: Designed for Claude Code (or similar products)
|
||||||
|
metadata:
|
||||||
|
author: DankMaterialShell
|
||||||
|
version: "1.0"
|
||||||
|
domain: qml-desktop-development
|
||||||
|
framework: DankMaterialShell
|
||||||
|
languages: qml, javascript
|
||||||
|
allowed-tools: Bash Read Write Edit
|
||||||
|
---
|
||||||
|
|
||||||
|
# DankMaterialShell Plugin Development
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
DMS plugins extend the desktop shell with custom widgets, background services, launcher
|
||||||
|
integrations, and desktop widgets. Plugins are QML components discovered from
|
||||||
|
`~/.config/DankMaterialShell/plugins/`.
|
||||||
|
|
||||||
|
**Minimum plugin structure:**
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.config/DankMaterialShell/plugins/YourPlugin/
|
||||||
|
plugin.json # Required: manifest with metadata
|
||||||
|
YourComponent.qml # Required: main QML component
|
||||||
|
YourSettings.qml # Optional: settings UI
|
||||||
|
*.js # Optional: JavaScript utilities
|
||||||
|
```
|
||||||
|
|
||||||
|
**Plugin registry:** Community plugins are available at https://plugins.danklinux.com/
|
||||||
|
|
||||||
|
**Four plugin types:**
|
||||||
|
|
||||||
|
| Type | Purpose | Base Component | Bar pills | CC integration |
|
||||||
|
|------------|--------------------------------|----------------------------|-----------|----------------|
|
||||||
|
| `widget` | Bar widget + popout | `PluginComponent` | Yes | Yes |
|
||||||
|
| `daemon` | Background service | `PluginComponent` (no UI) | No | Optional |
|
||||||
|
| `launcher` | Searchable items in launcher | `Item` | No | No |
|
||||||
|
| `desktop` | Draggable desktop widget | `DesktopPluginComponent` | No | No |
|
||||||
|
|
||||||
|
## Step 1: Determine Plugin Type
|
||||||
|
|
||||||
|
Choose the type based on what the plugin does:
|
||||||
|
|
||||||
|
- **Shows in the bar?** - Use `widget`. Displays a pill in DankBar, optionally opens a popout,
|
||||||
|
optionally integrates with Control Center.
|
||||||
|
- **Runs in background only?** - Use `daemon`. No visible UI, reacts to events (wallpaper
|
||||||
|
changes, notifications, battery level, etc.).
|
||||||
|
- **Provides searchable/actionable items?** - Use `launcher`. Items appear in the DMS launcher
|
||||||
|
with trigger-based filtering (e.g., type `=` for calculator, `:` for emoji).
|
||||||
|
- **Shows on the desktop background?** - Use `desktop`. Draggable, resizable widget on the
|
||||||
|
desktop layer.
|
||||||
|
|
||||||
|
## Step 2: Create the Manifest
|
||||||
|
|
||||||
|
Create `plugin.json` in your plugin directory. See [plugin-manifest-reference.md](references/plugin-manifest-reference.md) for the full schema.
|
||||||
|
|
||||||
|
**Minimal manifest:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "yourPlugin",
|
||||||
|
"name": "Your Plugin Name",
|
||||||
|
"description": "Brief description of what your plugin does",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "Your Name",
|
||||||
|
"type": "widget",
|
||||||
|
"capabilities": ["your-capability"],
|
||||||
|
"component": "./YourWidget.qml"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**With settings and permissions:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "yourPlugin",
|
||||||
|
"name": "Your Plugin Name",
|
||||||
|
"description": "Brief description",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "Your Name",
|
||||||
|
"type": "widget",
|
||||||
|
"capabilities": ["your-capability"],
|
||||||
|
"component": "./YourWidget.qml",
|
||||||
|
"icon": "extension",
|
||||||
|
"settings": "./Settings.qml",
|
||||||
|
"requires_dms": ">=0.1.0",
|
||||||
|
"permissions": ["settings_read", "settings_write"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key rules:**
|
||||||
|
- `id` must be camelCase, matching pattern `^[a-zA-Z][a-zA-Z0-9]*$`
|
||||||
|
- `version` must be semver (e.g., `1.0.0`)
|
||||||
|
- `component` must start with `./` and end with `.qml`
|
||||||
|
- `type: "launcher"` requires a `trigger` field
|
||||||
|
- `settings_write` permission is **required** if the plugin has a settings component
|
||||||
|
|
||||||
|
## Step 3: Create the Main Component
|
||||||
|
|
||||||
|
### Widget
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import QtQuick
|
||||||
|
import qs.Common
|
||||||
|
import qs.Widgets
|
||||||
|
import qs.Modules.Plugins
|
||||||
|
|
||||||
|
PluginComponent {
|
||||||
|
property var popoutService: null
|
||||||
|
|
||||||
|
horizontalBarPill: Component {
|
||||||
|
StyledRect {
|
||||||
|
width: label.implicitWidth + Theme.spacingM * 2
|
||||||
|
height: parent.widgetThickness
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: Theme.surfaceContainerHigh
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
id: label
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: "Hello"
|
||||||
|
color: Theme.surfaceText
|
||||||
|
font.pixelSize: Theme.fontSizeMedium
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
verticalBarPill: Component {
|
||||||
|
StyledRect {
|
||||||
|
width: parent.widgetThickness
|
||||||
|
height: label.implicitHeight + Theme.spacingM * 2
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: Theme.surfaceContainerHigh
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
id: label
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: "Hi"
|
||||||
|
color: Theme.surfaceText
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
rotation: 90
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
See [widget-plugin-guide.md](references/widget-plugin-guide.md) for popouts, CC integration, and advanced features.
|
||||||
|
|
||||||
|
### Launcher
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import QtQuick
|
||||||
|
import qs.Services
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property var pluginService: null
|
||||||
|
property string trigger: "#"
|
||||||
|
|
||||||
|
signal itemsChanged()
|
||||||
|
|
||||||
|
function getItems(query) {
|
||||||
|
const items = [
|
||||||
|
{ name: "Item One", icon: "material:star", comment: "Description",
|
||||||
|
action: "toast:Hello!", categories: ["MyPlugin"] }
|
||||||
|
]
|
||||||
|
if (!query) return items
|
||||||
|
const q = query.toLowerCase()
|
||||||
|
return items.filter(i => i.name.toLowerCase().includes(q))
|
||||||
|
}
|
||||||
|
|
||||||
|
function executeItem(item) {
|
||||||
|
const [type, ...rest] = item.action.split(":")
|
||||||
|
const data = rest.join(":")
|
||||||
|
if (type === "toast") ToastService?.showInfo(data)
|
||||||
|
else if (type === "copy") Quickshell.execDetached(["dms", "cl", "copy", data])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
See [launcher-plugin-guide.md](references/launcher-plugin-guide.md) for triggers, icon types, context menus, and image tiles.
|
||||||
|
|
||||||
|
### Desktop
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import QtQuick
|
||||||
|
import qs.Common
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property var pluginService: null
|
||||||
|
property string pluginId: ""
|
||||||
|
property bool editMode: false
|
||||||
|
property real widgetWidth: 200
|
||||||
|
property real widgetHeight: 200
|
||||||
|
property real minWidth: 150
|
||||||
|
property real minHeight: 150
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: Theme.surfaceContainer
|
||||||
|
opacity: 0.85
|
||||||
|
border.color: root.editMode ? Theme.primary : "transparent"
|
||||||
|
border.width: root.editMode ? 2 : 0
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: "Desktop Widget"
|
||||||
|
color: Theme.surfaceText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
See [desktop-plugin-guide.md](references/desktop-plugin-guide.md) for sizing, persistence, and edit mode.
|
||||||
|
|
||||||
|
### Daemon
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import QtQuick
|
||||||
|
import qs.Common
|
||||||
|
import qs.Services
|
||||||
|
import qs.Modules.Plugins
|
||||||
|
|
||||||
|
PluginComponent {
|
||||||
|
property var popoutService: null
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: SessionData
|
||||||
|
function onSomeSignal() {
|
||||||
|
console.log("Event received")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
See [daemon-plugin-guide.md](references/daemon-plugin-guide.md) for event-driven patterns and process execution.
|
||||||
|
|
||||||
|
## Step 4: Add Settings (Optional)
|
||||||
|
|
||||||
|
Wrap settings in `PluginSettings` with your `pluginId`. All settings auto-save and auto-load.
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import QtQuick
|
||||||
|
import qs.Common
|
||||||
|
import qs.Widgets
|
||||||
|
import qs.Modules.Plugins
|
||||||
|
|
||||||
|
PluginSettings {
|
||||||
|
pluginId: "yourPlugin"
|
||||||
|
|
||||||
|
StringSetting {
|
||||||
|
settingKey: "apiKey"
|
||||||
|
label: "API Key"
|
||||||
|
description: "Your API key"
|
||||||
|
placeholder: "sk-..."
|
||||||
|
}
|
||||||
|
|
||||||
|
ToggleSetting {
|
||||||
|
settingKey: "enabled"
|
||||||
|
label: "Enable Feature"
|
||||||
|
defaultValue: true
|
||||||
|
}
|
||||||
|
|
||||||
|
SelectionSetting {
|
||||||
|
settingKey: "interval"
|
||||||
|
label: "Refresh Interval"
|
||||||
|
options: [
|
||||||
|
{ label: "1 min", value: "60" },
|
||||||
|
{ label: "5 min", value: "300" }
|
||||||
|
]
|
||||||
|
defaultValue: "300"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Available setting components:** StringSetting, ToggleSetting, SelectionSetting, SliderSetting, ColorSetting, ListSetting, ListSettingWithInput.
|
||||||
|
|
||||||
|
See [settings-components-reference.md](references/settings-components-reference.md) for full property lists.
|
||||||
|
|
||||||
|
**Important:** Your plugin must declare `"permissions": ["settings_write"]` in plugin.json, or the settings UI will show an error.
|
||||||
|
|
||||||
|
## Step 5: Use Data Persistence
|
||||||
|
|
||||||
|
Three tiers of persistence:
|
||||||
|
|
||||||
|
| API | Persisted | Use case |
|
||||||
|
|-----|-----------|----------|
|
||||||
|
| `pluginService.savePluginData(id, key, val)` / `loadPluginData(id, key, default)` | Yes (settings.json) | User preferences, config |
|
||||||
|
| `pluginService.savePluginState(id, key, val)` / `loadPluginState(id, key, default)` | Yes (separate state file) | Runtime state, history, cache |
|
||||||
|
| `PluginGlobalVar { varName; defaultValue; value; set() }` | No (runtime only) | Cross-instance shared state |
|
||||||
|
|
||||||
|
- `pluginData` is a reactive property on PluginComponent, auto-loaded from settings
|
||||||
|
- React to settings changes with `Connections { target: pluginService; function onPluginDataChanged(id) { ... } }`
|
||||||
|
- Global vars sync across all instances (multi-monitor, multiple bar sections)
|
||||||
|
|
||||||
|
See [data-persistence-guide.md](references/data-persistence-guide.md) for details and examples.
|
||||||
|
|
||||||
|
## Step 6: Theme Integration
|
||||||
|
|
||||||
|
Always use `Theme.*` properties from `qs.Common` - never hardcode colors or sizes.
|
||||||
|
|
||||||
|
**Essential properties:**
|
||||||
|
- Colors: `Theme.surfaceContainerHigh`, `Theme.surfaceText`, `Theme.primary`, `Theme.onPrimary`
|
||||||
|
- Fonts: `Theme.fontSizeSmall` (12), `Theme.fontSizeMedium` (14), `Theme.fontSizeLarge` (16), `Theme.fontSizeXLarge` (20)
|
||||||
|
- Spacing: `Theme.spacingXS`, `Theme.spacingS`, `Theme.spacingM`, `Theme.spacingL`, `Theme.spacingXL`
|
||||||
|
- Radius: `Theme.cornerRadius`, `Theme.cornerRadiusSmall`, `Theme.cornerRadiusLarge`
|
||||||
|
- Icons: `Theme.iconSizeSmall` (16), `Theme.iconSize` (24), `Theme.iconSizeLarge` (32)
|
||||||
|
|
||||||
|
**Common widgets from `qs.Widgets`:** `StyledText`, `StyledRect`, `DankIcon`, `DankButton`, `DankToggle`, `DankTextField`, `DankSlider`, `DankGridView`, `CachingImage`.
|
||||||
|
|
||||||
|
See [theme-reference.md](references/theme-reference.md) for the complete property list.
|
||||||
|
|
||||||
|
## Step 7: Add Popout Content (Widgets Only)
|
||||||
|
|
||||||
|
Add a popout that opens when the bar pill is clicked:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
PluginComponent {
|
||||||
|
popoutWidth: 400
|
||||||
|
popoutHeight: 300
|
||||||
|
|
||||||
|
popoutContent: Component {
|
||||||
|
PopoutComponent {
|
||||||
|
headerText: "My Plugin"
|
||||||
|
detailsText: "Optional subtitle"
|
||||||
|
showCloseButton: true
|
||||||
|
|
||||||
|
Column {
|
||||||
|
width: parent.width
|
||||||
|
spacing: Theme.spacingM
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
text: "Content here"
|
||||||
|
color: Theme.surfaceText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
horizontalBarPill: Component { /* ... */ }
|
||||||
|
verticalBarPill: Component { /* ... */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**PopoutComponent properties:** `headerText`, `detailsText`, `showCloseButton`, `closePopout()` (auto-injected), `headerHeight` (readonly), `detailsHeight` (readonly).
|
||||||
|
|
||||||
|
Calculate available content height: `popoutHeight - headerHeight - detailsHeight - spacing`
|
||||||
|
|
||||||
|
## Step 8: Control Center Integration (Widgets Only)
|
||||||
|
|
||||||
|
Add your widget to the Control Center grid:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
PluginComponent {
|
||||||
|
ccWidgetIcon: "toggle_on"
|
||||||
|
ccWidgetPrimaryText: "My Feature"
|
||||||
|
ccWidgetSecondaryText: isActive ? "On" : "Off"
|
||||||
|
ccWidgetIsActive: isActive
|
||||||
|
|
||||||
|
onCcWidgetToggled: {
|
||||||
|
isActive = !isActive
|
||||||
|
pluginService?.savePluginData(pluginId, "active", isActive)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: expandable detail panel (for CompoundPill)
|
||||||
|
ccDetailContent: Component {
|
||||||
|
Rectangle {
|
||||||
|
implicitHeight: 200
|
||||||
|
color: Theme.surfaceContainerHigh
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**CC sizing:** 25% width = SmallToggleButton (icon only), 50% width = ToggleButton or CompoundPill (if ccDetailContent is defined).
|
||||||
|
|
||||||
|
## Step 9: External Commands and Clipboard
|
||||||
|
|
||||||
|
**Run commands and capture output:**
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import qs.Common
|
||||||
|
|
||||||
|
Proc.runCommand(
|
||||||
|
"myPlugin.fetch",
|
||||||
|
["curl", "-s", "https://api.example.com/data"],
|
||||||
|
(stdout, exitCode) => {
|
||||||
|
if (exitCode === 0) processData(stdout)
|
||||||
|
},
|
||||||
|
500 // debounce ms
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fire-and-forget (clipboard, notifications):**
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import Quickshell
|
||||||
|
|
||||||
|
Quickshell.execDetached(["dms", "cl", "copy", textToCopy])
|
||||||
|
```
|
||||||
|
|
||||||
|
**Long-running processes:** Use the `Process` QML component from `Quickshell.Io` with `StdioCollector`.
|
||||||
|
|
||||||
|
**Shell commands with pipes:** `["sh", "-c", "ps aux | grep foo"]`
|
||||||
|
|
||||||
|
**Do NOT use** `globalThis.clipboard` or browser JavaScript APIs - they don't exist in the QML runtime.
|
||||||
|
|
||||||
|
## Step 10: Validate and Test
|
||||||
|
|
||||||
|
1. Validate `plugin.json` against the schema at [assets/plugin-schema.json](assets/plugin-schema.json)
|
||||||
|
2. Run the shell with verbose output: `qs -v -p $CONFIGPATH/quickshell/dms/shell.qml`
|
||||||
|
3. Open Settings > Plugins > Scan for Plugins
|
||||||
|
4. Enable your plugin and add it to the DankBar layout
|
||||||
|
|
||||||
|
**Common issues:**
|
||||||
|
- Plugin not detected: check plugin.json syntax with `jq . plugin.json`
|
||||||
|
- Widget not showing: ensure it's enabled AND added to a DankBar section
|
||||||
|
- Settings error: verify `settings_write` permission is declared
|
||||||
|
- Data not persisting: check pluginService injection and permissions
|
||||||
|
|
||||||
|
## Common Mistakes
|
||||||
|
|
||||||
|
1. **Missing `settings_write` permission** - Settings UI shows error without it
|
||||||
|
2. **Missing `property var popoutService: null`** - Must declare for injection to work
|
||||||
|
3. **Missing vertical bar pill** - Widget disappears when bar is on left/right edge
|
||||||
|
4. **Hardcoded colors** - Use `Theme.*` properties, not hex values
|
||||||
|
5. **Using `globalThis.clipboard`** - Does not exist; use `Quickshell.execDetached(["dms", "cl", "copy", text])`
|
||||||
|
6. **Wrong Theme property names** - `Theme.fontSizeS` does not exist, use `Theme.fontSizeSmall`
|
||||||
|
7. **Wrong import for Quickshell** - Use `import Quickshell` (not `import QtQuick` for execDetached)
|
||||||
|
8. **Forgetting `categories` in launcher items** - Items won't display without it
|
||||||
|
9. **Not handling null pluginService** - Always use optional chaining or null checks
|
||||||
|
10. **Using `PluginComponent` for launchers** - Launchers use plain `Item`, not `PluginComponent`
|
||||||
|
|
||||||
|
## Quick Reference: Imports
|
||||||
|
|
||||||
|
**Widget / Daemon:**
|
||||||
|
```qml
|
||||||
|
import QtQuick
|
||||||
|
import qs.Common
|
||||||
|
import qs.Widgets
|
||||||
|
import qs.Modules.Plugins
|
||||||
|
```
|
||||||
|
|
||||||
|
**Launcher:**
|
||||||
|
```qml
|
||||||
|
import QtQuick
|
||||||
|
import qs.Services
|
||||||
|
```
|
||||||
|
|
||||||
|
**Desktop:**
|
||||||
|
```qml
|
||||||
|
import QtQuick
|
||||||
|
import qs.Common
|
||||||
|
```
|
||||||
|
|
||||||
|
**For clipboard/exec:** `import Quickshell`
|
||||||
|
**For processes:** `import Quickshell.Io`
|
||||||
|
**For networking:** `import Quickshell.Networking`
|
||||||
|
**For toast notifications:** access `ToastService` from `qs.Services`
|
||||||
|
|
||||||
|
## Quick Reference: File Naming
|
||||||
|
|
||||||
|
- **Directory name:** PascalCase (e.g., `MyAwesomePlugin/`)
|
||||||
|
- **Plugin ID:** camelCase (e.g., `myAwesomePlugin`)
|
||||||
|
- **QML files:** PascalCase (e.g., `MyWidget.qml`, `Settings.qml`)
|
||||||
|
- **Component paths in manifest:** relative with `./` prefix (e.g., `"./MyWidget.qml"`)
|
||||||
|
- **JS utility files:** camelCase (e.g., `utils.js`, `apiAdapter.js`)
|
||||||
|
|
||||||
|
## Reference Files
|
||||||
|
|
||||||
|
Load these on demand for detailed API documentation:
|
||||||
|
|
||||||
|
- [plugin-manifest-reference.md](references/plugin-manifest-reference.md) - Complete plugin.json field reference and JSON schema
|
||||||
|
- [widget-plugin-guide.md](references/widget-plugin-guide.md) - PluginComponent, bar pills, popouts, click actions, CC integration
|
||||||
|
- [launcher-plugin-guide.md](references/launcher-plugin-guide.md) - getItems/executeItem, triggers, icon types, context menus, tile view
|
||||||
|
- [desktop-plugin-guide.md](references/desktop-plugin-guide.md) - DesktopPluginComponent, sizing, edit mode, position persistence
|
||||||
|
- [daemon-plugin-guide.md](references/daemon-plugin-guide.md) - Event-driven background services, process execution
|
||||||
|
- [settings-components-reference.md](references/settings-components-reference.md) - All 7 setting components with complete property lists
|
||||||
|
- [theme-reference.md](references/theme-reference.md) - Theme colors, spacing, fonts, radii, common patterns
|
||||||
|
- [data-persistence-guide.md](references/data-persistence-guide.md) - pluginData, state API, global variables
|
||||||
|
- [popout-service-reference.md](references/popout-service-reference.md) - PopoutService API for controlling shell popouts and modals
|
||||||
|
- [advanced-patterns.md](references/advanced-patterns.md) - Variants, JS utilities, qmldir, IPC, multi-file plugins
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
{
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"$id": "https://danklinux.com/schemas/plugin.json",
|
||||||
|
"title": "DankMaterialShell Plugin Manifest",
|
||||||
|
"description": "Schema for DankMaterialShell plugin.json manifest files",
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"version",
|
||||||
|
"author",
|
||||||
|
"type",
|
||||||
|
"capabilities",
|
||||||
|
"component"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Unique plugin identifier (camelCase, no spaces)",
|
||||||
|
"pattern": "^[a-zA-Z][a-zA-Z0-9]*$"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Human-readable plugin name",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Short description of plugin functionality",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Semantic version string (e.g., '1.0.0')",
|
||||||
|
"pattern": "^\\d+\\.\\d+\\.\\d+(-[a-zA-Z0-9.-]+)?(\\+[a-zA-Z0-9.-]+)?$"
|
||||||
|
},
|
||||||
|
"author": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Plugin creator name or email",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Plugin type",
|
||||||
|
"enum": ["widget", "daemon", "launcher", "desktop"]
|
||||||
|
},
|
||||||
|
"capabilities": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Array of plugin capabilities",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"minItems": 1
|
||||||
|
},
|
||||||
|
"component": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Relative path to main QML component file",
|
||||||
|
"pattern": "^\\./.*\\.qml$"
|
||||||
|
},
|
||||||
|
"trigger": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Trigger string for launcher activation (required for launcher type)"
|
||||||
|
},
|
||||||
|
"icon": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Material Design icon name"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Path to settings component QML file",
|
||||||
|
"pattern": "^\\./.*\\.qml$"
|
||||||
|
},
|
||||||
|
"requires_dms": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Minimum DMS version requirement (e.g., '>=0.1.18', '>0.1.0')",
|
||||||
|
"pattern": "^(>=?|<=?|=|>|<)\\d+\\.\\d+\\.\\d+$"
|
||||||
|
},
|
||||||
|
"requires": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Array of required system tools/dependencies",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"permissions": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Required capabilities",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"settings_read",
|
||||||
|
"settings_write",
|
||||||
|
"process",
|
||||||
|
"network"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"if": {
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"const": "launcher"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
|
"required": ["trigger"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import QtQuick
|
||||||
|
import qs.Common
|
||||||
|
import qs.Services
|
||||||
|
import qs.Modules.Plugins
|
||||||
|
|
||||||
|
PluginComponent {
|
||||||
|
id: root
|
||||||
|
property var popoutService: null
|
||||||
|
|
||||||
|
// TODO: Read configuration from settings
|
||||||
|
property string configValue: pluginData?.configValue || ""
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: pluginService
|
||||||
|
function onPluginDataChanged(changedId) {
|
||||||
|
if (changedId !== pluginId) return
|
||||||
|
configValue = pluginService.loadPluginData(pluginId, "configValue", "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Connect to the service events you need
|
||||||
|
// Connections {
|
||||||
|
// target: SessionData
|
||||||
|
// function onWallpaperPathChanged() {
|
||||||
|
// console.log("[MyDaemon] Wallpaper changed:", SessionData.wallpaperPath)
|
||||||
|
// handleEvent(SessionData.wallpaperPath)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
function handleEvent(data) {
|
||||||
|
Proc.runCommand(
|
||||||
|
"myDaemon.handle",
|
||||||
|
["echo", "Event received:", data],
|
||||||
|
(stdout, exitCode) => {
|
||||||
|
if (exitCode === 0) {
|
||||||
|
console.log("[MyDaemon] Output:", stdout)
|
||||||
|
} else {
|
||||||
|
console.error("[MyDaemon] Failed:", exitCode)
|
||||||
|
ToastService?.showInfo("Daemon action failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: {
|
||||||
|
console.log("[MyDaemon] Started")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import QtQuick
|
||||||
|
import qs.Common
|
||||||
|
import qs.Widgets
|
||||||
|
import qs.Modules.Plugins
|
||||||
|
|
||||||
|
PluginSettings {
|
||||||
|
pluginId: "myDaemon"
|
||||||
|
|
||||||
|
StringSetting {
|
||||||
|
settingKey: "configValue"
|
||||||
|
label: "Configuration"
|
||||||
|
description: "Value used by the daemon"
|
||||||
|
placeholder: "Enter value..."
|
||||||
|
defaultValue: ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"id": "myDaemon",
|
||||||
|
"name": "My Daemon",
|
||||||
|
"description": "A background service that reacts to events",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "Your Name",
|
||||||
|
"type": "daemon",
|
||||||
|
"capabilities": ["background-service"],
|
||||||
|
"component": "./Daemon.qml",
|
||||||
|
"icon": "settings",
|
||||||
|
"settings": "./Settings.qml",
|
||||||
|
"permissions": ["settings_read", "settings_write", "process"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import QtQuick
|
||||||
|
import qs.Common
|
||||||
|
import qs.Widgets
|
||||||
|
import qs.Modules.Plugins
|
||||||
|
|
||||||
|
PluginSettings {
|
||||||
|
pluginId: "myDesktopWidget"
|
||||||
|
|
||||||
|
SliderSetting {
|
||||||
|
settingKey: "opacity"
|
||||||
|
label: "Opacity"
|
||||||
|
description: "Widget background opacity"
|
||||||
|
defaultValue: 85
|
||||||
|
minimum: 10
|
||||||
|
maximum: 100
|
||||||
|
unit: "%"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import QtQuick
|
||||||
|
import qs.Common
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property var pluginService: null
|
||||||
|
property string pluginId: ""
|
||||||
|
property bool editMode: false
|
||||||
|
property real widgetWidth: 200
|
||||||
|
property real widgetHeight: 200
|
||||||
|
property real minWidth: 150
|
||||||
|
property real minHeight: 150
|
||||||
|
|
||||||
|
// TODO: Load settings reactively
|
||||||
|
property real bgOpacity: {
|
||||||
|
if (!pluginService) return 0.85
|
||||||
|
var val = pluginService.loadPluginData(pluginId, "opacity", 85)
|
||||||
|
return val / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: pluginService
|
||||||
|
function onPluginDataChanged(changedId) {
|
||||||
|
if (changedId !== pluginId) return
|
||||||
|
var val = pluginService.loadPluginData(pluginId, "opacity", 85)
|
||||||
|
bgOpacity = val / 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: Theme.surfaceContainer
|
||||||
|
opacity: root.bgOpacity
|
||||||
|
border.color: root.editMode ? Theme.primary : "transparent"
|
||||||
|
border.width: root.editMode ? 2 : 0
|
||||||
|
|
||||||
|
// TODO: Add your widget content here
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: "Desktop Widget"
|
||||||
|
color: Theme.surfaceText
|
||||||
|
font.pixelSize: Theme.fontSizeLarge
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"id": "myDesktopWidget",
|
||||||
|
"name": "My Desktop Widget",
|
||||||
|
"description": "A custom desktop widget",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "Your Name",
|
||||||
|
"type": "desktop",
|
||||||
|
"capabilities": ["desktop-widget"],
|
||||||
|
"component": "./Widget.qml",
|
||||||
|
"icon": "widgets",
|
||||||
|
"settings": "./Settings.qml",
|
||||||
|
"permissions": ["settings_read", "settings_write"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import qs.Services
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property var pluginService: null
|
||||||
|
property string trigger: "#"
|
||||||
|
|
||||||
|
signal itemsChanged()
|
||||||
|
|
||||||
|
// TODO: Define your items
|
||||||
|
property var allItems: [
|
||||||
|
{
|
||||||
|
name: "Example Item",
|
||||||
|
icon: "material:star",
|
||||||
|
comment: "An example launcher item",
|
||||||
|
action: "toast:Hello from my launcher!",
|
||||||
|
categories: ["MyLauncher"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
function getItems(query) {
|
||||||
|
if (!query || query.length === 0) return allItems
|
||||||
|
|
||||||
|
var q = query.toLowerCase()
|
||||||
|
return allItems.filter(function(item) {
|
||||||
|
return item.name.toLowerCase().includes(q) ||
|
||||||
|
item.comment.toLowerCase().includes(q)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function executeItem(item) {
|
||||||
|
var actionParts = item.action.split(":")
|
||||||
|
var actionType = actionParts[0]
|
||||||
|
var actionData = actionParts.slice(1).join(":")
|
||||||
|
|
||||||
|
switch (actionType) {
|
||||||
|
case "toast":
|
||||||
|
if (typeof ToastService !== "undefined")
|
||||||
|
ToastService.showInfo(actionData)
|
||||||
|
break
|
||||||
|
case "copy":
|
||||||
|
Quickshell.execDetached(["dms", "cl", "copy", actionData])
|
||||||
|
if (typeof ToastService !== "undefined")
|
||||||
|
ToastService.showInfo("Copied to clipboard")
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
console.warn("Unknown action type:", actionType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: {
|
||||||
|
if (pluginService) {
|
||||||
|
trigger = pluginService.loadPluginData("myLauncher", "trigger", "#")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import QtQuick
|
||||||
|
import qs.Common
|
||||||
|
import qs.Widgets
|
||||||
|
import qs.Modules.Plugins
|
||||||
|
|
||||||
|
PluginSettings {
|
||||||
|
pluginId: "myLauncher"
|
||||||
|
|
||||||
|
StringSetting {
|
||||||
|
settingKey: "trigger"
|
||||||
|
label: "Trigger"
|
||||||
|
description: "Type this prefix in the launcher to activate the plugin"
|
||||||
|
placeholder: "#"
|
||||||
|
defaultValue: "#"
|
||||||
|
}
|
||||||
|
|
||||||
|
ToggleSetting {
|
||||||
|
settingKey: "noTrigger"
|
||||||
|
label: "Always Visible"
|
||||||
|
description: "Show items alongside regular apps without needing a trigger"
|
||||||
|
defaultValue: false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"id": "myLauncher",
|
||||||
|
"name": "My Launcher",
|
||||||
|
"description": "Custom launcher plugin with searchable items",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "Your Name",
|
||||||
|
"type": "launcher",
|
||||||
|
"capabilities": ["launcher"],
|
||||||
|
"component": "./Launcher.qml",
|
||||||
|
"trigger": "#",
|
||||||
|
"icon": "search",
|
||||||
|
"settings": "./Settings.qml",
|
||||||
|
"permissions": ["settings_read", "settings_write"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import QtQuick
|
||||||
|
import qs.Common
|
||||||
|
import qs.Widgets
|
||||||
|
import qs.Modules.Plugins
|
||||||
|
|
||||||
|
PluginSettings {
|
||||||
|
pluginId: "myWidget"
|
||||||
|
|
||||||
|
StringSetting {
|
||||||
|
settingKey: "text"
|
||||||
|
label: "Display Text"
|
||||||
|
description: "Text shown in the bar widget"
|
||||||
|
placeholder: "Hello"
|
||||||
|
defaultValue: "Hello"
|
||||||
|
}
|
||||||
|
|
||||||
|
ToggleSetting {
|
||||||
|
settingKey: "showIcon"
|
||||||
|
label: "Show Icon"
|
||||||
|
description: "Display an icon next to the text"
|
||||||
|
defaultValue: true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import QtQuick
|
||||||
|
import qs.Common
|
||||||
|
import qs.Widgets
|
||||||
|
import qs.Modules.Plugins
|
||||||
|
|
||||||
|
PluginComponent {
|
||||||
|
id: root
|
||||||
|
property var popoutService: null
|
||||||
|
|
||||||
|
// TODO: Read settings reactively
|
||||||
|
property string displayText: pluginData?.text || "Hello"
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: pluginService
|
||||||
|
function onPluginDataChanged(changedId) {
|
||||||
|
if (changedId !== pluginId) return
|
||||||
|
displayText = pluginService.loadPluginData(pluginId, "text", "Hello")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
horizontalBarPill: Component {
|
||||||
|
StyledRect {
|
||||||
|
width: label.implicitWidth + Theme.spacingM * 2
|
||||||
|
height: parent.widgetThickness
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: Theme.surfaceContainerHigh
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
id: label
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: root.displayText
|
||||||
|
color: Theme.surfaceText
|
||||||
|
font.pixelSize: Theme.fontSizeMedium
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
verticalBarPill: Component {
|
||||||
|
StyledRect {
|
||||||
|
width: parent.widgetThickness
|
||||||
|
height: label.implicitHeight + Theme.spacingM * 2
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: Theme.surfaceContainerHigh
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
id: label
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: root.displayText
|
||||||
|
color: Theme.surfaceText
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
rotation: 90
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Uncomment and customize popout content
|
||||||
|
// popoutWidth: 350
|
||||||
|
// popoutHeight: 300
|
||||||
|
// popoutContent: Component {
|
||||||
|
// PopoutComponent {
|
||||||
|
// headerText: "My Widget"
|
||||||
|
// showCloseButton: true
|
||||||
|
//
|
||||||
|
// Column {
|
||||||
|
// width: parent.width
|
||||||
|
// spacing: Theme.spacingM
|
||||||
|
//
|
||||||
|
// StyledText {
|
||||||
|
// text: "Popout content here"
|
||||||
|
// color: Theme.surfaceText
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"id": "myWidget",
|
||||||
|
"name": "My Widget",
|
||||||
|
"description": "A custom bar widget",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "Your Name",
|
||||||
|
"type": "widget",
|
||||||
|
"capabilities": ["dankbar-widget"],
|
||||||
|
"component": "./Widget.qml",
|
||||||
|
"icon": "extension",
|
||||||
|
"settings": "./Settings.qml",
|
||||||
|
"permissions": ["settings_read", "settings_write"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
# Advanced Patterns
|
||||||
|
|
||||||
|
Patterns observed in production DMS plugins that go beyond the basics.
|
||||||
|
|
||||||
|
## Plugin Variants
|
||||||
|
|
||||||
|
Create multiple widget instances from a single plugin definition. Each variant has its own configuration.
|
||||||
|
|
||||||
|
### Manifest
|
||||||
|
|
||||||
|
No special manifest changes needed - the variant system is built into PluginComponent.
|
||||||
|
|
||||||
|
### Widget with Variant Support
|
||||||
|
|
||||||
|
```qml
|
||||||
|
PluginComponent {
|
||||||
|
property string variantId: ""
|
||||||
|
property var variantData: ({})
|
||||||
|
|
||||||
|
property string displayText: variantData?.text || "Default"
|
||||||
|
|
||||||
|
horizontalBarPill: Component {
|
||||||
|
StyledRect {
|
||||||
|
width: label.implicitWidth + Theme.spacingM * 2
|
||||||
|
height: parent.widgetThickness
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
id: label
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: root.displayText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Widget format in bar config: `pluginId:variantId` (e.g., `exampleVariants:variant_1234567890`)
|
||||||
|
|
||||||
|
### Settings with Variant Management
|
||||||
|
|
||||||
|
```qml
|
||||||
|
PluginSettings {
|
||||||
|
pluginId: "exampleVariants"
|
||||||
|
|
||||||
|
// Variant creation UI
|
||||||
|
DankButton {
|
||||||
|
text: "Add New Instance"
|
||||||
|
onClicked: {
|
||||||
|
var id = "variant_" + Date.now()
|
||||||
|
root.createVariant(id, { name: "New Instance", text: "Hello" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-variant configuration
|
||||||
|
Repeater {
|
||||||
|
model: root.variants
|
||||||
|
delegate: Column {
|
||||||
|
StringSetting {
|
||||||
|
settingKey: modelData.id + "_text"
|
||||||
|
label: modelData.name || modelData.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## JavaScript Utility Files
|
||||||
|
|
||||||
|
For complex logic, split into `.js` files:
|
||||||
|
|
||||||
|
### utils.js
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
.pragma library
|
||||||
|
|
||||||
|
function formatDuration(ms) {
|
||||||
|
if (ms < 60000) return "just now"
|
||||||
|
if (ms < 3600000) return Math.floor(ms / 60000) + "m ago"
|
||||||
|
return Math.floor(ms / 3600000) + "h ago"
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseResponse(json) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(json)
|
||||||
|
} catch (e) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using in QML
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import "utils.js" as Utils
|
||||||
|
|
||||||
|
Item {
|
||||||
|
StyledText {
|
||||||
|
text: Utils.formatDuration(Date.now() - timestamp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `.pragma library` directive makes the JS file a shared singleton - it is loaded once and shared across all QML instances that import it.
|
||||||
|
|
||||||
|
## qmldir for Singleton Services
|
||||||
|
|
||||||
|
For plugins with internal singleton services:
|
||||||
|
|
||||||
|
### qmldir
|
||||||
|
|
||||||
|
```
|
||||||
|
singleton MyService 1.0 MyService.qml
|
||||||
|
```
|
||||||
|
|
||||||
|
### MyService.qml
|
||||||
|
|
||||||
|
```qml
|
||||||
|
pragma Singleton
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
QtObject {
|
||||||
|
property var cache: ({})
|
||||||
|
|
||||||
|
function getData(key) {
|
||||||
|
return cache[key] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function setData(key, value) {
|
||||||
|
cache[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using the singleton
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import "." as Local
|
||||||
|
|
||||||
|
Item {
|
||||||
|
Component.onCompleted: {
|
||||||
|
Local.MyService.setData("key", "value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Inline Component Declarations
|
||||||
|
|
||||||
|
Reusable sub-components defined inline:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
Item {
|
||||||
|
component StatusBadge: Rectangle {
|
||||||
|
property string label: ""
|
||||||
|
property color badgeColor: Theme.primary
|
||||||
|
|
||||||
|
width: badgeText.implicitWidth + Theme.spacingM * 2
|
||||||
|
height: 24
|
||||||
|
radius: 12
|
||||||
|
color: badgeColor
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
id: badgeText
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: label
|
||||||
|
color: Theme.onPrimary
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
spacing: Theme.spacingS
|
||||||
|
StatusBadge { label: "Running"; badgeColor: Theme.success }
|
||||||
|
StatusBadge { label: "Stopped"; badgeColor: Theme.error }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Multi-Provider Adapter Pattern
|
||||||
|
|
||||||
|
For plugins supporting multiple backends (AI providers, API services):
|
||||||
|
|
||||||
|
### apiAdapters.js
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
.pragma library
|
||||||
|
|
||||||
|
function createAdapter(provider) {
|
||||||
|
switch (provider) {
|
||||||
|
case "openai": return {
|
||||||
|
url: "https://api.openai.com/v1/chat/completions",
|
||||||
|
headers: (key) => ({ "Authorization": "Bearer " + key }),
|
||||||
|
formatRequest: (messages) => JSON.stringify({ model: "gpt-4", messages: messages }),
|
||||||
|
parseResponse: (text) => JSON.parse(text).choices[0].message.content
|
||||||
|
}
|
||||||
|
case "anthropic": return {
|
||||||
|
url: "https://api.anthropic.com/v1/messages",
|
||||||
|
headers: (key) => ({ "x-api-key": key, "anthropic-version": "2023-06-01" }),
|
||||||
|
formatRequest: (messages) => JSON.stringify({ model: "claude-sonnet-4-20250514", messages: messages }),
|
||||||
|
parseResponse: (text) => JSON.parse(text).content[0].text
|
||||||
|
}
|
||||||
|
default: return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## IPC Integration
|
||||||
|
|
||||||
|
For plugins that respond to keyboard shortcuts or external commands:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
PluginComponent {
|
||||||
|
Connections {
|
||||||
|
target: DMSIpc
|
||||||
|
function onCommandReceived(command, args) {
|
||||||
|
if (command === "myPlugin.toggle") {
|
||||||
|
doToggle()
|
||||||
|
} else if (command === "myPlugin.next") {
|
||||||
|
goNext()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
External trigger: `dms ipc call myPlugin.toggle`
|
||||||
|
|
||||||
|
## Networking with Quickshell.Networking
|
||||||
|
|
||||||
|
For API calls using the built-in networking module:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import Quickshell.Networking
|
||||||
|
|
||||||
|
Item {
|
||||||
|
NetworkRequest {
|
||||||
|
id: request
|
||||||
|
url: "https://api.example.com/data"
|
||||||
|
method: "GET"
|
||||||
|
|
||||||
|
onResponseReceived: (response) => {
|
||||||
|
const data = JSON.parse(response.body)
|
||||||
|
processData(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
onErrorOccurred: (error) => {
|
||||||
|
console.error("Network error:", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchData() {
|
||||||
|
request.send()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Toast Notifications
|
||||||
|
|
||||||
|
Show user feedback:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import qs.Services
|
||||||
|
|
||||||
|
// Info toast
|
||||||
|
ToastService?.showInfo("Operation completed")
|
||||||
|
|
||||||
|
// With title
|
||||||
|
ToastService?.showInfo("Plugin Name", "Data refreshed successfully")
|
||||||
|
```
|
||||||
|
|
||||||
|
Always use optional chaining since ToastService may not be available in all contexts.
|
||||||
|
|
||||||
|
## Clipboard Operations
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import Quickshell
|
||||||
|
|
||||||
|
function copyToClipboard(text) {
|
||||||
|
Quickshell.execDetached(["dms", "cl", "copy", text])
|
||||||
|
ToastService?.showInfo("Copied to clipboard")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Do NOT use `globalThis.clipboard`, `navigator.clipboard`, or any browser API - they do not exist in the QML runtime.
|
||||||
|
|
||||||
|
## Multi-File Plugin Architecture
|
||||||
|
|
||||||
|
Large plugins can be split across multiple files:
|
||||||
|
|
||||||
|
```
|
||||||
|
MyPlugin/
|
||||||
|
plugin.json
|
||||||
|
Main.qml # Main widget component
|
||||||
|
Settings.qml # Settings UI
|
||||||
|
DetailView.qml # Popout detail view
|
||||||
|
utils.js # Utility functions
|
||||||
|
apiAdapter.js # API adapter layer
|
||||||
|
qmldir # Optional: singleton registrations
|
||||||
|
```
|
||||||
|
|
||||||
|
Import sibling files:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
// In Main.qml
|
||||||
|
import "." as Local
|
||||||
|
|
||||||
|
Item {
|
||||||
|
Loader {
|
||||||
|
source: "DetailView.qml"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Tips
|
||||||
|
|
||||||
|
1. Use `Proc.runCommand` with appropriate debounce for external commands
|
||||||
|
2. Pre-cache images and thumbnails for image-heavy plugins
|
||||||
|
3. Limit concurrent network requests
|
||||||
|
4. Use `Timer` with reasonable intervals (don't poll faster than needed)
|
||||||
|
5. Lazy-load heavy content (use `Loader` for complex popout content)
|
||||||
|
6. Avoid blocking the UI thread with synchronous operations
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
# Daemon Plugin Guide
|
||||||
|
|
||||||
|
Daemon plugins are invisible background services that react to events and execute actions. They have no bar pills or desktop presence.
|
||||||
|
|
||||||
|
## Base Component
|
||||||
|
|
||||||
|
Daemons use `PluginComponent` with no bar pills:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import QtQuick
|
||||||
|
import qs.Common
|
||||||
|
import qs.Services
|
||||||
|
import qs.Modules.Plugins
|
||||||
|
|
||||||
|
PluginComponent {
|
||||||
|
id: root
|
||||||
|
property var popoutService: null
|
||||||
|
|
||||||
|
// Event-driven logic goes here
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## When to Use Daemons
|
||||||
|
|
||||||
|
- Monitor system events (wallpaper changes, battery level, notifications)
|
||||||
|
- Run periodic background tasks (polling APIs, checking system state)
|
||||||
|
- Execute scripts in response to events
|
||||||
|
- Control shell UI via PopoutService based on conditions
|
||||||
|
|
||||||
|
## Event-Driven Pattern
|
||||||
|
|
||||||
|
Use `Connections` to react to service signals:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
PluginComponent {
|
||||||
|
property var popoutService: null
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: SessionData
|
||||||
|
function onWallpaperPathChanged() {
|
||||||
|
console.log("Wallpaper changed to:", SessionData.wallpaperPath)
|
||||||
|
runScript(SessionData.wallpaperPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: BatteryService
|
||||||
|
function onPercentageChanged() {
|
||||||
|
if (BatteryService.percentage < 10 && !BatteryService.isCharging) {
|
||||||
|
popoutService?.openBattery()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Available Services
|
||||||
|
|
||||||
|
Common services daemons can connect to:
|
||||||
|
|
||||||
|
| Service | Signals/Properties | Description |
|
||||||
|
|---------|-------------------|-------------|
|
||||||
|
| `SessionData` | `wallpaperPath`, `onWallpaperPathChanged` | Desktop session state |
|
||||||
|
| `BatteryService` | `percentage`, `isCharging`, `batteryAvailable` | Battery status |
|
||||||
|
| `NotificationService` | `onNotificationReceived(notification)` | Desktop notifications |
|
||||||
|
| `PluginService` | `onPluginLoaded`, `onGlobalVarChanged` | Plugin lifecycle |
|
||||||
|
|
||||||
|
Import services from `qs.Services`.
|
||||||
|
|
||||||
|
## Process Execution
|
||||||
|
|
||||||
|
### Simple command with Proc
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import qs.Common
|
||||||
|
|
||||||
|
PluginComponent {
|
||||||
|
function runScript(arg) {
|
||||||
|
Proc.runCommand(
|
||||||
|
"myDaemon.script",
|
||||||
|
["bash", "-c", "echo 'Processing: " + arg + "'"],
|
||||||
|
(stdout, exitCode) => {
|
||||||
|
if (exitCode === 0) {
|
||||||
|
console.log("Script output:", stdout)
|
||||||
|
} else {
|
||||||
|
ToastService?.showInfo("Script failed: exit " + exitCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Long-running process with Process component
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import Quickshell.Io
|
||||||
|
|
||||||
|
PluginComponent {
|
||||||
|
property string scriptPath: ""
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: proc
|
||||||
|
command: ["bash", scriptPath]
|
||||||
|
running: false
|
||||||
|
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onTextReceived: (text) => {
|
||||||
|
console.log("stdout:", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stderr: StdioCollector {
|
||||||
|
onTextReceived: (text) => {
|
||||||
|
console.error("stderr:", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onExited: (exitCode) => {
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
ToastService?.showInfo("Process failed: exit " + exitCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startProcess() {
|
||||||
|
if (scriptPath && !proc.running) {
|
||||||
|
proc.running = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Timer-Based Polling
|
||||||
|
|
||||||
|
```qml
|
||||||
|
PluginComponent {
|
||||||
|
Timer {
|
||||||
|
interval: 60000 // every minute
|
||||||
|
running: true
|
||||||
|
repeat: true
|
||||||
|
onTriggered: checkStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkStatus() {
|
||||||
|
Proc.runCommand(
|
||||||
|
"myDaemon.check",
|
||||||
|
["sh", "-c", "systemctl is-active myservice"],
|
||||||
|
(stdout, exitCode) => {
|
||||||
|
const active = stdout.trim() === "active"
|
||||||
|
PluginService.setGlobalVar("myDaemon", "serviceActive", active)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Persistence
|
||||||
|
|
||||||
|
Daemons access PluginService directly (it's injected via PluginComponent):
|
||||||
|
|
||||||
|
```qml
|
||||||
|
PluginComponent {
|
||||||
|
property string configuredScript: pluginData?.scriptPath || ""
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: pluginService
|
||||||
|
function onPluginDataChanged(changedId) {
|
||||||
|
if (changedId === pluginId) {
|
||||||
|
configuredScript = pluginService.loadPluginData(pluginId, "scriptPath", "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## PopoutService Usage
|
||||||
|
|
||||||
|
Daemons can control shell UI via the injected popoutService:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
PluginComponent {
|
||||||
|
property var popoutService: null
|
||||||
|
|
||||||
|
function showAlert() {
|
||||||
|
popoutService?.openNotificationCenter()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSettings() {
|
||||||
|
popoutService?.openSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
See [popout-service-reference.md](popout-service-reference.md) for the full API.
|
||||||
|
|
||||||
|
## Complete Example
|
||||||
|
|
||||||
|
Based on the WallpaperWatcherDaemon:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import qs.Common
|
||||||
|
import qs.Services
|
||||||
|
import qs.Modules.Plugins
|
||||||
|
|
||||||
|
PluginComponent {
|
||||||
|
id: root
|
||||||
|
property var popoutService: null
|
||||||
|
|
||||||
|
property string scriptPath: pluginData?.scriptPath || ""
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: pluginService
|
||||||
|
function onPluginDataChanged(changedId) {
|
||||||
|
if (changedId === pluginId) {
|
||||||
|
scriptPath = pluginService.loadPluginData(pluginId, "scriptPath", "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: SessionData
|
||||||
|
function onWallpaperPathChanged() {
|
||||||
|
if (scriptPath) {
|
||||||
|
runWallpaperScript(SessionData.wallpaperPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runWallpaperScript(wallpaperPath) {
|
||||||
|
console.log("[WallpaperWatcher] Running script:", scriptPath, wallpaperPath)
|
||||||
|
|
||||||
|
Proc.runCommand(
|
||||||
|
"wallpaperWatcher.run",
|
||||||
|
["bash", scriptPath, wallpaperPath],
|
||||||
|
(stdout, exitCode) => {
|
||||||
|
if (exitCode === 0) {
|
||||||
|
console.log("[WallpaperWatcher] Script output:", stdout)
|
||||||
|
} else {
|
||||||
|
console.error("[WallpaperWatcher] Script failed:", exitCode)
|
||||||
|
ToastService?.showInfo("Wallpaper script failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: {
|
||||||
|
console.log("[WallpaperWatcher] Daemon started")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manifest Example
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "wallpaperWatcher",
|
||||||
|
"name": "Wallpaper Watcher",
|
||||||
|
"description": "Runs a script when the wallpaper changes",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "Developer",
|
||||||
|
"type": "daemon",
|
||||||
|
"capabilities": ["wallpaper-automation"],
|
||||||
|
"component": "./WallpaperWatcher.qml",
|
||||||
|
"icon": "wallpaper",
|
||||||
|
"settings": "./Settings.qml",
|
||||||
|
"permissions": ["settings_read", "settings_write", "process"]
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
# Data Persistence Guide
|
||||||
|
|
||||||
|
DMS plugins have three tiers of data persistence, each suited for different use cases.
|
||||||
|
|
||||||
|
## Tier 1: Plugin Data (Settings)
|
||||||
|
|
||||||
|
Persisted to `settings.json`. Use for user preferences and configuration.
|
||||||
|
|
||||||
|
### Saving
|
||||||
|
|
||||||
|
```qml
|
||||||
|
pluginService.savePluginData(pluginId, "key", value)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Loading
|
||||||
|
|
||||||
|
```qml
|
||||||
|
var value = pluginService.loadPluginData(pluginId, "key", defaultValue)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reactive Access via pluginData
|
||||||
|
|
||||||
|
`PluginComponent` has a reactive `pluginData` property that auto-loads from settings:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
PluginComponent {
|
||||||
|
property string displayText: pluginData?.text || "Default"
|
||||||
|
property bool showIcon: pluginData?.showIcon !== undefined ? pluginData.showIcon : true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reacting to Settings Changes
|
||||||
|
|
||||||
|
When settings are changed (e.g., from the settings UI), react with `Connections`:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
Connections {
|
||||||
|
target: pluginService
|
||||||
|
function onPluginDataChanged(changedId) {
|
||||||
|
if (changedId !== pluginId) return
|
||||||
|
displayText = pluginService.loadPluginData(pluginId, "text", "Default")
|
||||||
|
showIcon = pluginService.loadPluginData(pluginId, "showIcon", true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tier 2: Plugin State
|
||||||
|
|
||||||
|
Persisted to a separate state file. Use for runtime state that should survive restarts but is not user-configurable (history, cache, counters).
|
||||||
|
|
||||||
|
### Saving
|
||||||
|
|
||||||
|
```qml
|
||||||
|
pluginService.savePluginState(pluginId, "key", value)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Loading
|
||||||
|
|
||||||
|
```qml
|
||||||
|
var state = pluginService.loadPluginState(pluginId, "key", defaultValue)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Additional Methods
|
||||||
|
|
||||||
|
```qml
|
||||||
|
pluginService.clearPluginState(pluginId)
|
||||||
|
pluginService.removePluginStateKey(pluginId, "key")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example: Persistent History
|
||||||
|
|
||||||
|
```qml
|
||||||
|
Item {
|
||||||
|
property var history: []
|
||||||
|
|
||||||
|
Component.onCompleted: {
|
||||||
|
history = pluginService?.loadPluginState(pluginId, "history", []) || []
|
||||||
|
}
|
||||||
|
|
||||||
|
function addToHistory(entry) {
|
||||||
|
history.unshift({
|
||||||
|
text: entry,
|
||||||
|
timestamp: Date.now()
|
||||||
|
})
|
||||||
|
if (history.length > 100) history = history.slice(0, 100)
|
||||||
|
pluginService?.savePluginState(pluginId, "history", history)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearHistory() {
|
||||||
|
history = []
|
||||||
|
pluginService?.removePluginStateKey(pluginId, "history")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tier 3: Global Variables (Runtime Only)
|
||||||
|
|
||||||
|
NOT persisted. Shared across all instances of a plugin. Use for cross-instance state synchronization (multi-monitor consistency, multi-instance widgets).
|
||||||
|
|
||||||
|
### Using PluginGlobalVar Component
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import qs.Modules.Plugins
|
||||||
|
|
||||||
|
PluginComponent {
|
||||||
|
PluginGlobalVar {
|
||||||
|
id: globalCounter
|
||||||
|
varName: "counter"
|
||||||
|
defaultValue: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
horizontalBarPill: Component {
|
||||||
|
StyledRect {
|
||||||
|
// ...
|
||||||
|
StyledText {
|
||||||
|
text: "Count: " + globalCounter.value
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
onClicked: globalCounter.set(globalCounter.value + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**PluginGlobalVar properties:**
|
||||||
|
|
||||||
|
| Property | Type | Description |
|
||||||
|
|----------|------|-------------|
|
||||||
|
| `varName` | string | Required: name of the global variable |
|
||||||
|
| `defaultValue` | any | Optional: default if not set |
|
||||||
|
| `value` | any | Readonly: current value |
|
||||||
|
|
||||||
|
**Methods:**
|
||||||
|
- `set(newValue)` - update the value (triggers reactivity across all instances)
|
||||||
|
|
||||||
|
### Using PluginService API Directly
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import qs.Services
|
||||||
|
|
||||||
|
property int counter: PluginService.getGlobalVar("myPlugin", "counter", 0)
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: PluginService
|
||||||
|
function onGlobalVarChanged(pluginId, varName) {
|
||||||
|
if (pluginId === "myPlugin" && varName === "counter") {
|
||||||
|
counter = PluginService.getGlobalVar("myPlugin", "counter", 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function increment() {
|
||||||
|
var current = PluginService.getGlobalVar("myPlugin", "counter", 0)
|
||||||
|
PluginService.setGlobalVar("myPlugin", "counter", current + 1)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Decision Matrix
|
||||||
|
|
||||||
|
| Need | API | Persisted | Scope |
|
||||||
|
|------|-----|-----------|-------|
|
||||||
|
| User preferences (API keys, themes, intervals) | `savePluginData` / `loadPluginData` | Yes (settings.json) | Per plugin |
|
||||||
|
| Runtime state (history, cache, counters) | `savePluginState` / `loadPluginState` | Yes (state file) | Per plugin |
|
||||||
|
| Cross-instance sync (multi-monitor data) | `PluginGlobalVar` or `getGlobalVar`/`setGlobalVar` | No (runtime only) | All instances |
|
||||||
|
| Quick reactive reads from settings | `pluginData` property | N/A (read-only) | Per instance |
|
||||||
|
|
||||||
|
## Important Notes
|
||||||
|
|
||||||
|
1. **pluginData is reactive** - bindings update automatically when data changes
|
||||||
|
2. **Global vars are NOT persistent** - they reset when the shell restarts
|
||||||
|
3. **State vs Data** - data is for user-facing settings, state is for internal runtime data
|
||||||
|
4. **Null safety** - always check `pluginService` is not null before calling methods
|
||||||
|
5. **Signal namespacing** - global var signals include `pluginId` to filter for your plugin
|
||||||
|
6. **Performance** - global vars are efficient for frequent updates; settings writes are batched
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
# Desktop Plugin Guide
|
||||||
|
|
||||||
|
Desktop plugins are widgets that appear on the desktop background layer. They support drag-and-drop positioning and resize via corner handles.
|
||||||
|
|
||||||
|
## Base Component
|
||||||
|
|
||||||
|
Desktop widgets use a plain `Item` with injected properties:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import QtQuick
|
||||||
|
import qs.Common
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property var pluginService: null
|
||||||
|
property string pluginId: ""
|
||||||
|
property bool editMode: false
|
||||||
|
property real widgetWidth: 200
|
||||||
|
property real widgetHeight: 200
|
||||||
|
property real minWidth: 150
|
||||||
|
property real minHeight: 150
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: Theme.surfaceContainer
|
||||||
|
opacity: 0.85
|
||||||
|
|
||||||
|
// Your content here
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Injected Properties
|
||||||
|
|
||||||
|
These are set automatically by the DesktopPluginWrapper:
|
||||||
|
|
||||||
|
| Property | Type | Description |
|
||||||
|
|----------|------|-------------|
|
||||||
|
| `pluginService` | object | PluginService reference for data persistence |
|
||||||
|
| `pluginId` | string | Plugin's unique identifier |
|
||||||
|
| `editMode` | bool | `true` when user is dragging/resizing |
|
||||||
|
| `widgetWidth` | real | Current widget container width |
|
||||||
|
| `widgetHeight` | real | Current widget container height |
|
||||||
|
|
||||||
|
## Optional Properties
|
||||||
|
|
||||||
|
Define these on your root item to customize behavior:
|
||||||
|
|
||||||
|
| Property | Type | Default | Description |
|
||||||
|
|----------|------|---------|-------------|
|
||||||
|
| `minWidth` | real | 100 | Minimum allowed width during resize |
|
||||||
|
| `minHeight` | real | 100 | Minimum allowed height during resize |
|
||||||
|
|
||||||
|
## Position and Size Persistence
|
||||||
|
|
||||||
|
Position (`desktopX`, `desktopY`) and size (`desktopWidth`, `desktopHeight`) are automatically managed by the DesktopPluginWrapper. You do not need to handle persistence for positioning.
|
||||||
|
|
||||||
|
## Edit Mode
|
||||||
|
|
||||||
|
When `editMode` is true, the user is repositioning or resizing. Use this to:
|
||||||
|
- Show visual indicators (borders, handles)
|
||||||
|
- Disable interactive elements to prevent accidental actions
|
||||||
|
- Display additional controls
|
||||||
|
|
||||||
|
```qml
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
border.color: root.editMode ? Theme.primary : "transparent"
|
||||||
|
border.width: root.editMode ? 2 : 0
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
enabled: !root.editMode
|
||||||
|
onClicked: doSomething()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Loading and Saving Data
|
||||||
|
|
||||||
|
Use the injected `pluginService` for data persistence:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
property string displayMode: {
|
||||||
|
if (!pluginService) return "default"
|
||||||
|
return pluginService.loadPluginData(pluginId, "displayMode", "default")
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: pluginService
|
||||||
|
function onPluginDataChanged(changedId) {
|
||||||
|
if (changedId !== pluginId) return
|
||||||
|
root.displayMode = pluginService.loadPluginData(pluginId, "displayMode", "default")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveMode(mode) {
|
||||||
|
pluginService?.savePluginData(pluginId, "displayMode", mode)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Settings Component
|
||||||
|
|
||||||
|
Desktop plugin settings use the same `PluginSettings` wrapper as other types:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import QtQuick
|
||||||
|
import qs.Common
|
||||||
|
import qs.Widgets
|
||||||
|
import qs.Modules.Plugins
|
||||||
|
|
||||||
|
PluginSettings {
|
||||||
|
pluginId: "myDesktopWidget"
|
||||||
|
|
||||||
|
SliderSetting {
|
||||||
|
settingKey: "opacity"
|
||||||
|
label: "Opacity"
|
||||||
|
description: "Widget background opacity"
|
||||||
|
defaultValue: 85
|
||||||
|
minimum: 10
|
||||||
|
maximum: 100
|
||||||
|
unit: "%"
|
||||||
|
}
|
||||||
|
|
||||||
|
SelectionSetting {
|
||||||
|
settingKey: "style"
|
||||||
|
label: "Display Style"
|
||||||
|
options: [
|
||||||
|
{ label: "Compact", value: "compact" },
|
||||||
|
{ label: "Expanded", value: "expanded" }
|
||||||
|
]
|
||||||
|
defaultValue: "compact"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## User Interaction
|
||||||
|
|
||||||
|
Desktop widgets support:
|
||||||
|
1. **Drag** - click and drag anywhere (in edit mode)
|
||||||
|
2. **Resize** - drag bottom-right corner handle (in edit mode)
|
||||||
|
3. **Edit mode toggle** - via the desktop edit button
|
||||||
|
|
||||||
|
## Complete Example
|
||||||
|
|
||||||
|
Based on the ExampleDesktopClock pattern:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import QtQuick
|
||||||
|
import qs.Common
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property var pluginService: null
|
||||||
|
property string pluginId: ""
|
||||||
|
property bool editMode: false
|
||||||
|
property real widgetWidth: 250
|
||||||
|
property real widgetHeight: 250
|
||||||
|
property real minWidth: 150
|
||||||
|
property real minHeight: 150
|
||||||
|
|
||||||
|
property string clockStyle: {
|
||||||
|
if (!pluginService) return "digital"
|
||||||
|
return pluginService.loadPluginData(pluginId, "clockStyle", "digital")
|
||||||
|
}
|
||||||
|
|
||||||
|
property real bgOpacity: {
|
||||||
|
if (!pluginService) return 0.85
|
||||||
|
var val = pluginService.loadPluginData(pluginId, "opacity", 85)
|
||||||
|
return val / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: pluginService
|
||||||
|
function onPluginDataChanged(changedId) {
|
||||||
|
if (changedId !== pluginId) return
|
||||||
|
clockStyle = pluginService.loadPluginData(pluginId, "clockStyle", "digital")
|
||||||
|
var val = pluginService.loadPluginData(pluginId, "opacity", 85)
|
||||||
|
bgOpacity = val / 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: Theme.surfaceContainer
|
||||||
|
opacity: root.bgOpacity
|
||||||
|
border.color: root.editMode ? Theme.primary : "transparent"
|
||||||
|
border.width: root.editMode ? 2 : 0
|
||||||
|
|
||||||
|
Column {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
spacing: Theme.spacingS
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
text: Qt.formatTime(new Date(), "hh:mm:ss")
|
||||||
|
color: Theme.surfaceText
|
||||||
|
font.pixelSize: root.widgetWidth * 0.15
|
||||||
|
font.weight: Font.Bold
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
text: Qt.formatDate(new Date(), "ddd, MMM d")
|
||||||
|
color: Theme.onSurfaceVariant
|
||||||
|
font.pixelSize: Theme.fontSizeMedium
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
interval: 1000
|
||||||
|
running: true
|
||||||
|
repeat: true
|
||||||
|
onTriggered: root.widgetWidth = root.widgetWidth // force update
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manifest Example
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "myDesktopClock",
|
||||||
|
"name": "Desktop Clock",
|
||||||
|
"description": "Analog and digital clock for the desktop",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "Developer",
|
||||||
|
"type": "desktop",
|
||||||
|
"capabilities": ["desktop-widget"],
|
||||||
|
"component": "./ClockWidget.qml",
|
||||||
|
"icon": "schedule",
|
||||||
|
"settings": "./Settings.qml",
|
||||||
|
"permissions": ["settings_read", "settings_write"]
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
# Launcher Plugin Guide
|
||||||
|
|
||||||
|
Launcher plugins extend the DMS launcher with custom searchable items and actions. They use trigger-based filtering and integrate directly into the app drawer.
|
||||||
|
|
||||||
|
## Base Component
|
||||||
|
|
||||||
|
Launchers use a plain `Item` (not PluginComponent):
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import QtQuick
|
||||||
|
import qs.Services
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property var pluginService: null
|
||||||
|
property string trigger: "#"
|
||||||
|
|
||||||
|
signal itemsChanged()
|
||||||
|
|
||||||
|
function getItems(query) {
|
||||||
|
// Return array of items
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
function executeItem(item) {
|
||||||
|
// Handle item selection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Required Interface
|
||||||
|
|
||||||
|
| Member | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `pluginService` | property | Injected PluginService reference (declare as `null`) |
|
||||||
|
| `trigger` | property | Trigger string for activation |
|
||||||
|
| `itemsChanged` | signal | Emit when item list changes (triggers UI refresh) |
|
||||||
|
| `getItems(query)` | function | Return array of items matching query |
|
||||||
|
| `executeItem(item)` | function | Handle item selection |
|
||||||
|
|
||||||
|
## Item Structure
|
||||||
|
|
||||||
|
Each item returned by `getItems()`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
name: "Item Display Name", // Required: shown in launcher
|
||||||
|
icon: "material:star", // Optional: icon specification
|
||||||
|
comment: "Description text", // Required: subtitle text
|
||||||
|
action: "type:data", // Required: action identifier
|
||||||
|
categories: ["MyPlugin"], // Required: array with plugin category
|
||||||
|
imageUrl: "https://..." // Optional: image for tile view
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Icon Types
|
||||||
|
|
||||||
|
### 1. Material Design Icons
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{ icon: "material:lightbulb" }
|
||||||
|
{ icon: "material:terminal" }
|
||||||
|
{ icon: "material:translate" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Uses the Material Symbols Rounded font.
|
||||||
|
|
||||||
|
### 2. Unicode / Emoji Icons
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{ icon: "unicode:smile_face" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Rendered at 70-80% of icon size with theming.
|
||||||
|
|
||||||
|
### 3. Desktop Theme Icons
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{ icon: "firefox" }
|
||||||
|
{ icon: "folder" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Uses the user's installed icon theme.
|
||||||
|
|
||||||
|
### 4. No Icon
|
||||||
|
|
||||||
|
Omit the `icon` field entirely. The launcher hides the icon area and gives full width to the item name.
|
||||||
|
|
||||||
|
## Trigger System
|
||||||
|
|
||||||
|
**Custom trigger** (items only appear when trigger is typed):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "trigger": "#" }
|
||||||
|
```
|
||||||
|
|
||||||
|
- Type `#` alone: shows all plugin items
|
||||||
|
- Type `# query`: filters plugin items by query
|
||||||
|
- The query string (without trigger) is passed to `getItems(query)`
|
||||||
|
|
||||||
|
**No trigger** (items always visible alongside regular apps):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "trigger": "" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Save empty trigger at runtime:
|
||||||
|
```qml
|
||||||
|
Component.onCompleted: {
|
||||||
|
trigger = pluginService?.loadPluginData(pluginId, "trigger", "#") ?? "#"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Action Execution
|
||||||
|
|
||||||
|
Parse action strings in `executeItem()`:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
function executeItem(item) {
|
||||||
|
const actionParts = item.action.split(":")
|
||||||
|
const actionType = actionParts[0]
|
||||||
|
const actionData = actionParts.slice(1).join(":")
|
||||||
|
|
||||||
|
switch (actionType) {
|
||||||
|
case "toast":
|
||||||
|
ToastService?.showInfo(actionData)
|
||||||
|
break
|
||||||
|
case "copy":
|
||||||
|
Quickshell.execDetached(["dms", "cl", "copy", actionData])
|
||||||
|
ToastService?.showInfo("Copied to clipboard")
|
||||||
|
break
|
||||||
|
case "exec":
|
||||||
|
Quickshell.execDetached(actionData.split(" "))
|
||||||
|
break
|
||||||
|
case "url":
|
||||||
|
Quickshell.execDetached(["xdg-open", actionData])
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
console.warn("Unknown action type:", actionType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Search / Filtering
|
||||||
|
|
||||||
|
The `query` parameter in `getItems()` contains the user's search text (without the trigger prefix).
|
||||||
|
|
||||||
|
```qml
|
||||||
|
function getItems(query) {
|
||||||
|
const allItems = [
|
||||||
|
{ name: "Calculator", icon: "material:calculate",
|
||||||
|
comment: "Open calculator", action: "exec:gnome-calculator",
|
||||||
|
categories: ["Tools"] },
|
||||||
|
{ name: "Terminal", icon: "material:terminal",
|
||||||
|
comment: "Open terminal", action: "exec:alacritty",
|
||||||
|
categories: ["Tools"] }
|
||||||
|
]
|
||||||
|
|
||||||
|
if (!query || query.length === 0) return allItems
|
||||||
|
|
||||||
|
const q = query.toLowerCase()
|
||||||
|
return allItems.filter(item =>
|
||||||
|
item.name.toLowerCase().includes(q) ||
|
||||||
|
item.comment.toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context Menu Actions
|
||||||
|
|
||||||
|
Add right-click actions to launcher items:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
function getContextMenuActions(item) {
|
||||||
|
return [
|
||||||
|
{ name: "Copy", icon: "material:content_copy",
|
||||||
|
action: "copy:" + item.name },
|
||||||
|
{ name: "Open in Browser", icon: "material:open_in_new",
|
||||||
|
action: "url:" + item.url }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Context menu actions use the same `executeItem()` handler.
|
||||||
|
|
||||||
|
## Image Tile View
|
||||||
|
|
||||||
|
For image-heavy launchers (GIF search, sticker pickers), use tile view:
|
||||||
|
|
||||||
|
In `plugin.json`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"viewMode": "tile",
|
||||||
|
"viewModeEnforced": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In items:
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
name: "Image Title",
|
||||||
|
imageUrl: "https://example.com/image.png",
|
||||||
|
comment: "Description",
|
||||||
|
action: "copy:https://example.com/image.png",
|
||||||
|
categories: ["MyPlugin"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## State Persistence
|
||||||
|
|
||||||
|
For plugins with persistent state (notes, history, favorites):
|
||||||
|
|
||||||
|
```qml
|
||||||
|
property var notes: []
|
||||||
|
|
||||||
|
Component.onCompleted: {
|
||||||
|
const saved = pluginService?.loadPluginState(pluginId, "notes", [])
|
||||||
|
if (saved) notes = saved
|
||||||
|
}
|
||||||
|
|
||||||
|
function addNote(text) {
|
||||||
|
notes.push({ text: text, timestamp: Date.now() })
|
||||||
|
pluginService?.savePluginState(pluginId, "notes", notes)
|
||||||
|
itemsChanged()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `savePluginState/loadPluginState` for runtime data and `savePluginData/loadPluginData` for user preferences.
|
||||||
|
|
||||||
|
## Settings for Trigger Configuration
|
||||||
|
|
||||||
|
Provide a PluginSettings component for trigger customization:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import QtQuick
|
||||||
|
import qs.Common
|
||||||
|
import qs.Widgets
|
||||||
|
import qs.Modules.Plugins
|
||||||
|
|
||||||
|
PluginSettings {
|
||||||
|
pluginId: "myLauncher"
|
||||||
|
|
||||||
|
StringSetting {
|
||||||
|
settingKey: "trigger"
|
||||||
|
label: "Trigger"
|
||||||
|
description: "Type this prefix to activate the launcher plugin"
|
||||||
|
placeholder: "#"
|
||||||
|
defaultValue: "#"
|
||||||
|
}
|
||||||
|
|
||||||
|
ToggleSetting {
|
||||||
|
settingKey: "noTrigger"
|
||||||
|
label: "Always Visible"
|
||||||
|
description: "Show items alongside regular apps (no trigger needed)"
|
||||||
|
defaultValue: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Example
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import qs.Services
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property var pluginService: null
|
||||||
|
property string trigger: "!"
|
||||||
|
|
||||||
|
signal itemsChanged()
|
||||||
|
|
||||||
|
property var commands: [
|
||||||
|
{ name: "Lock Screen", icon: "material:lock",
|
||||||
|
comment: "Lock the session", action: "exec:loginctl lock-session" },
|
||||||
|
{ name: "Screenshot", icon: "material:screenshot_monitor",
|
||||||
|
comment: "Take a screenshot", action: "exec:grim" },
|
||||||
|
{ name: "File Manager", icon: "material:folder",
|
||||||
|
comment: "Open file manager", action: "exec:nautilus" }
|
||||||
|
]
|
||||||
|
|
||||||
|
function getItems(query) {
|
||||||
|
if (!query) return commands
|
||||||
|
const q = query.toLowerCase()
|
||||||
|
return commands.filter(c =>
|
||||||
|
c.name.toLowerCase().includes(q) ||
|
||||||
|
c.comment.toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function executeItem(item) {
|
||||||
|
const [type, ...rest] = item.action.split(":")
|
||||||
|
const data = rest.join(":")
|
||||||
|
if (type === "exec") {
|
||||||
|
Quickshell.execDetached(data.split(" "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: {
|
||||||
|
if (pluginService) {
|
||||||
|
trigger = pluginService.loadPluginData("quickCommands", "trigger", "!")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
# Plugin Manifest Reference (plugin.json)
|
||||||
|
|
||||||
|
## Required Fields
|
||||||
|
|
||||||
|
| Field | Type | Description | Validation |
|
||||||
|
|-------|------|-------------|------------|
|
||||||
|
| `id` | string | Unique plugin identifier | camelCase, pattern `^[a-zA-Z][a-zA-Z0-9]*$` |
|
||||||
|
| `name` | string | Human-readable name | Non-empty |
|
||||||
|
| `description` | string | Short description (shown in UI) | Non-empty |
|
||||||
|
| `version` | string | Semantic version | Pattern `^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$` |
|
||||||
|
| `author` | string | Creator name or email | Non-empty |
|
||||||
|
| `type` | string | Plugin type | One of: `widget`, `daemon`, `launcher`, `desktop` |
|
||||||
|
| `capabilities` | array | Plugin capabilities | At least 1 string item |
|
||||||
|
| `component` | string | Path to main QML file | Must start with `./`, end with `.qml` |
|
||||||
|
|
||||||
|
## Conditional Requirements
|
||||||
|
|
||||||
|
| Condition | Required Field | Description |
|
||||||
|
|-----------|---------------|-------------|
|
||||||
|
| `type: "launcher"` | `trigger` | Trigger string for launcher activation (e.g., `=`, `#`, `!`) |
|
||||||
|
|
||||||
|
## Optional Fields
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `icon` | string | Material Design icon name (displayed in plugin list UI) |
|
||||||
|
| `settings` | string | Path to settings QML file (must start with `./`, end with `.qml`) |
|
||||||
|
| `requires_dms` | string | Minimum DMS version (e.g., `>=0.1.18`), pattern `^(>=?\|<=?\|=\|>\|<)\d+\.\d+\.\d+$` |
|
||||||
|
| `requires` | array | System tool dependencies (e.g., `["curl", "jq"]`) |
|
||||||
|
| `permissions` | array | Required permissions |
|
||||||
|
| `trigger` | string | Launcher trigger string (required for launcher type) |
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
| Permission | Description | Enforced |
|
||||||
|
|------------|-------------|----------|
|
||||||
|
| `settings_read` | Read plugin configuration | No (not currently enforced) |
|
||||||
|
| `settings_write` | Write plugin configuration / use PluginSettings | **Yes** |
|
||||||
|
| `process` | Execute system commands | No (not currently enforced) |
|
||||||
|
| `network` | Network access | No (not currently enforced) |
|
||||||
|
|
||||||
|
If your plugin has a `settings` component but does not declare `settings_write`, users will see an error instead of the settings UI.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
Capabilities are free-form strings that describe what the plugin does. Common values:
|
||||||
|
|
||||||
|
- `dankbar-widget` - general bar widget
|
||||||
|
- `control-center` - integrates with Control Center
|
||||||
|
- `monitoring` - system/service monitoring
|
||||||
|
- `launcher` - launcher search provider
|
||||||
|
- `desktop-widget` - desktop background widget
|
||||||
|
- `ai` - AI/LLM integration
|
||||||
|
- `slideout` - uses slideout panel
|
||||||
|
|
||||||
|
## Complete Example
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "myPlugin",
|
||||||
|
"name": "My Plugin",
|
||||||
|
"description": "A sample plugin demonstrating all fields",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "Developer Name",
|
||||||
|
"type": "widget",
|
||||||
|
"capabilities": ["dankbar-widget", "control-center"],
|
||||||
|
"component": "./MyWidget.qml",
|
||||||
|
"icon": "extension",
|
||||||
|
"settings": "./Settings.qml",
|
||||||
|
"requires_dms": ">=0.1.18",
|
||||||
|
"requires": ["curl", "jq"],
|
||||||
|
"permissions": ["settings_read", "settings_write", "process", "network"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Launcher Example
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "myLauncher",
|
||||||
|
"name": "My Launcher",
|
||||||
|
"description": "Search and execute custom actions",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "Developer Name",
|
||||||
|
"type": "launcher",
|
||||||
|
"capabilities": ["launcher"],
|
||||||
|
"component": "./MyLauncher.qml",
|
||||||
|
"trigger": "#",
|
||||||
|
"icon": "search",
|
||||||
|
"settings": "./Settings.qml",
|
||||||
|
"requires_dms": ">=0.1.18",
|
||||||
|
"permissions": ["settings_read", "settings_write"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## JSON Schema
|
||||||
|
|
||||||
|
The complete JSON schema is available at `assets/plugin-schema.json` in this skill. Validate with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Using python
|
||||||
|
python3 -c "
|
||||||
|
import json, jsonschema
|
||||||
|
schema = json.load(open('path/to/plugin-schema.json'))
|
||||||
|
manifest = json.load(open('plugin.json'))
|
||||||
|
jsonschema.validate(manifest, schema)
|
||||||
|
print('Valid!')
|
||||||
|
"
|
||||||
|
|
||||||
|
# Using jq (syntax check only)
|
||||||
|
jq . plugin.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Additional Properties
|
||||||
|
|
||||||
|
The schema allows additional properties (`"additionalProperties": true`), so plugins can include custom fields. Common custom fields seen in production plugins:
|
||||||
|
|
||||||
|
- `viewMode` - launcher display mode (`"tile"` for image grids)
|
||||||
|
- `viewModeEnforced` - lock launcher to specific view mode (`true`/`false`)
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# PopoutService Reference
|
||||||
|
|
||||||
|
The `PopoutService` singleton lets plugins control all DMS popouts and modals. It is automatically injected into widget, daemon, and settings components.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
Declare the property in your component for injection to work:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
property var popoutService: null
|
||||||
|
```
|
||||||
|
|
||||||
|
Without this declaration, injection fails with: `Cannot assign to non-existent property "popoutService"`
|
||||||
|
|
||||||
|
## Popouts (DankPopout-based)
|
||||||
|
|
||||||
|
| Component | Open | Close | Toggle |
|
||||||
|
|-----------|------|-------|--------|
|
||||||
|
| Control Center | `openControlCenter()` | `closeControlCenter()` | `toggleControlCenter()` |
|
||||||
|
| Notification Center | `openNotificationCenter()` | `closeNotificationCenter()` | `toggleNotificationCenter()` |
|
||||||
|
| App Drawer | `openAppDrawer()` | `closeAppDrawer()` | `toggleAppDrawer()` |
|
||||||
|
| Process List | `openProcessList()` | `closeProcessList()` | `toggleProcessList()` |
|
||||||
|
| DankDash | `openDankDash(tab)` | `closeDankDash()` | `toggleDankDash(tab)` |
|
||||||
|
| Battery | `openBattery()` | `closeBattery()` | `toggleBattery()` |
|
||||||
|
| VPN | `openVpn()` | `closeVpn()` | `toggleVpn()` |
|
||||||
|
| System Update | `openSystemUpdate()` | `closeSystemUpdate()` | `toggleSystemUpdate()` |
|
||||||
|
|
||||||
|
## Modals (DankModal-based)
|
||||||
|
|
||||||
|
| Modal | Show | Hide | Notes |
|
||||||
|
|-------|------|------|-------|
|
||||||
|
| Settings | `openSettings()` | `closeSettings()` | Full settings interface |
|
||||||
|
| Clipboard History | `openClipboardHistory()` | `closeClipboardHistory()` | Clipboard integration |
|
||||||
|
| Launcher | `openDankLauncherV2()` | `closeDankLauncherV2()` | Also has `toggleDankLauncherV2()` |
|
||||||
|
| Power Menu | `openPowerMenu()` | `closePowerMenu()` | Also has `togglePowerMenu()` |
|
||||||
|
| Process List Modal | `showProcessListModal()` | `hideProcessListModal()` | Has `toggleProcessListModal()` |
|
||||||
|
| Color Picker | `showColorPicker()` | `hideColorPicker()` | Theme color selection |
|
||||||
|
| Notification | `showNotificationModal()` | `hideNotificationModal()` | Notification details |
|
||||||
|
| WiFi Password | `showWifiPasswordModal()` | `hideWifiPasswordModal()` | Network auth |
|
||||||
|
| Network Info | `showNetworkInfoModal()` | `hideNetworkInfoModal()` | Network details |
|
||||||
|
|
||||||
|
## Slideouts
|
||||||
|
|
||||||
|
| Component | Open | Close | Toggle |
|
||||||
|
|-----------|------|-------|--------|
|
||||||
|
| Notepad | `openNotepad()` | `closeNotepad()` | `toggleNotepad()` |
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Simple toggle
|
||||||
|
|
||||||
|
```qml
|
||||||
|
MouseArea {
|
||||||
|
onClicked: popoutService?.toggleControlCenter()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Conditional popout
|
||||||
|
|
||||||
|
```qml
|
||||||
|
Connections {
|
||||||
|
target: BatteryService
|
||||||
|
function onPercentageChanged() {
|
||||||
|
if (BatteryService.percentage < 10 && !BatteryService.isCharging) {
|
||||||
|
popoutService?.openBattery()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Context menu with multiple actions
|
||||||
|
|
||||||
|
```qml
|
||||||
|
MouseArea {
|
||||||
|
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||||
|
onClicked: (mouse) => {
|
||||||
|
if (mouse.button === Qt.RightButton) contextMenu.popup()
|
||||||
|
else popoutService?.toggleControlCenter()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Menu {
|
||||||
|
id: contextMenu
|
||||||
|
MenuItem { text: "Settings"; onClicked: popoutService?.openSettings() }
|
||||||
|
MenuItem { text: "Notifications"; onClicked: popoutService?.toggleNotificationCenter() }
|
||||||
|
MenuItem { text: "Power"; onClicked: popoutService?.openPowerMenu() }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Position-aware toggle (from bar pill)
|
||||||
|
|
||||||
|
Some toggle functions accept position parameters for proper popout placement:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
pillClickAction: (x, y, width, section, screen) => {
|
||||||
|
popoutService?.toggleControlCenter(x, y, width, section, screen)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Always use optional chaining** (`?.`) - the service may not be injected yet
|
||||||
|
2. **Check feature availability** before opening feature-specific popouts:
|
||||||
|
```qml
|
||||||
|
if (BatteryService.batteryAvailable) {
|
||||||
|
popoutService?.openBattery()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
3. **Lazy loading** - first access may activate lazy loaders; this is normal
|
||||||
|
4. **Popouts are shared** - avoid opening conflicting popouts simultaneously
|
||||||
|
5. **User intent** - only trigger popouts from user actions or critical system events
|
||||||
|
6. **Multi-monitor** - positioned popouts are screen-aware when using position parameters
|
||||||
|
|
||||||
|
## Injection Locations
|
||||||
|
|
||||||
|
The service is injected at these points:
|
||||||
|
- `DMSShell.qml` - daemon plugins
|
||||||
|
- `WidgetHost.qml` - widget plugins in left/right bar sections
|
||||||
|
- `CenterSection.qml` - center bar widgets
|
||||||
|
- `PluginsTab.qml` - settings components
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
# Settings Components Reference
|
||||||
|
|
||||||
|
All plugin settings use the `PluginSettings` wrapper. Setting components auto-save on change and auto-load on creation.
|
||||||
|
|
||||||
|
## PluginSettings Wrapper
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import QtQuick
|
||||||
|
import qs.Common
|
||||||
|
import qs.Widgets
|
||||||
|
import qs.Modules.Plugins
|
||||||
|
|
||||||
|
PluginSettings {
|
||||||
|
pluginId: "yourPlugin" // Required: must match plugin.json id
|
||||||
|
|
||||||
|
// Setting components go here
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important:** The plugin must declare `"permissions": ["settings_write"]` in plugin.json for the settings UI to render. Without it, users see an error.
|
||||||
|
|
||||||
|
**PluginSettings provides to children:**
|
||||||
|
- `saveValue(key, value)` - save a setting value
|
||||||
|
- `loadValue(key, defaultValue)` - load a setting value
|
||||||
|
- `saveState(key, value)` - save plugin state (separate file)
|
||||||
|
- `loadState(key, defaultValue)` - load plugin state
|
||||||
|
- `clearState()` - clear all plugin state
|
||||||
|
- Variant management functions (for variant plugins)
|
||||||
|
|
||||||
|
## StringSetting
|
||||||
|
|
||||||
|
Text input field.
|
||||||
|
|
||||||
|
```qml
|
||||||
|
StringSetting {
|
||||||
|
settingKey: "apiKey" // Required: storage key
|
||||||
|
label: "API Key" // Required: display label
|
||||||
|
description: "Your API key" // Optional: help text
|
||||||
|
placeholder: "sk-..." // Optional: input placeholder
|
||||||
|
defaultValue: "" // Optional: default (default: "")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Layout:** Vertical stack - label, description, input field.
|
||||||
|
|
||||||
|
## ToggleSetting
|
||||||
|
|
||||||
|
Boolean toggle switch.
|
||||||
|
|
||||||
|
```qml
|
||||||
|
ToggleSetting {
|
||||||
|
settingKey: "notifications" // Required: storage key
|
||||||
|
label: "Enable Notifications" // Required: display label
|
||||||
|
description: "Show alerts" // Optional: help text
|
||||||
|
defaultValue: true // Optional: default (default: false)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Layout:** Horizontal - label/description on left, toggle on right.
|
||||||
|
|
||||||
|
## SelectionSetting
|
||||||
|
|
||||||
|
Dropdown menu.
|
||||||
|
|
||||||
|
```qml
|
||||||
|
SelectionSetting {
|
||||||
|
settingKey: "theme" // Required: storage key
|
||||||
|
label: "Theme" // Required: display label
|
||||||
|
description: "Color scheme" // Optional: help text
|
||||||
|
options: [ // Required: array of options
|
||||||
|
{ label: "Dark", value: "dark" },
|
||||||
|
{ label: "Light", value: "light" },
|
||||||
|
{ label: "Auto", value: "auto" }
|
||||||
|
]
|
||||||
|
defaultValue: "dark" // Optional: default value
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Options can be `{ label, value }` objects or simple strings. Stores the `value` field, displays the `label` field.
|
||||||
|
|
||||||
|
**Layout:** Horizontal - label/description on left, dropdown on right.
|
||||||
|
|
||||||
|
**Reacting to changes:**
|
||||||
|
```qml
|
||||||
|
SelectionSetting {
|
||||||
|
settingKey: "updateInterval"
|
||||||
|
label: "Update Interval"
|
||||||
|
options: [
|
||||||
|
{ label: "1 minute", value: "60" },
|
||||||
|
{ label: "5 minutes", value: "300" }
|
||||||
|
]
|
||||||
|
defaultValue: "300"
|
||||||
|
onValueChanged: (newValue) => {
|
||||||
|
console.log("Interval changed to:", newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## SliderSetting
|
||||||
|
|
||||||
|
Numeric slider with min/max.
|
||||||
|
|
||||||
|
```qml
|
||||||
|
SliderSetting {
|
||||||
|
settingKey: "opacity" // Required: storage key
|
||||||
|
label: "Opacity" // Required: display label
|
||||||
|
description: "Background" // Optional: help text
|
||||||
|
defaultValue: 85 // Optional: default value
|
||||||
|
minimum: 0 // Required: min value
|
||||||
|
maximum: 100 // Required: max value
|
||||||
|
unit: "%" // Optional: unit label shown after value
|
||||||
|
leftIcon: "dark_mode" // Optional: Material icon on left
|
||||||
|
rightIcon: "light_mode" // Optional: Material icon on right
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## ColorSetting
|
||||||
|
|
||||||
|
Color picker.
|
||||||
|
|
||||||
|
```qml
|
||||||
|
ColorSetting {
|
||||||
|
settingKey: "accentColor" // Required: storage key
|
||||||
|
label: "Accent Color" // Required: display label
|
||||||
|
description: "Custom accent" // Optional: help text
|
||||||
|
defaultValue: "#ff5722" // Optional: default hex color
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Displays a color swatch that opens a color picker dialog.
|
||||||
|
|
||||||
|
## ListSetting
|
||||||
|
|
||||||
|
Manage a list of items with manual add/remove. Use when you need custom UI for adding items.
|
||||||
|
|
||||||
|
```qml
|
||||||
|
ListSetting {
|
||||||
|
id: itemList
|
||||||
|
settingKey: "items" // Required: storage key
|
||||||
|
label: "Saved Items" // Required: display label
|
||||||
|
description: "Your items" // Optional: help text
|
||||||
|
defaultValue: [] // Optional: default array
|
||||||
|
delegate: Component { // Optional: custom item display
|
||||||
|
StyledRect {
|
||||||
|
width: parent.width
|
||||||
|
height: 40
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: Theme.surfaceContainerHigh
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: Theme.spacingM
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: modelData.name
|
||||||
|
color: Theme.surfaceText
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: Theme.spacingM
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: 60
|
||||||
|
height: 28
|
||||||
|
color: removeArea.containsMouse ? Theme.errorHover : Theme.error
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: "Remove"
|
||||||
|
color: Theme.errorText
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
font.weight: Font.Medium
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: removeArea
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: itemList.removeItem(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Methods:**
|
||||||
|
- `addItem(item)` - add an item to the list
|
||||||
|
- `removeItem(index)` - remove item at index
|
||||||
|
|
||||||
|
## ListSettingWithInput
|
||||||
|
|
||||||
|
Complete list management with built-in form. Best for collecting structured data.
|
||||||
|
|
||||||
|
```qml
|
||||||
|
ListSettingWithInput {
|
||||||
|
settingKey: "locations" // Required: storage key
|
||||||
|
label: "Locations" // Required: display label
|
||||||
|
description: "Track zones" // Optional: help text
|
||||||
|
defaultValue: [] // Optional: default array
|
||||||
|
fields: [ // Required: field definitions
|
||||||
|
{
|
||||||
|
id: "name", // Required: key in saved object
|
||||||
|
label: "Name", // Required: column header
|
||||||
|
placeholder: "Home", // Optional: input placeholder
|
||||||
|
width: 150, // Optional: column width (default: 200)
|
||||||
|
required: true // Optional: must have value to add
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "timezone",
|
||||||
|
label: "Timezone",
|
||||||
|
placeholder: "America/New_York",
|
||||||
|
width: 200,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Automatically generates: column headers, input fields, add button with validation, list display, remove buttons.
|
||||||
|
|
||||||
|
## Mixing Custom UI with Settings
|
||||||
|
|
||||||
|
You can interleave regular QML elements with setting components:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
PluginSettings {
|
||||||
|
pluginId: "myPlugin"
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
width: parent.width
|
||||||
|
text: "General Settings"
|
||||||
|
font.pixelSize: Theme.fontSizeLarge
|
||||||
|
font.weight: Font.Bold
|
||||||
|
color: Theme.surfaceText
|
||||||
|
}
|
||||||
|
|
||||||
|
StringSetting {
|
||||||
|
settingKey: "name"
|
||||||
|
label: "Display Name"
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
width: parent.width
|
||||||
|
text: "Advanced Settings"
|
||||||
|
font.pixelSize: Theme.fontSizeLarge
|
||||||
|
font.weight: Font.Bold
|
||||||
|
color: Theme.surfaceText
|
||||||
|
topPadding: Theme.spacingL
|
||||||
|
}
|
||||||
|
|
||||||
|
ToggleSetting {
|
||||||
|
settingKey: "debug"
|
||||||
|
label: "Debug Mode"
|
||||||
|
defaultValue: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Default Values
|
||||||
|
|
||||||
|
Define sensible defaults in every setting component. The default is used when no saved value exists:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
StringSetting { settingKey: "text"; defaultValue: "Hello" }
|
||||||
|
ToggleSetting { settingKey: "enabled"; defaultValue: true }
|
||||||
|
SelectionSetting { settingKey: "mode"; defaultValue: "auto" }
|
||||||
|
SliderSetting { settingKey: "opacity"; defaultValue: 85 }
|
||||||
|
ColorSetting { settingKey: "color"; defaultValue: "#ff5722" }
|
||||||
|
ListSetting { settingKey: "items"; defaultValue: [] }
|
||||||
|
ListSettingWithInput { settingKey: "data"; defaultValue: [] }
|
||||||
|
```
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
# Theme Property Reference
|
||||||
|
|
||||||
|
All theme properties are accessed via the `Theme` singleton from `qs.Common`. Always use these instead of hardcoded values.
|
||||||
|
|
||||||
|
## Font Sizes
|
||||||
|
|
||||||
|
```qml
|
||||||
|
Theme.fontSizeSmall // 12px (scaled by SettingsData.fontScale)
|
||||||
|
Theme.fontSizeMedium // 14px (scaled)
|
||||||
|
Theme.fontSizeLarge // 16px (scaled)
|
||||||
|
Theme.fontSizeXLarge // 20px (scaled)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Icon Sizes
|
||||||
|
|
||||||
|
```qml
|
||||||
|
Theme.iconSizeSmall // 16px
|
||||||
|
Theme.iconSize // 24px (default)
|
||||||
|
Theme.iconSizeLarge // 32px
|
||||||
|
```
|
||||||
|
|
||||||
|
## Spacing
|
||||||
|
|
||||||
|
```qml
|
||||||
|
Theme.spacingXS // Extra small
|
||||||
|
Theme.spacingS // Small
|
||||||
|
Theme.spacingM // Medium
|
||||||
|
Theme.spacingL // Large
|
||||||
|
Theme.spacingXL // Extra large
|
||||||
|
```
|
||||||
|
|
||||||
|
## Border Radius
|
||||||
|
|
||||||
|
```qml
|
||||||
|
Theme.cornerRadius // Standard
|
||||||
|
Theme.cornerRadiusSmall // Smaller
|
||||||
|
Theme.cornerRadiusLarge // Larger
|
||||||
|
```
|
||||||
|
|
||||||
|
## Surface Colors
|
||||||
|
|
||||||
|
```qml
|
||||||
|
Theme.surface
|
||||||
|
Theme.surfaceContainerLow
|
||||||
|
Theme.surfaceContainer
|
||||||
|
Theme.surfaceContainerHigh
|
||||||
|
Theme.surfaceContainerHighest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Text Colors
|
||||||
|
|
||||||
|
```qml
|
||||||
|
Theme.onSurface // Primary text on surface
|
||||||
|
Theme.onSurfaceVariant // Secondary text on surface
|
||||||
|
Theme.surfaceText // Alias for primary surface text
|
||||||
|
Theme.surfaceVariantText // Alias for secondary surface text
|
||||||
|
Theme.outline // Border/divider color
|
||||||
|
```
|
||||||
|
|
||||||
|
## Semantic Colors
|
||||||
|
|
||||||
|
```qml
|
||||||
|
Theme.primary
|
||||||
|
Theme.onPrimary
|
||||||
|
Theme.secondary
|
||||||
|
Theme.onSecondary
|
||||||
|
Theme.error
|
||||||
|
Theme.errorHover
|
||||||
|
Theme.errorText
|
||||||
|
Theme.warning
|
||||||
|
Theme.success
|
||||||
|
```
|
||||||
|
|
||||||
|
## Special Functions
|
||||||
|
|
||||||
|
```qml
|
||||||
|
Theme.popupBackground() // Popup background with proper opacity
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Widget Patterns
|
||||||
|
|
||||||
|
### Icon with Text
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import qs.Widgets
|
||||||
|
|
||||||
|
Row {
|
||||||
|
spacing: Theme.spacingS
|
||||||
|
|
||||||
|
DankIcon {
|
||||||
|
name: "icon_name"
|
||||||
|
color: Theme.onSurface
|
||||||
|
font.pixelSize: Theme.iconSize
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
text: "Label"
|
||||||
|
color: Theme.onSurface
|
||||||
|
font.pixelSize: Theme.fontSizeMedium
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Container with Border
|
||||||
|
|
||||||
|
```qml
|
||||||
|
Rectangle {
|
||||||
|
color: Theme.surfaceContainerHigh
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hover Effect
|
||||||
|
|
||||||
|
```qml
|
||||||
|
Rectangle {
|
||||||
|
id: container
|
||||||
|
color: Theme.surfaceContainerHigh
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
onEntered: container.color = Qt.lighter(Theme.surfaceContainerHigh, 1.1)
|
||||||
|
onExited: container.color = Theme.surfaceContainerHigh
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Clickable Pill
|
||||||
|
|
||||||
|
```qml
|
||||||
|
StyledRect {
|
||||||
|
width: content.implicitWidth + Theme.spacingM * 2
|
||||||
|
height: parent.widgetThickness
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: mouseArea.containsMouse
|
||||||
|
? Qt.lighter(Theme.surfaceContainerHigh, 1.1)
|
||||||
|
: Theme.surfaceContainerHigh
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: mouseArea
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: content
|
||||||
|
anchors.centerIn: parent
|
||||||
|
spacing: Theme.spacingS
|
||||||
|
|
||||||
|
DankIcon {
|
||||||
|
name: "star"
|
||||||
|
color: Theme.surfaceText
|
||||||
|
font.pixelSize: Theme.iconSize
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
text: "Label"
|
||||||
|
color: Theme.surfaceText
|
||||||
|
font.pixelSize: Theme.fontSizeMedium
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Mistakes
|
||||||
|
|
||||||
|
**Wrong property names** (these do NOT exist):
|
||||||
|
```qml
|
||||||
|
Theme.fontSizeS // Use Theme.fontSizeSmall
|
||||||
|
Theme.iconSizeS // Use Theme.iconSizeSmall
|
||||||
|
Theme.spacingSmall // Use Theme.spacingS
|
||||||
|
Theme.borderRadius // Use Theme.cornerRadius
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hardcoded values** (do NOT do this):
|
||||||
|
```qml
|
||||||
|
color: "#1e1e1e" // Use Theme.surfaceContainerHigh
|
||||||
|
color: "white" // Use Theme.surfaceText
|
||||||
|
font.pixelSize: 14 // Use Theme.fontSizeMedium
|
||||||
|
```
|
||||||
|
|
||||||
|
## Available Widgets from qs.Widgets
|
||||||
|
|
||||||
|
| Widget | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `StyledText` | Themed text with proper color defaults |
|
||||||
|
| `StyledRect` | Themed rectangle |
|
||||||
|
| `DankIcon` | Material Symbols icon renderer |
|
||||||
|
| `DankNFIcon` | Nerd Font icon renderer |
|
||||||
|
| `DankButton` | Themed button |
|
||||||
|
| `DankToggle` | Toggle switch |
|
||||||
|
| `DankTextField` | Text input field |
|
||||||
|
| `DankSlider` | Slider control |
|
||||||
|
| `DankDropdown` | Dropdown menu |
|
||||||
|
| `DankGridView` | Grid layout view |
|
||||||
|
| `DankListView` | List layout view |
|
||||||
|
| `DankFlickable` | Scrollable container |
|
||||||
|
| `DankTabBar` | Tab bar navigation |
|
||||||
|
| `DankCollapsibleSection` | Collapsible content section |
|
||||||
|
| `DankTooltip` | Hover tooltip |
|
||||||
|
| `DankNumberStepper` | Number +/- control |
|
||||||
|
| `DankFilterChips` | Filter chip row |
|
||||||
|
| `CachingImage` | Image with disk cache |
|
||||||
|
| `NumericText` | Fixed-width numeric display |
|
||||||
|
|
||||||
|
## Checking All Properties
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep "property" Common/Theme.qml
|
||||||
|
```
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
# Widget Plugin Guide
|
||||||
|
|
||||||
|
Widgets are bar plugins that display pills in DankBar, optionally open popouts, and can integrate with the Control Center.
|
||||||
|
|
||||||
|
## Base Component
|
||||||
|
|
||||||
|
Widgets use `PluginComponent` from `qs.Modules.Plugins`.
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import QtQuick
|
||||||
|
import qs.Common
|
||||||
|
import qs.Widgets
|
||||||
|
import qs.Modules.Plugins
|
||||||
|
|
||||||
|
PluginComponent {
|
||||||
|
property var popoutService: null
|
||||||
|
|
||||||
|
horizontalBarPill: Component { /* ... */ }
|
||||||
|
verticalBarPill: Component { /* ... */ }
|
||||||
|
popoutContent: Component { /* ... */ }
|
||||||
|
popoutWidth: 400
|
||||||
|
popoutHeight: 300
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Injected Properties
|
||||||
|
|
||||||
|
These are automatically set by the plugin host:
|
||||||
|
|
||||||
|
| Property | Type | Description |
|
||||||
|
|----------|------|-------------|
|
||||||
|
| `axis` | object | Bar axis info (horizontal/vertical) |
|
||||||
|
| `section` | string | Bar section: `"left"`, `"center"`, or `"right"` |
|
||||||
|
| `parentScreen` | object | Screen reference for multi-monitor |
|
||||||
|
| `widgetThickness` | real | Widget size perpendicular to bar edge |
|
||||||
|
| `barThickness` | real | Bar thickness parallel to edge |
|
||||||
|
| `pluginId` | string | This plugin's ID |
|
||||||
|
| `pluginService` | object | PluginService reference |
|
||||||
|
| `pluginData` | object | Reactive plugin settings data |
|
||||||
|
|
||||||
|
## Bar Pills
|
||||||
|
|
||||||
|
Define `horizontalBarPill` (for top/bottom bars) and `verticalBarPill` (for left/right bars).
|
||||||
|
|
||||||
|
### Horizontal Bar Pill
|
||||||
|
|
||||||
|
```qml
|
||||||
|
horizontalBarPill: Component {
|
||||||
|
StyledRect {
|
||||||
|
width: content.implicitWidth + Theme.spacingM * 2
|
||||||
|
height: parent.widgetThickness
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: Theme.surfaceContainerHigh
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: content
|
||||||
|
anchors.centerIn: parent
|
||||||
|
spacing: Theme.spacingS
|
||||||
|
|
||||||
|
DankIcon {
|
||||||
|
name: "star"
|
||||||
|
color: Theme.surfaceText
|
||||||
|
font.pixelSize: Theme.iconSize
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
text: "Label"
|
||||||
|
color: Theme.surfaceText
|
||||||
|
font.pixelSize: Theme.fontSizeMedium
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vertical Bar Pill
|
||||||
|
|
||||||
|
```qml
|
||||||
|
verticalBarPill: Component {
|
||||||
|
StyledRect {
|
||||||
|
width: parent.widgetThickness
|
||||||
|
height: content.implicitHeight + Theme.spacingM * 2
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: Theme.surfaceContainerHigh
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: content
|
||||||
|
anchors.centerIn: parent
|
||||||
|
spacing: Theme.spacingS
|
||||||
|
|
||||||
|
DankIcon {
|
||||||
|
name: "star"
|
||||||
|
color: Theme.surfaceText
|
||||||
|
font.pixelSize: Theme.iconSizeSmall
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important:** Always define both pills. If a pill is missing, the widget disappears when the bar is on that orientation's edge.
|
||||||
|
|
||||||
|
## Popout Content
|
||||||
|
|
||||||
|
Open a popout window when the bar pill is clicked:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
PluginComponent {
|
||||||
|
popoutWidth: 400
|
||||||
|
popoutHeight: 300
|
||||||
|
|
||||||
|
popoutContent: Component {
|
||||||
|
PopoutComponent {
|
||||||
|
headerText: "My Plugin"
|
||||||
|
detailsText: "Optional subtitle"
|
||||||
|
showCloseButton: true
|
||||||
|
|
||||||
|
Column {
|
||||||
|
width: parent.width
|
||||||
|
spacing: Theme.spacingM
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
text: "Content here"
|
||||||
|
color: Theme.surfaceText
|
||||||
|
font.pixelSize: Theme.fontSizeMedium
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**PopoutComponent properties:**
|
||||||
|
|
||||||
|
| Property | Type | Default | Description |
|
||||||
|
|----------|------|---------|-------------|
|
||||||
|
| `headerText` | string | `""` | Main header (bold, large). Hidden if empty. |
|
||||||
|
| `detailsText` | string | `""` | Subtitle below header. Hidden if empty. |
|
||||||
|
| `showCloseButton` | bool | `false` | Show X button in top-right corner. |
|
||||||
|
| `closePopout` | function | (injected) | Call to close the popout programmatically. |
|
||||||
|
| `headerHeight` | int | (readonly) | Height of header area (0 if hidden). |
|
||||||
|
| `detailsHeight` | int | (readonly) | Height of details area (0 if hidden). |
|
||||||
|
|
||||||
|
**Content sizing:** Content children render below the header/details. Calculate available height: `popoutHeight - headerHeight - detailsHeight - spacing`
|
||||||
|
|
||||||
|
## Custom Click Actions
|
||||||
|
|
||||||
|
Override the default popout behavior:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
PluginComponent {
|
||||||
|
// Simple no-args handler
|
||||||
|
pillClickAction: () => {
|
||||||
|
popoutService?.toggleControlCenter()
|
||||||
|
}
|
||||||
|
|
||||||
|
// With position params (x, y, width, section, screen)
|
||||||
|
pillClickAction: (x, y, width, section, screen) => {
|
||||||
|
popoutService?.toggleControlCenter(x, y, width, section, screen)
|
||||||
|
}
|
||||||
|
|
||||||
|
pillRightClickAction: () => {
|
||||||
|
popoutService?.openSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Control Center Integration
|
||||||
|
|
||||||
|
Add CC properties to show your widget in the Control Center grid:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
PluginComponent {
|
||||||
|
ccWidgetIcon: "toggle_on"
|
||||||
|
ccWidgetPrimaryText: "Feature Name"
|
||||||
|
ccWidgetSecondaryText: isActive ? "Active" : "Off"
|
||||||
|
ccWidgetIsActive: isActive
|
||||||
|
|
||||||
|
onCcWidgetToggled: {
|
||||||
|
isActive = !isActive
|
||||||
|
pluginService?.savePluginData(pluginId, "active", isActive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**CC properties:**
|
||||||
|
|
||||||
|
| Property | Type | Description |
|
||||||
|
|----------|------|-------------|
|
||||||
|
| `ccWidgetIcon` | string | Material icon name |
|
||||||
|
| `ccWidgetPrimaryText` | string | Main label |
|
||||||
|
| `ccWidgetSecondaryText` | string | Subtitle / status text |
|
||||||
|
| `ccWidgetIsActive` | bool | Active state (changes styling) |
|
||||||
|
|
||||||
|
**CC signals:**
|
||||||
|
|
||||||
|
| Signal | When fired |
|
||||||
|
|--------|-----------|
|
||||||
|
| `ccWidgetToggled()` | Icon area clicked |
|
||||||
|
| `ccWidgetExpanded()` | Expand area clicked (CompoundPill only) |
|
||||||
|
|
||||||
|
**CC sizing rules:**
|
||||||
|
- 25% width - SmallToggleButton (icon only)
|
||||||
|
- 50% width - ToggleButton (no detail) or CompoundPill (with ccDetailContent)
|
||||||
|
- Users can resize in CC edit mode
|
||||||
|
|
||||||
|
### Detail Content (CompoundPill)
|
||||||
|
|
||||||
|
Add an expandable panel below the CC widget:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
ccDetailContent: Component {
|
||||||
|
Rectangle {
|
||||||
|
implicitHeight: 200
|
||||||
|
color: Theme.surfaceContainerHigh
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
|
||||||
|
Column {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Theme.spacingM
|
||||||
|
spacing: Theme.spacingS
|
||||||
|
|
||||||
|
// Detail UI here
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Visibility Control
|
||||||
|
|
||||||
|
Conditionally show/hide the bar pill:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
PluginComponent {
|
||||||
|
visibilityCommand: "pgrep -x myapp"
|
||||||
|
visibilityInterval: 5000 // check every 5 seconds
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Popout Namespace
|
||||||
|
|
||||||
|
For plugins with multiple popout instances, use `layerNamespacePlugin` to isolate popout state:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
PluginComponent {
|
||||||
|
layerNamespacePlugin: true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reading Plugin Data
|
||||||
|
|
||||||
|
Access saved settings reactively via the injected `pluginData`:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
PluginComponent {
|
||||||
|
property string displayText: pluginData?.text || "Default"
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: pluginService
|
||||||
|
function onPluginDataChanged(changedId) {
|
||||||
|
if (changedId === pluginId)
|
||||||
|
displayText = pluginService.loadPluginData(pluginId, "text", "Default")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Example
|
||||||
|
|
||||||
|
Based on the ExampleEmojiPlugin pattern:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import qs.Common
|
||||||
|
import qs.Widgets
|
||||||
|
import qs.Services
|
||||||
|
import qs.Modules.Plugins
|
||||||
|
|
||||||
|
PluginComponent {
|
||||||
|
id: root
|
||||||
|
property var popoutService: null
|
||||||
|
|
||||||
|
property var emojis: ["star", "heart", "smile"]
|
||||||
|
property int currentIndex: 0
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
interval: 2000
|
||||||
|
running: true
|
||||||
|
repeat: true
|
||||||
|
onTriggered: currentIndex = (currentIndex + 1) % emojis.length
|
||||||
|
}
|
||||||
|
|
||||||
|
popoutWidth: 350
|
||||||
|
popoutHeight: 400
|
||||||
|
|
||||||
|
horizontalBarPill: Component {
|
||||||
|
StyledRect {
|
||||||
|
width: label.implicitWidth + Theme.spacingM * 2
|
||||||
|
height: parent.widgetThickness
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: Theme.surfaceContainerHigh
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
id: label
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: root.emojis[root.currentIndex]
|
||||||
|
font.pixelSize: Theme.fontSizeLarge
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
verticalBarPill: Component {
|
||||||
|
StyledRect {
|
||||||
|
width: parent.widgetThickness
|
||||||
|
height: label.implicitHeight + Theme.spacingM * 2
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: Theme.surfaceContainerHigh
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
id: label
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: root.emojis[root.currentIndex]
|
||||||
|
font.pixelSize: Theme.fontSizeMedium
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
popoutContent: Component {
|
||||||
|
PopoutComponent {
|
||||||
|
headerText: "Emoji Picker"
|
||||||
|
showCloseButton: true
|
||||||
|
|
||||||
|
DankGridView {
|
||||||
|
width: parent.width
|
||||||
|
height: 300
|
||||||
|
cellWidth: 50
|
||||||
|
cellHeight: 50
|
||||||
|
model: root.emojis
|
||||||
|
|
||||||
|
delegate: Rectangle {
|
||||||
|
width: 48
|
||||||
|
height: 48
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: mouseArea.containsMouse ? Theme.surfaceContainerHighest : "transparent"
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: modelData
|
||||||
|
font.pixelSize: 24
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: mouseArea
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
onClicked: {
|
||||||
|
Quickshell.execDetached(["dms", "cl", "copy", modelData])
|
||||||
|
ToastService?.showInfo("Copied " + modelData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Check nix flake
|
name: Nix flake and NixOS tests
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
@@ -9,6 +9,7 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
check-flake:
|
check-flake:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 120
|
||||||
steps:
|
steps:
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -18,6 +19,25 @@ jobs:
|
|||||||
|
|
||||||
- name: Install Nix
|
- name: Install Nix
|
||||||
uses: cachix/install-nix-action@v31
|
uses: cachix/install-nix-action@v31
|
||||||
|
with:
|
||||||
|
enable_kvm: true
|
||||||
|
extra_nix_config: |
|
||||||
|
system-features = nixos-test benchmark big-parallel kvm
|
||||||
|
|
||||||
- name: Check the flake
|
- name: Check the flake
|
||||||
run: nix flake check
|
run: nix flake check -L
|
||||||
|
|
||||||
|
- name: Run NixOS module test
|
||||||
|
run: nix build .#nixosTests.x86_64-linux.nixos-module -L
|
||||||
|
|
||||||
|
- name: Run NixOS service start test
|
||||||
|
run: nix build .#nixosTests.x86_64-linux.nixos-service-start-module -L
|
||||||
|
|
||||||
|
- name: Run greeter niri test
|
||||||
|
run: nix build .#nixosTests.x86_64-linux.greeter-niri-module -L
|
||||||
|
|
||||||
|
- name: Run home-manager module test
|
||||||
|
run: nix build .#nixosTests.x86_64-linux.home-manager-module -L
|
||||||
|
|
||||||
|
- name: Run niri home-manager module test
|
||||||
|
run: nix build .#nixosTests.x86_64-linux.niri-home-module -L
|
||||||
|
|||||||
@@ -367,6 +367,16 @@ jobs:
|
|||||||
EOF
|
EOF
|
||||||
chmod 600 ~/.config/osc/oscrc
|
chmod 600 ~/.config/osc/oscrc
|
||||||
|
|
||||||
|
# Cache OBS bundled Go toolchains
|
||||||
|
- name: Cache OBS bundled Go toolchains (dms-git)
|
||||||
|
if: contains(steps.packages.outputs.packages, 'dms-git')
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: /home/runner/.cache/dms-obs-go-toolchain
|
||||||
|
key: dms-obs-go-toolchain-${{ runner.os }}-${{ hashFiles('core/go.mod') }}
|
||||||
|
restore-keys: |
|
||||||
|
dms-obs-go-toolchain-${{ runner.os }}-
|
||||||
|
|
||||||
- name: Upload to OBS
|
- name: Upload to OBS
|
||||||
id: upload
|
id: upload
|
||||||
env:
|
env:
|
||||||
|
|||||||
+58
-198
@@ -22,12 +22,13 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check-updates:
|
check-updates:
|
||||||
name: Check for updates
|
name: Check package/series updates
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
outputs:
|
outputs:
|
||||||
has_updates: ${{ steps.check.outputs.has_updates }}
|
has_updates: ${{ steps.check.outputs.has_updates }}
|
||||||
packages: ${{ steps.check.outputs.packages }}
|
targets: ${{ steps.check.outputs.targets }}
|
||||||
|
targets_json: ${{ steps.check.outputs.targets_json }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -35,125 +36,57 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y jq curl git
|
||||||
|
|
||||||
- name: Check for updates
|
- name: Check for updates
|
||||||
id: check
|
id: check
|
||||||
run: |
|
run: |
|
||||||
# Helper function to check dms-git commit
|
chmod +x distro/scripts/ppa-sync-plan.sh
|
||||||
check_dms_git() {
|
|
||||||
local CURRENT_COMMIT=$(git rev-parse --short=8 HEAD)
|
|
||||||
local PPA_VERSION=$(curl -s "https://api.launchpad.net/1.0/~avengemedia/+archive/ubuntu/dms-git?ws.op=getPublishedSources&source_name=dms-git&status=Published" | grep -oP '"source_package_version":\s*"\K[^"]+' | head -1 || echo "")
|
|
||||||
local PPA_COMMIT=$(echo "$PPA_VERSION" | grep -oP '\.[a-f0-9]{8}' | tr -d '.' || echo "")
|
|
||||||
|
|
||||||
if [[ -n "$PPA_COMMIT" && "$CURRENT_COMMIT" == "$PPA_COMMIT" ]]; then
|
|
||||||
echo "📋 dms-git: Commit $CURRENT_COMMIT already exists, skipping"
|
|
||||||
return 1 # No update needed
|
|
||||||
else
|
|
||||||
echo "📋 dms-git: New commit $CURRENT_COMMIT (PPA has ${PPA_COMMIT:-none})"
|
|
||||||
return 0 # Update needed
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Helper function to check stable package tag
|
|
||||||
check_stable_package() {
|
|
||||||
local PKG="$1"
|
|
||||||
local PPA_NAME="$2"
|
|
||||||
# Use git ls-remote to find the latest tag, sorted by version (descending)
|
|
||||||
local LATEST_TAG=$(git ls-remote --tags --refs --sort='-v:refname' https://github.com/AvengeMedia/DankMaterialShell.git | head -n1 | awk -F/ '{print $NF}' | sed 's/^v//')
|
|
||||||
local PPA_VERSION=$(curl -s "https://api.launchpad.net/1.0/~avengemedia/+archive/ubuntu/$PPA_NAME?ws.op=getPublishedSources&source_name=$PKG&status=Published" | grep -oP '"source_package_version":\s*"\K[^"]+' | head -1 || echo "")
|
|
||||||
local PPA_BASE_VERSION=$(echo "$PPA_VERSION" | sed 's/ppa[0-9]*$//')
|
|
||||||
|
|
||||||
if [[ -n "$LATEST_TAG" && "$LATEST_TAG" == "$PPA_BASE_VERSION" ]]; then
|
|
||||||
echo "📋 $PKG: Tag $LATEST_TAG already exists, skipping"
|
|
||||||
return 1 # No update needed
|
|
||||||
else
|
|
||||||
echo "📋 $PKG: New tag ${LATEST_TAG:-unknown} (PPA has ${PPA_BASE_VERSION:-none})"
|
|
||||||
return 0 # Update needed
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Main logic
|
|
||||||
REBUILD="${{ github.event.inputs.rebuild_release }}"
|
|
||||||
|
|
||||||
if [[ "${{ github.event_name }}" == "schedule" ]]; then
|
if [[ "${{ github.event_name }}" == "schedule" ]]; then
|
||||||
# Scheduled run - check dms-git only
|
PACKAGE="dms-git"
|
||||||
echo "packages=dms-git" >> $GITHUB_OUTPUT
|
|
||||||
if check_dms_git; then
|
|
||||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
|
||||||
else
|
else
|
||||||
echo "has_updates=false" >> $GITHUB_OUTPUT
|
PACKAGE="${{ github.event.inputs.package }}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
elif [[ -n "${{ github.event.inputs.package }}" ]]; then
|
REBUILD_RELEASE="${{ github.event.inputs.rebuild_release }}"
|
||||||
# Manual workflow trigger
|
ARGS=(--package "$PACKAGE" --json)
|
||||||
PKG="${{ github.event.inputs.package }}"
|
if [[ -n "$REBUILD_RELEASE" ]]; then
|
||||||
|
ARGS+=(--rebuild "$REBUILD_RELEASE")
|
||||||
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
|
fi
|
||||||
|
|
||||||
elif [[ "$PKG" == "dms-git" ]]; then
|
TARGETS_JSON=$(distro/scripts/ppa-sync-plan.sh "${ARGS[@]}" 2> ppa-audit.log)
|
||||||
if check_dms_git; then
|
cat ppa-audit.log
|
||||||
echo "packages=$PKG" >> $GITHUB_OUTPUT
|
|
||||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
|
||||||
else
|
|
||||||
echo "packages=" >> $GITHUB_OUTPUT
|
|
||||||
echo "has_updates=false" >> $GITHUB_OUTPUT
|
|
||||||
fi
|
|
||||||
|
|
||||||
elif [[ "$PKG" == "dms" ]]; then
|
TARGETS=$(echo "$TARGETS_JSON" | jq -r 'join(" ")')
|
||||||
if check_stable_package "dms" "dms"; then
|
if [[ "$TARGETS_JSON" != "[]" ]]; then
|
||||||
echo "packages=$PKG" >> $GITHUB_OUTPUT
|
echo "has_updates=true" >> "$GITHUB_OUTPUT"
|
||||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
echo "targets=$TARGETS" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "targets_json=$TARGETS_JSON" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Package/series targets: $TARGETS"
|
||||||
else
|
else
|
||||||
echo "packages=" >> $GITHUB_OUTPUT
|
echo "has_updates=false" >> "$GITHUB_OUTPUT"
|
||||||
echo "has_updates=false" >> $GITHUB_OUTPUT
|
echo "targets=" >> "$GITHUB_OUTPUT"
|
||||||
fi
|
echo "targets_json=[]" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "No package/series uploads needed"
|
||||||
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" >> $GITHUB_OUTPUT
|
|
||||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
upload-ppa:
|
upload-ppa:
|
||||||
name: Upload to PPA
|
name: Upload ${{ matrix.target }}
|
||||||
needs: check-updates
|
needs: check-updates
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: needs.check-updates.outputs.has_updates == 'true'
|
if: needs.check-updates.outputs.has_updates == 'true'
|
||||||
|
timeout-minutes: 120
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
target: ${{ fromJson(needs.check-updates.outputs.targets_json) }}
|
||||||
|
concurrency:
|
||||||
|
group: ppa-dms-${{ matrix.target }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -177,7 +110,8 @@ jobs:
|
|||||||
lftp \
|
lftp \
|
||||||
build-essential \
|
build-essential \
|
||||||
fakeroot \
|
fakeroot \
|
||||||
dpkg-dev
|
dpkg-dev \
|
||||||
|
openssh-client
|
||||||
|
|
||||||
- name: Configure GPG
|
- name: Configure GPG
|
||||||
env:
|
env:
|
||||||
@@ -185,106 +119,32 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "$GPG_KEY" | gpg --import
|
echo "$GPG_KEY" | gpg --import
|
||||||
GPG_KEY_ID=$(gpg --list-secret-keys --keyid-format LONG | grep sec | awk '{print $2}' | cut -d'/' -f2)
|
GPG_KEY_ID=$(gpg --list-secret-keys --keyid-format LONG | grep sec | awk '{print $2}' | cut -d'/' -f2)
|
||||||
echo "DEBSIGN_KEYID=$GPG_KEY_ID" >> $GITHUB_ENV
|
echo "DEBSIGN_KEYID=$GPG_KEY_ID" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Determine packages to upload
|
- name: Upload target
|
||||||
id: packages
|
env:
|
||||||
|
TARGET: ${{ matrix.target }}
|
||||||
|
LAUNCHPAD_SSH_PRIVATE_KEY: ${{ secrets.LAUNCHPAD_SSH_PRIVATE_KEY }}
|
||||||
|
LAUNCHPAD_SSH_LOGIN: ${{ secrets.LAUNCHPAD_SSH_LOGIN }}
|
||||||
run: |
|
run: |
|
||||||
# Use packages determined by check-updates job
|
IFS=':' read -r PACKAGE UBUNTU_SERIES PPA_NUM <<< "$TARGET"
|
||||||
echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
if [[ "${{ github.event_name }}" == "schedule" ]]; then
|
case "$PACKAGE" in
|
||||||
echo "Triggered by schedule: uploading git package"
|
dms) PPA_NAME="dms" ;;
|
||||||
elif [[ -n "${{ github.event.inputs.package }}" ]]; then
|
dms-git) PPA_NAME="dms-git" ;;
|
||||||
echo "Manual trigger: ${{ needs.check-updates.outputs.packages }}"
|
dms-greeter) PPA_NAME="danklinux" ;;
|
||||||
fi
|
*) echo "::error::Unknown package $PACKAGE"; exit 1 ;;
|
||||||
|
|
||||||
- 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 dms-greeter" from "all" check)
|
|
||||||
# Loop through each package and upload
|
|
||||||
for PKG in $PACKAGES; do
|
|
||||||
# Map package to PPA name
|
|
||||||
case "$PKG" in
|
|
||||||
dms)
|
|
||||||
PPA_NAME="dms"
|
|
||||||
;;
|
|
||||||
dms-git)
|
|
||||||
PPA_NAME="dms-git"
|
|
||||||
;;
|
|
||||||
dms-greeter)
|
|
||||||
PPA_NAME="danklinux"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "⚠️ Unknown package: $PKG, skipping"
|
|
||||||
continue
|
|
||||||
;;
|
|
||||||
esac
|
esac
|
||||||
|
|
||||||
echo ""
|
echo "Uploading $PACKAGE to $PPA_NAME/$UBUNTU_SERIES with ppa$PPA_NUM"
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
bash distro/scripts/ppa-upload.sh "$PACKAGE" "$PPA_NAME" "$UBUNTU_SERIES" "$PPA_NUM"
|
||||||
echo "Uploading $PKG to PPA $PPA_NAME..."
|
|
||||||
if [[ -n "$REBUILD_RELEASE" ]]; then
|
|
||||||
echo "🔄 Using rebuild release number: ppa$REBUILD_RELEASE"
|
|
||||||
fi
|
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
||||||
# ppa-upload.sh uploads to questing + resolute when series is omitted
|
|
||||||
if ! bash distro/scripts/ppa-upload.sh "$PKG" "$PPA_NAME" ${REBUILD_RELEASE:+"$REBUILD_RELEASE"}; then
|
|
||||||
echo "::error::Upload failed for $PKG"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Summary
|
- name: Summary
|
||||||
if: always()
|
if: always()
|
||||||
run: |
|
run: |
|
||||||
echo "### PPA Package Upload Summary" >> $GITHUB_STEP_SUMMARY
|
echo "### PPA Package Upload" >> "$GITHUB_STEP_SUMMARY"
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
echo "- **Target:** ${{ matrix.target }}" >> "$GITHUB_STEP_SUMMARY"
|
||||||
PACKAGES="${{ steps.packages.outputs.packages }}"
|
echo "- **DMS PPA:** https://launchpad.net/~avengemedia/+archive/ubuntu/dms/+packages" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
echo "- **DMS-Git PPA:** https://launchpad.net/~avengemedia/+archive/ubuntu/dms-git/+packages" >> "$GITHUB_STEP_SUMMARY"
|
||||||
if [[ -z "$PACKAGES" ]]; then
|
echo "- **DankLinux PPA:** https://launchpad.net/~avengemedia/+archive/ubuntu/danklinux/+packages" >> "$GITHUB_STEP_SUMMARY"
|
||||||
echo "**Status:** ✅ All packages up to date (no uploads needed)" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "All packages are current. Run will complete successfully." >> $GITHUB_STEP_SUMMARY
|
|
||||||
else
|
|
||||||
echo "**Packages Uploaded:**" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
|
||||||
|
|
||||||
for PKG in $PACKAGES; do
|
|
||||||
case "$PKG" in
|
|
||||||
dms)
|
|
||||||
echo "- ✅ **dms** → [View builds](https://launchpad.net/~avengemedia/+archive/ubuntu/dms/+packages)" >> $GITHUB_STEP_SUMMARY
|
|
||||||
;;
|
|
||||||
dms-git)
|
|
||||||
echo "- ✅ **dms-git** → [View builds](https://launchpad.net/~avengemedia/+archive/ubuntu/dms-git/+packages)" >> $GITHUB_STEP_SUMMARY
|
|
||||||
;;
|
|
||||||
dms-greeter)
|
|
||||||
echo "- ✅ **dms-greeter** → [View builds](https://launchpad.net/~avengemedia/+archive/ubuntu/danklinux/+packages)" >> $GITHUB_STEP_SUMMARY
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
|
||||||
|
|
||||||
if [[ -n "${{ github.event.inputs.rebuild_release }}" ]]; then
|
|
||||||
echo "**Rebuild Number:** ppa${{ github.event.inputs.rebuild_release }}" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Builds will appear once Launchpad processes the uploads." >> $GITHUB_STEP_SUMMARY
|
|
||||||
fi
|
|
||||||
|
|||||||
@@ -107,6 +107,9 @@ vim/
|
|||||||
|
|
||||||
bin/
|
bin/
|
||||||
|
|
||||||
|
# Core dumps
|
||||||
|
core.*
|
||||||
|
|
||||||
# direnv
|
# direnv
|
||||||
.envrc
|
.envrc
|
||||||
.direnv/
|
.direnv/
|
||||||
|
|||||||
@@ -20,3 +20,11 @@ repos:
|
|||||||
language: system
|
language: system
|
||||||
files: ^core/.*\.(go|mod|sum)$
|
files: ^core/.*\.(go|mod|sum)$
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
|
- repo: local
|
||||||
|
hooks:
|
||||||
|
- id: no-console-in-qml
|
||||||
|
name: no console.* in QML (use Log service)
|
||||||
|
entry: bash -c 'if grep -nE "console\.(log|error|info|warn|debug)" "$@"; then echo "Use the Log service (log.info/warn/error/debug/fatal) instead of console.*" >&2; exit 1; fi' --
|
||||||
|
language: system
|
||||||
|
files: ^quickshell/.*\.qml$
|
||||||
|
exclude: ^quickshell/(Services/Log\.qml$|dms-plugins/|PLUGINS/)
|
||||||
|
|||||||
@@ -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/stargazers)
|
||||||
[](https://github.com/AvengeMedia/DankMaterialShell/blob/master/LICENSE)
|
[](https://github.com/AvengeMedia/DankMaterialShell/blob/master/LICENSE)
|
||||||
[](https://github.com/AvengeMedia/DankMaterialShell/releases)
|
[](https://github.com/AvengeMedia/DankMaterialShell/releases)
|
||||||
[](https://aur.archlinux.org/packages/dms-shell-bin)
|
[](https://archlinux.org/packages/extra/x86_64/dms-shell/)
|
||||||
[>)](https://aur.archlinux.org/packages/dms-shell-git)
|
[>)](https://aur.archlinux.org/packages/dms-shell-git)
|
||||||
[](https://ko-fi.com/danklinux)
|
[](https://ko-fi.com/danklinux)
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,13 @@
|
|||||||
repos:
|
repos:
|
||||||
- repo: local
|
- repo: https://github.com/golangci/golangci-lint
|
||||||
|
rev: v2.10.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: golangci-lint-fmt
|
- id: golangci-lint-fmt
|
||||||
name: golangci-lint-fmt
|
|
||||||
entry: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.3 fmt
|
|
||||||
language: system
|
|
||||||
require_serial: true
|
require_serial: true
|
||||||
types: [go]
|
|
||||||
pass_filenames: false
|
|
||||||
- id: golangci-lint-full
|
- id: golangci-lint-full
|
||||||
name: golangci-lint-full
|
|
||||||
entry: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.3 run --fix
|
|
||||||
language: system
|
|
||||||
require_serial: true
|
|
||||||
types: [go]
|
|
||||||
pass_filenames: false
|
|
||||||
- id: golangci-lint-config-verify
|
- id: golangci-lint-config-verify
|
||||||
name: golangci-lint-config-verify
|
- repo: local
|
||||||
entry: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.3 config verify
|
hooks:
|
||||||
language: system
|
|
||||||
files: \.golangci\.(?:yml|yaml|toml|json)
|
|
||||||
pass_filenames: false
|
|
||||||
- id: go-test
|
- id: go-test
|
||||||
name: go test
|
name: go test
|
||||||
entry: go test ./...
|
entry: go test ./...
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ var authSyncCmd = &cobra.Command{
|
|||||||
Use: "sync",
|
Use: "sync",
|
||||||
Short: "Sync DMS authentication configuration",
|
Short: "Sync DMS authentication configuration",
|
||||||
Long: "Apply shared PAM/authentication changes for the lock screen and greeter based on current DMS settings",
|
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) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
yes, _ := cmd.Flags().GetBool("yes")
|
yes, _ := cmd.Flags().GetBool("yes")
|
||||||
term, _ := cmd.Flags().GetBool("terminal")
|
term, _ := cmd.Flags().GetBool("terminal")
|
||||||
|
|||||||
@@ -236,6 +236,7 @@ func runBrightnessSet(cmd *cobra.Command, args []string) {
|
|||||||
defer ddc.Close()
|
defer ddc.Close()
|
||||||
time.Sleep(100 * time.Millisecond)
|
time.Sleep(100 * time.Millisecond)
|
||||||
if err := ddc.SetBrightnessWithExponent(deviceID, percent, exponential, exponent, nil); err == nil {
|
if err := ddc.SetBrightnessWithExponent(deviceID, percent, exponential, exponent, nil); err == nil {
|
||||||
|
ddc.WaitPending()
|
||||||
fmt.Printf("Set %s to %d%%\n", deviceID, percent)
|
fmt.Printf("Set %s to %d%%\n", deviceID, percent)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/plugins"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/plugins"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server"
|
||||||
@@ -26,6 +27,18 @@ var runCmd = &cobra.Command{
|
|||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
daemon, _ := cmd.Flags().GetBool("daemon")
|
daemon, _ := cmd.Flags().GetBool("daemon")
|
||||||
session, _ := cmd.Flags().GetBool("session")
|
session, _ := cmd.Flags().GetBool("session")
|
||||||
|
if v, _ := cmd.Flags().GetString("log-level"); v != "" {
|
||||||
|
if err := os.Setenv("DMS_LOG_LEVEL", v); err != nil {
|
||||||
|
log.Fatalf("Failed to set DMS_LOG_LEVEL: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v, _ := cmd.Flags().GetString("log-file"); v != "" {
|
||||||
|
if err := os.Setenv("DMS_LOG_FILE", v); err != nil {
|
||||||
|
log.Fatalf("Failed to set DMS_LOG_FILE: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.ApplyEnvOverrides()
|
||||||
|
config.CleanupStrayHyprlandConfFile(log.Infof)
|
||||||
if daemon {
|
if daemon {
|
||||||
runShellDaemon(session)
|
runShellDaemon(session)
|
||||||
} else {
|
} else {
|
||||||
@@ -526,5 +539,8 @@ func getCommonCommands() []*cobra.Command {
|
|||||||
dlCmd,
|
dlCmd,
|
||||||
randrCmd,
|
randrCmd,
|
||||||
blurCmd,
|
blurCmd,
|
||||||
|
trashCmd,
|
||||||
|
systemCmd,
|
||||||
|
switchUserCmd,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/luaconfig"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
@@ -27,7 +28,21 @@ var resolveIncludeCmd = &cobra.Command{
|
|||||||
case 0:
|
case 0:
|
||||||
return []string{"hyprland", "niri", "mangowc"}, cobra.ShellCompDirectiveNoFileComp
|
return []string{"hyprland", "niri", "mangowc"}, cobra.ShellCompDirectiveNoFileComp
|
||||||
case 1:
|
case 1:
|
||||||
return []string{"cursor.kdl", "cursor.conf", "outputs.kdl", "outputs.conf", "binds.kdl", "binds.conf"}, cobra.ShellCompDirectiveNoFileComp
|
return []string{
|
||||||
|
"binds.lua",
|
||||||
|
"binds-user.lua",
|
||||||
|
"colors.lua",
|
||||||
|
"layout.lua",
|
||||||
|
"outputs.lua",
|
||||||
|
"cursor.lua",
|
||||||
|
"windowrules.lua",
|
||||||
|
"cursor.kdl",
|
||||||
|
"outputs.kdl",
|
||||||
|
"binds.kdl",
|
||||||
|
"cursor.conf",
|
||||||
|
"outputs.conf",
|
||||||
|
"binds.conf",
|
||||||
|
}, cobra.ShellCompDirectiveNoFileComp
|
||||||
}
|
}
|
||||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
},
|
},
|
||||||
@@ -82,17 +97,35 @@ func checkHyprlandInclude(filename string) (IncludeResult, error) {
|
|||||||
result.Exists = true
|
result.Exists = true
|
||||||
}
|
}
|
||||||
|
|
||||||
mainConfig := filepath.Join(configDir, "hyprland.conf")
|
targetAbs, err := filepath.Abs(targetPath)
|
||||||
if _, err := os.Stat(mainConfig); os.IsNotExist(err) {
|
if err != nil {
|
||||||
return result, nil
|
return result, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
targetRel := filepath.ToSlash(filepath.Join("dms", filename))
|
||||||
|
|
||||||
|
mainLua := filepath.Join(configDir, "hyprland.lua")
|
||||||
|
if _, err := os.Stat(mainLua); err == nil {
|
||||||
|
processedLua := make(map[string]bool)
|
||||||
|
if luaconfig.RequiresTarget(mainLua, targetAbs, processedLua) {
|
||||||
|
result.Included = true
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mainConf := filepath.Join(configDir, "hyprland.conf")
|
||||||
|
if _, err := os.Stat(mainConf); err == nil {
|
||||||
processed := make(map[string]bool)
|
processed := make(map[string]bool)
|
||||||
result.Included = hyprlandFindInclude(mainConfig, "dms/"+filename, processed)
|
if hyprlandFindIncludeHyprlang(mainConf, targetRel, processed) {
|
||||||
|
result.Included = true
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func hyprlandFindInclude(filePath, target string, processed map[string]bool) bool {
|
func hyprlandFindIncludeHyprlang(filePath, target string, processed map[string]bool) bool {
|
||||||
absPath, err := filepath.Abs(filePath)
|
absPath, err := filepath.Abs(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
@@ -141,7 +174,7 @@ func hyprlandFindInclude(filePath, target string, processed map[string]bool) boo
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if hyprlandFindInclude(expanded, target, processed) {
|
if hyprlandFindIncludeHyprlang(expanded, target, processed) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/blur"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
|
||||||
@@ -90,6 +91,7 @@ var (
|
|||||||
wayfireVersionRegex = regexp.MustCompile(`wayfire (\d+\.\d+)`)
|
wayfireVersionRegex = regexp.MustCompile(`wayfire (\d+\.\d+)`)
|
||||||
labwcVersionRegex = regexp.MustCompile(`labwc (\d+\.\d+\.\d+)`)
|
labwcVersionRegex = regexp.MustCompile(`labwc (\d+\.\d+\.\d+)`)
|
||||||
mangowcVersionRegex = regexp.MustCompile(`mango (\d+\.\d+\.\d+)`)
|
mangowcVersionRegex = regexp.MustCompile(`mango (\d+\.\d+\.\d+)`)
|
||||||
|
miracleVersionRegex = regexp.MustCompile(`miracle-wm v?(\d+\.\d+\.\d+)`)
|
||||||
)
|
)
|
||||||
|
|
||||||
var doctorCmd = &cobra.Command{
|
var doctorCmd = &cobra.Command{
|
||||||
@@ -468,6 +470,7 @@ func checkWindowManagers() []checkResult {
|
|||||||
{"Wayfire", "wayfire", "--version", wayfireVersionRegex, []string{"wayfire"}},
|
{"Wayfire", "wayfire", "--version", wayfireVersionRegex, []string{"wayfire"}},
|
||||||
{"labwc", "labwc", "--version", labwcVersionRegex, []string{"labwc"}},
|
{"labwc", "labwc", "--version", labwcVersionRegex, []string{"labwc"}},
|
||||||
{"mangowc", "mango", "-v", mangowcVersionRegex, []string{"mango"}},
|
{"mangowc", "mango", "-v", mangowcVersionRegex, []string{"mango"}},
|
||||||
|
{"Miracle WM", "miracle-wm", "--version", miracleVersionRegex, []string{"miracle-wm"}},
|
||||||
}
|
}
|
||||||
|
|
||||||
var results []checkResult
|
var results []checkResult
|
||||||
@@ -500,7 +503,7 @@ func checkWindowManagers() []checkResult {
|
|||||||
results = append(results, checkResult{
|
results = append(results, checkResult{
|
||||||
catCompositor, "Compositor", statusError,
|
catCompositor, "Compositor", statusError,
|
||||||
"No supported Wayland compositor found",
|
"No supported Wayland compositor found",
|
||||||
"Install Hyprland, niri, Sway, River, or Wayfire",
|
"Install Hyprland, niri, Sway, River, Wayfire, or miracle-wm",
|
||||||
doctorDocsURL + "#compositor-checks",
|
doctorDocsURL + "#compositor-checks",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -509,9 +512,24 @@ func checkWindowManagers() []checkResult {
|
|||||||
results = append(results, checkResult{catCompositor, "Active", statusInfo, wm, "", doctorDocsURL + "#compositor"})
|
results = append(results, checkResult{catCompositor, "Active", statusInfo, wm, "", doctorDocsURL + "#compositor"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
results = append(results, checkCompositorBlurSupport())
|
||||||
|
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func checkCompositorBlurSupport() checkResult {
|
||||||
|
supported, err := blur.ProbeSupport()
|
||||||
|
if err != nil {
|
||||||
|
return checkResult{catCompositor, "Background Blur", statusInfo, "Unable to verify", err.Error(), doctorDocsURL + "#compositor-checks"}
|
||||||
|
}
|
||||||
|
|
||||||
|
if supported {
|
||||||
|
return checkResult{catCompositor, "Background Blur", statusOK, "Supported", "Compositor supports ext-background-effect-v1", doctorDocsURL + "#compositor-checks"}
|
||||||
|
}
|
||||||
|
|
||||||
|
return checkResult{catCompositor, "Background Blur", statusWarn, "Unsupported", "Compositor does not support ext-background-effect-v1", doctorDocsURL + "#compositor-checks"}
|
||||||
|
}
|
||||||
|
|
||||||
func getVersionFromCommand(cmd, arg string, regex *regexp.Regexp) string {
|
func getVersionFromCommand(cmd, arg string, regex *regexp.Regexp) string {
|
||||||
output, err := exec.Command(cmd, arg).CombinedOutput()
|
output, err := exec.Command(cmd, arg).CombinedOutput()
|
||||||
if err != nil && len(output) == 0 {
|
if err != nil && len(output) == 0 {
|
||||||
@@ -535,6 +553,8 @@ func detectRunningWM() string {
|
|||||||
return "Hyprland"
|
return "Hyprland"
|
||||||
case os.Getenv("NIRI_SOCKET") != "":
|
case os.Getenv("NIRI_SOCKET") != "":
|
||||||
return "niri"
|
return "niri"
|
||||||
|
case os.Getenv("MIRACLESOCK") != "":
|
||||||
|
return "Miracle WM"
|
||||||
case os.Getenv("XDG_CURRENT_DESKTOP") != "":
|
case os.Getenv("XDG_CURRENT_DESKTOP") != "":
|
||||||
return os.Getenv("XDG_CURRENT_DESKTOP")
|
return os.Getenv("XDG_CURRENT_DESKTOP")
|
||||||
}
|
}
|
||||||
@@ -553,6 +573,7 @@ func checkQuickshellFeatures() ([]checkResult, bool) {
|
|||||||
qmlContent := `
|
qmlContent := `
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import Quickshell
|
import Quickshell
|
||||||
|
import Quickshell.Wayland
|
||||||
|
|
||||||
ShellRoot {
|
ShellRoot {
|
||||||
id: root
|
id: root
|
||||||
@@ -561,6 +582,7 @@ ShellRoot {
|
|||||||
property bool idleMonitorAvailable: false
|
property bool idleMonitorAvailable: false
|
||||||
property bool idleInhibitorAvailable: false
|
property bool idleInhibitorAvailable: false
|
||||||
property bool shortcutInhibitorAvailable: false
|
property bool shortcutInhibitorAvailable: false
|
||||||
|
property bool backgroundBlurAvailable: false
|
||||||
|
|
||||||
Timer {
|
Timer {
|
||||||
interval: 50
|
interval: 50
|
||||||
@@ -578,16 +600,18 @@ ShellRoot {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
var testItem = Qt.createQmlObject(
|
var testItem = Qt.createQmlObject(
|
||||||
'import Quickshell.Wayland; import QtQuick; QtObject { ' +
|
'import Quickshell; import Quickshell.Wayland; import QtQuick; QtObject { ' +
|
||||||
'readonly property bool hasIdleMonitor: typeof IdleMonitor !== "undefined"; ' +
|
'readonly property bool hasIdleMonitor: typeof IdleMonitor !== "undefined"; ' +
|
||||||
'readonly property bool hasIdleInhibitor: typeof IdleInhibitor !== "undefined"; ' +
|
'readonly property bool hasIdleInhibitor: typeof IdleInhibitor !== "undefined"; ' +
|
||||||
'readonly property bool hasShortcutInhibitor: typeof ShortcutInhibitor !== "undefined" ' +
|
'readonly property bool hasShortcutInhibitor: typeof ShortcutInhibitor !== "undefined"; ' +
|
||||||
|
'readonly property bool hasBackgroundBlur: typeof BackgroundEffect !== "undefined" ' +
|
||||||
'}',
|
'}',
|
||||||
root
|
root
|
||||||
)
|
)
|
||||||
root.idleMonitorAvailable = testItem.hasIdleMonitor
|
root.idleMonitorAvailable = testItem.hasIdleMonitor
|
||||||
root.idleInhibitorAvailable = testItem.hasIdleInhibitor
|
root.idleInhibitorAvailable = testItem.hasIdleInhibitor
|
||||||
root.shortcutInhibitorAvailable = testItem.hasShortcutInhibitor
|
root.shortcutInhibitorAvailable = testItem.hasShortcutInhibitor
|
||||||
|
root.backgroundBlurAvailable = testItem.hasBackgroundBlur
|
||||||
testItem.destroy()
|
testItem.destroy()
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
@@ -596,6 +620,8 @@ ShellRoot {
|
|||||||
console.warn(root.idleInhibitorAvailable ? "FEATURE:IdleInhibitor:OK" : "FEATURE:IdleInhibitor:UNAVAILABLE")
|
console.warn(root.idleInhibitorAvailable ? "FEATURE:IdleInhibitor:OK" : "FEATURE:IdleInhibitor:UNAVAILABLE")
|
||||||
console.warn(root.shortcutInhibitorAvailable ? "FEATURE:ShortcutInhibitor:OK" : "FEATURE:ShortcutInhibitor:UNAVAILABLE")
|
console.warn(root.shortcutInhibitorAvailable ? "FEATURE:ShortcutInhibitor:OK" : "FEATURE:ShortcutInhibitor:UNAVAILABLE")
|
||||||
|
|
||||||
|
console.warn(root.backgroundBlurAvailable ? "FEATURE:BackgroundBlur:OK" : "FEATURE:BackgroundBlur:UNAVAILABLE")
|
||||||
|
|
||||||
Quickshell.execDetached(["kill", "-TERM", String(Quickshell.processId)])
|
Quickshell.execDetached(["kill", "-TERM", String(Quickshell.processId)])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -616,6 +642,7 @@ ShellRoot {
|
|||||||
{"IdleMonitor", "Idle detection"},
|
{"IdleMonitor", "Idle detection"},
|
||||||
{"IdleInhibitor", "Prevent idle/sleep"},
|
{"IdleInhibitor", "Prevent idle/sleep"},
|
||||||
{"ShortcutInhibitor", "Allow shortcut management (niri)"},
|
{"ShortcutInhibitor", "Allow shortcut management (niri)"},
|
||||||
|
{"BackgroundBlur", "Background blur API support in Quickshell"},
|
||||||
}
|
}
|
||||||
|
|
||||||
var results []checkResult
|
var results []checkResult
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
@@ -15,6 +16,7 @@ import (
|
|||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/version"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/version"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
@@ -130,12 +132,8 @@ func updateArchLinux() error {
|
|||||||
return errdefs.ErrUpdateCancelled
|
return errdefs.ErrUpdateCancelled
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("\nRunning: sudo pacman -S %s\n", packageName)
|
fmt.Printf("\nRunning: pacman -S %s\n", packageName)
|
||||||
cmd := exec.Command("sudo", "pacman", "-S", "--noconfirm", packageName)
|
if err := privesc.Run(context.Background(), "", "pacman", "-S", "--noconfirm", packageName); err != nil {
|
||||||
cmd.Stdin = os.Stdin
|
|
||||||
cmd.Stdout = os.Stdout
|
|
||||||
cmd.Stderr = os.Stderr
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
fmt.Printf("Error: Failed to update using pacman: %v\n", err)
|
fmt.Printf("Error: Failed to update using pacman: %v\n", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -479,11 +477,7 @@ func updateDMSBinary() error {
|
|||||||
|
|
||||||
fmt.Printf("Installing to %s...\n", currentPath)
|
fmt.Printf("Installing to %s...\n", currentPath)
|
||||||
|
|
||||||
replaceCmd := exec.Command("sudo", "install", "-m", "0755", decompressedPath, currentPath)
|
if err := privesc.Run(context.Background(), "", "install", "-m", "0755", decompressedPath, currentPath); err != nil {
|
||||||
replaceCmd.Stdin = os.Stdin
|
|
||||||
replaceCmd.Stdout = os.Stdout
|
|
||||||
replaceCmd.Stderr = os.Stderr
|
|
||||||
if err := replaceCmd.Run(); err != nil {
|
|
||||||
return fmt.Errorf("failed to replace binary: %w", err)
|
return fmt.Errorf("failed to replace binary: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -14,6 +15,7 @@ import (
|
|||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/greeter"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/greeter"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam"
|
sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"golang.org/x/text/cases"
|
"golang.org/x/text/cases"
|
||||||
@@ -35,7 +37,7 @@ var greeterInstallCmd = &cobra.Command{
|
|||||||
Use: "install",
|
Use: "install",
|
||||||
Short: "Install and configure DMS greeter",
|
Short: "Install and configure DMS greeter",
|
||||||
Long: "Install greetd and configure it to use DMS as the greeter interface",
|
Long: "Install greetd and configure it to use DMS as the greeter interface",
|
||||||
PreRunE: requireMutableSystemCommand,
|
PreRunE: preRunPrivileged,
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
yes, _ := cmd.Flags().GetBool("yes")
|
yes, _ := cmd.Flags().GetBool("yes")
|
||||||
term, _ := cmd.Flags().GetBool("terminal")
|
term, _ := cmd.Flags().GetBool("terminal")
|
||||||
@@ -60,6 +62,7 @@ var greeterSyncCmd = &cobra.Command{
|
|||||||
Use: "sync",
|
Use: "sync",
|
||||||
Short: "Sync DMS theme and settings with greeter",
|
Short: "Sync DMS theme and settings with greeter",
|
||||||
Long: "Synchronize your current user's DMS theme, settings, and wallpaper configuration with the login greeter screen",
|
Long: "Synchronize your current user's DMS theme, settings, and wallpaper configuration with the login greeter screen",
|
||||||
|
PreRunE: preRunPrivileged,
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
yes, _ := cmd.Flags().GetBool("yes")
|
yes, _ := cmd.Flags().GetBool("yes")
|
||||||
auth, _ := cmd.Flags().GetBool("auth")
|
auth, _ := cmd.Flags().GetBool("auth")
|
||||||
@@ -88,7 +91,7 @@ var greeterEnableCmd = &cobra.Command{
|
|||||||
Use: "enable",
|
Use: "enable",
|
||||||
Short: "Enable DMS greeter in greetd config",
|
Short: "Enable DMS greeter in greetd config",
|
||||||
Long: "Configure greetd to use DMS as the greeter",
|
Long: "Configure greetd to use DMS as the greeter",
|
||||||
PreRunE: requireMutableSystemCommand,
|
PreRunE: preRunPrivileged,
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
yes, _ := cmd.Flags().GetBool("yes")
|
yes, _ := cmd.Flags().GetBool("yes")
|
||||||
term, _ := cmd.Flags().GetBool("terminal")
|
term, _ := cmd.Flags().GetBool("terminal")
|
||||||
@@ -124,7 +127,7 @@ var greeterUninstallCmd = &cobra.Command{
|
|||||||
Use: "uninstall",
|
Use: "uninstall",
|
||||||
Short: "Remove DMS greeter configuration and restore previous display manager",
|
Short: "Remove DMS greeter configuration and restore previous display manager",
|
||||||
Long: "Disable greetd, remove DMS managed configs, and restore the system to its pre-DMS-greeter state",
|
Long: "Disable greetd, remove DMS managed configs, and restore the system to its pre-DMS-greeter state",
|
||||||
PreRunE: requireMutableSystemCommand,
|
PreRunE: preRunPrivileged,
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
yes, _ := cmd.Flags().GetBool("yes")
|
yes, _ := cmd.Flags().GetBool("yes")
|
||||||
term, _ := cmd.Flags().GetBool("terminal")
|
term, _ := cmd.Flags().GetBool("terminal")
|
||||||
@@ -306,10 +309,7 @@ func uninstallGreeter(nonInteractive bool) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("\nDisabling greetd...")
|
fmt.Println("\nDisabling greetd...")
|
||||||
disableCmd := exec.Command("sudo", "systemctl", "disable", "greetd")
|
if err := privesc.Run(context.Background(), "", "systemctl", "disable", "greetd"); err != nil {
|
||||||
disableCmd.Stdout = os.Stdout
|
|
||||||
disableCmd.Stderr = os.Stderr
|
|
||||||
if err := disableCmd.Run(); err != nil {
|
|
||||||
fmt.Printf(" ⚠ Could not disable greetd: %v\n", err)
|
fmt.Printf(" ⚠ Could not disable greetd: %v\n", err)
|
||||||
} else {
|
} else {
|
||||||
fmt.Println(" ✓ greetd disabled")
|
fmt.Println(" ✓ greetd disabled")
|
||||||
@@ -375,10 +375,10 @@ func restorePreDMSGreetdConfig(sudoPassword string) error {
|
|||||||
}
|
}
|
||||||
tmp.Close()
|
tmp.Close()
|
||||||
|
|
||||||
if err := runSudoCommand(sudoPassword, "cp", tmpPath, configPath); err != nil {
|
if err := privesc.Run(context.Background(), sudoPassword, "cp", tmpPath, configPath); err != nil {
|
||||||
return fmt.Errorf("failed to restore %s: %w", candidate, err)
|
return fmt.Errorf("failed to restore %s: %w", candidate, err)
|
||||||
}
|
}
|
||||||
if err := runSudoCommand(sudoPassword, "chmod", "644", configPath); err != nil {
|
if err := privesc.Run(context.Background(), sudoPassword, "chmod", "644", configPath); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Printf(" ✓ Restored greetd config from %s\n", candidate)
|
fmt.Printf(" ✓ Restored greetd config from %s\n", candidate)
|
||||||
@@ -406,21 +406,14 @@ command = "agreety --cmd /bin/bash"
|
|||||||
}
|
}
|
||||||
tmp.Close()
|
tmp.Close()
|
||||||
|
|
||||||
if err := runSudoCommand(sudoPassword, "cp", tmpPath, configPath); err != nil {
|
if err := privesc.Run(context.Background(), sudoPassword, "cp", tmpPath, configPath); err != nil {
|
||||||
return fmt.Errorf("failed to write fallback greetd config: %w", err)
|
return fmt.Errorf("failed to write fallback greetd config: %w", err)
|
||||||
}
|
}
|
||||||
_ = runSudoCommand(sudoPassword, "chmod", "644", configPath)
|
_ = privesc.Run(context.Background(), sudoPassword, "chmod", "644", configPath)
|
||||||
fmt.Println(" ✓ Wrote minimal fallback greetd config (configure a greeter command manually if needed)")
|
fmt.Println(" ✓ Wrote minimal fallback greetd config (configure a greeter command manually if needed)")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func runSudoCommand(_ string, command string, args ...string) error {
|
|
||||||
cmd := exec.Command("sudo", append([]string{command}, args...)...)
|
|
||||||
cmd.Stdout = os.Stdout
|
|
||||||
cmd.Stderr = os.Stderr
|
|
||||||
return cmd.Run()
|
|
||||||
}
|
|
||||||
|
|
||||||
// suggestDisplayManagerRestore scans for installed DMs and re-enables one
|
// suggestDisplayManagerRestore scans for installed DMs and re-enables one
|
||||||
func suggestDisplayManagerRestore(nonInteractive bool) {
|
func suggestDisplayManagerRestore(nonInteractive bool) {
|
||||||
knownDMs := []string{"gdm", "gdm3", "lightdm", "sddm", "lxdm", "xdm", "cosmic-greeter"}
|
knownDMs := []string{"gdm", "gdm3", "lightdm", "sddm", "lxdm", "xdm", "cosmic-greeter"}
|
||||||
@@ -439,10 +432,7 @@ func suggestDisplayManagerRestore(nonInteractive bool) {
|
|||||||
|
|
||||||
enableDM := func(dm string) {
|
enableDM := func(dm string) {
|
||||||
fmt.Printf(" Enabling %s...\n", dm)
|
fmt.Printf(" Enabling %s...\n", dm)
|
||||||
cmd := exec.Command("sudo", "systemctl", "enable", "--force", dm)
|
if err := privesc.Run(context.Background(), "", "systemctl", "enable", "--force", dm); err != nil {
|
||||||
cmd.Stdout = os.Stdout
|
|
||||||
cmd.Stderr = os.Stderr
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
fmt.Printf(" ⚠ Failed to enable %s: %v\n", dm, err)
|
fmt.Printf(" ⚠ Failed to enable %s: %v\n", dm, err)
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf(" ✓ %s enabled (will take effect on next boot).\n", dm)
|
fmt.Printf(" ✓ %s enabled (will take effect on next boot).\n", dm)
|
||||||
@@ -641,10 +631,7 @@ func syncGreeter(nonInteractive bool, forceAuth bool, local bool) error {
|
|||||||
|
|
||||||
if response != "n" && response != "no" {
|
if response != "n" && response != "no" {
|
||||||
fmt.Printf("\nAdding user to %s group...\n", greeterGroup)
|
fmt.Printf("\nAdding user to %s group...\n", greeterGroup)
|
||||||
addUserCmd := exec.Command("sudo", "usermod", "-aG", greeterGroup, currentUser.Username)
|
if err := privesc.Run(context.Background(), "", "usermod", "-aG", greeterGroup, currentUser.Username); err != nil {
|
||||||
addUserCmd.Stdout = os.Stdout
|
|
||||||
addUserCmd.Stderr = os.Stderr
|
|
||||||
if err := addUserCmd.Run(); err != nil {
|
|
||||||
return fmt.Errorf("failed to add user to %s group: %w", greeterGroup, err)
|
return fmt.Errorf("failed to add user to %s group: %w", greeterGroup, err)
|
||||||
}
|
}
|
||||||
fmt.Printf("✓ User added to %s group\n", greeterGroup)
|
fmt.Printf("✓ User added to %s group\n", greeterGroup)
|
||||||
@@ -869,22 +856,19 @@ func disableDisplayManager(dmName string) (bool, error) {
|
|||||||
actionTaken := false
|
actionTaken := false
|
||||||
|
|
||||||
if state.NeedsDisable {
|
if state.NeedsDisable {
|
||||||
var disableCmd *exec.Cmd
|
var action, actionVerb string
|
||||||
var actionVerb string
|
switch state.EnabledState {
|
||||||
|
case "static":
|
||||||
if state.EnabledState == "static" {
|
|
||||||
fmt.Printf(" Masking %s (static service cannot be disabled)...\n", dmName)
|
fmt.Printf(" Masking %s (static service cannot be disabled)...\n", dmName)
|
||||||
disableCmd = exec.Command("sudo", "systemctl", "mask", dmName)
|
action = "mask"
|
||||||
actionVerb = "masked"
|
actionVerb = "masked"
|
||||||
} else {
|
default:
|
||||||
fmt.Printf(" Disabling %s...\n", dmName)
|
fmt.Printf(" Disabling %s...\n", dmName)
|
||||||
disableCmd = exec.Command("sudo", "systemctl", "disable", dmName)
|
action = "disable"
|
||||||
actionVerb = "disabled"
|
actionVerb = "disabled"
|
||||||
}
|
}
|
||||||
|
|
||||||
disableCmd.Stdout = os.Stdout
|
if err := privesc.Run(context.Background(), "", "systemctl", action, dmName); err != nil {
|
||||||
disableCmd.Stderr = os.Stderr
|
|
||||||
if err := disableCmd.Run(); err != nil {
|
|
||||||
return actionTaken, fmt.Errorf("failed to disable/mask %s: %w", dmName, err)
|
return actionTaken, fmt.Errorf("failed to disable/mask %s: %w", dmName, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -925,10 +909,7 @@ func ensureGreetdEnabled() error {
|
|||||||
|
|
||||||
if state.EnabledState == "masked" || state.EnabledState == "masked-runtime" {
|
if state.EnabledState == "masked" || state.EnabledState == "masked-runtime" {
|
||||||
fmt.Println(" Unmasking greetd...")
|
fmt.Println(" Unmasking greetd...")
|
||||||
unmaskCmd := exec.Command("sudo", "systemctl", "unmask", "greetd")
|
if err := privesc.Run(context.Background(), "", "systemctl", "unmask", "greetd"); err != nil {
|
||||||
unmaskCmd.Stdout = os.Stdout
|
|
||||||
unmaskCmd.Stderr = os.Stderr
|
|
||||||
if err := unmaskCmd.Run(); err != nil {
|
|
||||||
return fmt.Errorf("failed to unmask greetd: %w", err)
|
return fmt.Errorf("failed to unmask greetd: %w", err)
|
||||||
}
|
}
|
||||||
fmt.Println(" ✓ Unmasked greetd")
|
fmt.Println(" ✓ Unmasked greetd")
|
||||||
@@ -940,10 +921,7 @@ func ensureGreetdEnabled() error {
|
|||||||
fmt.Println(" Enabling greetd service...")
|
fmt.Println(" Enabling greetd service...")
|
||||||
}
|
}
|
||||||
|
|
||||||
enableCmd := exec.Command("sudo", "systemctl", "enable", "--force", "greetd")
|
if err := privesc.Run(context.Background(), "", "systemctl", "enable", "--force", "greetd"); err != nil {
|
||||||
enableCmd.Stdout = os.Stdout
|
|
||||||
enableCmd.Stderr = os.Stderr
|
|
||||||
if err := enableCmd.Run(); err != nil {
|
|
||||||
return fmt.Errorf("failed to enable greetd: %w", err)
|
return fmt.Errorf("failed to enable greetd: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -973,10 +951,7 @@ func ensureGraphicalTarget() error {
|
|||||||
currentTargetStr := strings.TrimSpace(string(currentTarget))
|
currentTargetStr := strings.TrimSpace(string(currentTarget))
|
||||||
if currentTargetStr != "graphical.target" {
|
if currentTargetStr != "graphical.target" {
|
||||||
fmt.Printf("\nSetting graphical.target as default (current: %s)...\n", currentTargetStr)
|
fmt.Printf("\nSetting graphical.target as default (current: %s)...\n", currentTargetStr)
|
||||||
setDefaultCmd := exec.Command("sudo", "systemctl", "set-default", "graphical.target")
|
if err := privesc.Run(context.Background(), "", "systemctl", "set-default", "graphical.target"); err != nil {
|
||||||
setDefaultCmd.Stdout = os.Stdout
|
|
||||||
setDefaultCmd.Stderr = os.Stderr
|
|
||||||
if err := setDefaultCmd.Run(); err != nil {
|
|
||||||
fmt.Println("⚠ Warning: Failed to set graphical.target as default")
|
fmt.Println("⚠ Warning: Failed to set graphical.target as default")
|
||||||
fmt.Println(" Greeter may not start on boot. Run manually:")
|
fmt.Println(" Greeter may not start on boot. Run manually:")
|
||||||
fmt.Println(" sudo systemctl set-default graphical.target")
|
fmt.Println(" sudo systemctl set-default graphical.target")
|
||||||
|
|||||||
@@ -51,12 +51,20 @@ var keybindsSetCmd = &cobra.Command{
|
|||||||
|
|
||||||
var keybindsRemoveCmd = &cobra.Command{
|
var keybindsRemoveCmd = &cobra.Command{
|
||||||
Use: "remove <provider> <key>",
|
Use: "remove <provider> <key>",
|
||||||
Short: "Remove a keybind override",
|
Short: "Remove a keybind",
|
||||||
Long: "Remove a keybind override from the specified provider",
|
Long: "Remove a keybind. For Hyprland this writes a negative override to dms/binds-user.lua so the key stays unbound across DMS updates. For other providers it deletes the entry from the managed file.",
|
||||||
Args: cobra.ExactArgs(2),
|
Args: cobra.ExactArgs(2),
|
||||||
Run: runKeybindsRemove,
|
Run: runKeybindsRemove,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var keybindsResetCmd = &cobra.Command{
|
||||||
|
Use: "reset <provider> <key>",
|
||||||
|
Short: "Reset a keybind override to its DMS default",
|
||||||
|
Long: "Drop the user override for the given key so the DMS default re-applies. For providers without a separate default file (Niri, MangoWC) this is equivalent to remove.",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
Run: runKeybindsReset,
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
keybindsListCmd.Flags().BoolP("json", "j", false, "Output as JSON")
|
keybindsListCmd.Flags().BoolP("json", "j", false, "Output as JSON")
|
||||||
keybindsShowCmd.Flags().String("path", "", "Override config path for the provider")
|
keybindsShowCmd.Flags().String("path", "", "Override config path for the provider")
|
||||||
@@ -72,6 +80,7 @@ func init() {
|
|||||||
keybindsCmd.AddCommand(keybindsShowCmd)
|
keybindsCmd.AddCommand(keybindsShowCmd)
|
||||||
keybindsCmd.AddCommand(keybindsSetCmd)
|
keybindsCmd.AddCommand(keybindsSetCmd)
|
||||||
keybindsCmd.AddCommand(keybindsRemoveCmd)
|
keybindsCmd.AddCommand(keybindsRemoveCmd)
|
||||||
|
keybindsCmd.AddCommand(keybindsResetCmd)
|
||||||
|
|
||||||
keybinds.SetJSONProviderFactory(func(filePath string) (keybinds.Provider, error) {
|
keybinds.SetJSONProviderFactory(func(filePath string) (keybinds.Provider, error) {
|
||||||
return providers.NewJSONFileProvider(filePath)
|
return providers.NewJSONFileProvider(filePath)
|
||||||
@@ -263,3 +272,19 @@ func runKeybindsRemove(_ *cobra.Command, args []string) {
|
|||||||
}, "", " ")
|
}, "", " ")
|
||||||
fmt.Fprintln(os.Stdout, string(output))
|
fmt.Fprintln(os.Stdout, string(output))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runKeybindsReset(_ *cobra.Command, args []string) {
|
||||||
|
providerName, key := args[0], args[1]
|
||||||
|
writable := getWritableProvider(providerName)
|
||||||
|
|
||||||
|
if err := writable.ResetBind(key); err != nil {
|
||||||
|
log.Fatalf("Error resetting keybind: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
output, _ := json.MarshalIndent(map[string]any{
|
||||||
|
"success": true,
|
||||||
|
"key": key,
|
||||||
|
"reset": true,
|
||||||
|
}, "", " ")
|
||||||
|
fmt.Fprintln(os.Stdout, string(output))
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,9 +28,9 @@ with flags to handle different MIME types or application categories.
|
|||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
dms open https://example.com # Open URL with browser picker
|
dms open https://example.com # Open URL with browser picker
|
||||||
dms open file.pdf --mime application/pdf # Open PDF with compatible apps
|
dms open file.pdf # Open file (MIME auto-detected)
|
||||||
dms open document.odt --category Office # Open with office applications
|
dms open file.pdf --mime application/pdf # Override MIME detection
|
||||||
dms open --mime image/png image.png # Open image with image viewers`,
|
dms open document.odt --category Office # Open with office applications`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
runOpen(args[0])
|
runOpen(args[0])
|
||||||
@@ -47,123 +47,58 @@ func init() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// mimeTypeToCategories maps MIME types to desktop file categories
|
func detectMimeFromPath(path string) string {
|
||||||
func mimeTypeToCategories(mimeType string) []string {
|
ext := filepath.Ext(path)
|
||||||
// Split MIME type to get the main type
|
if ext == "" {
|
||||||
parts := strings.Split(mimeType, "/")
|
return ""
|
||||||
if len(parts) < 1 {
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
return mime.TypeByExtension(ext)
|
||||||
mainType := parts[0]
|
|
||||||
|
|
||||||
switch mainType {
|
|
||||||
case "image":
|
|
||||||
return []string{"Graphics", "Viewer"}
|
|
||||||
case "video":
|
|
||||||
return []string{"Video", "AudioVideo"}
|
|
||||||
case "audio":
|
|
||||||
return []string{"Audio", "AudioVideo"}
|
|
||||||
case "text":
|
|
||||||
if strings.Contains(mimeType, "html") {
|
|
||||||
return []string{"WebBrowser"}
|
|
||||||
}
|
|
||||||
return []string{"TextEditor", "Office"}
|
|
||||||
case "application":
|
|
||||||
if strings.Contains(mimeType, "pdf") {
|
|
||||||
return []string{"Office", "Viewer"}
|
|
||||||
}
|
|
||||||
if strings.Contains(mimeType, "document") || strings.Contains(mimeType, "spreadsheet") ||
|
|
||||||
strings.Contains(mimeType, "presentation") || strings.Contains(mimeType, "msword") ||
|
|
||||||
strings.Contains(mimeType, "ms-excel") || strings.Contains(mimeType, "ms-powerpoint") ||
|
|
||||||
strings.Contains(mimeType, "opendocument") {
|
|
||||||
return []string{"Office"}
|
|
||||||
}
|
|
||||||
if strings.Contains(mimeType, "zip") || strings.Contains(mimeType, "tar") ||
|
|
||||||
strings.Contains(mimeType, "gzip") || strings.Contains(mimeType, "compress") {
|
|
||||||
return []string{"Archiving", "Utility"}
|
|
||||||
}
|
|
||||||
return []string{"Office", "Viewer"}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func runOpen(target string) {
|
func runOpen(target string) {
|
||||||
// Parse file:// URIs to extract the actual file path
|
|
||||||
actualTarget := target
|
actualTarget := target
|
||||||
detectedMimeType := openMimeType
|
detectedMimeType := openMimeType
|
||||||
detectedCategories := openCategories
|
|
||||||
detectedRequestType := openRequestType
|
detectedRequestType := openRequestType
|
||||||
|
|
||||||
log.Infof("Processing target: %s", target)
|
log.Infof("Processing target: %s", target)
|
||||||
|
|
||||||
if parsedURL, err := url.Parse(target); err == nil && parsedURL.Scheme == "file" {
|
switch {
|
||||||
// Extract file path from file:// URI and convert to absolute path
|
case isScheme(target, "file://"):
|
||||||
|
parsedURL, err := url.Parse(target)
|
||||||
|
if err == nil {
|
||||||
actualTarget = parsedURL.Path
|
actualTarget = parsedURL.Path
|
||||||
if absPath, err := filepath.Abs(actualTarget); err == nil {
|
|
||||||
actualTarget = absPath
|
|
||||||
}
|
}
|
||||||
|
if abs, err := filepath.Abs(actualTarget); err == nil {
|
||||||
|
actualTarget = abs
|
||||||
|
}
|
||||||
if detectedRequestType == "url" || detectedRequestType == "" {
|
if detectedRequestType == "url" || detectedRequestType == "" {
|
||||||
detectedRequestType = "file"
|
detectedRequestType = "file"
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Infof("Detected file:// URI, extracted absolute path: %s", actualTarget)
|
|
||||||
|
|
||||||
// Auto-detect MIME type if not provided
|
|
||||||
if detectedMimeType == "" {
|
if detectedMimeType == "" {
|
||||||
ext := filepath.Ext(actualTarget)
|
detectedMimeType = detectMimeFromPath(actualTarget)
|
||||||
if ext != "" {
|
|
||||||
detectedMimeType = mime.TypeByExtension(ext)
|
|
||||||
log.Infof("Detected MIME type from extension %s: %s", ext, detectedMimeType)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
log.Infof("Detected file:// URI, absolute path: %s", actualTarget)
|
||||||
|
|
||||||
// Auto-detect categories based on MIME type if not provided
|
case isScheme(target, "http://"), isScheme(target, "https://"), isScheme(target, "dms://"):
|
||||||
if len(detectedCategories) == 0 && detectedMimeType != "" {
|
|
||||||
detectedCategories = mimeTypeToCategories(detectedMimeType)
|
|
||||||
log.Infof("Detected categories from MIME type: %v", detectedCategories)
|
|
||||||
}
|
|
||||||
} else if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") {
|
|
||||||
// Handle HTTP(S) URLs
|
|
||||||
if detectedRequestType == "" {
|
if detectedRequestType == "" {
|
||||||
detectedRequestType = "url"
|
detectedRequestType = "url"
|
||||||
}
|
}
|
||||||
log.Infof("Detected HTTP(S) URL")
|
log.Infof("Detected URL: %s", target)
|
||||||
} else if strings.HasPrefix(target, "dms://") {
|
|
||||||
// Handle DMS internal URLs (theme/plugin install, etc.)
|
|
||||||
if detectedRequestType == "" {
|
|
||||||
detectedRequestType = "url"
|
|
||||||
}
|
|
||||||
log.Infof("Detected DMS internal URL")
|
|
||||||
} else if _, err := os.Stat(target); err == nil {
|
|
||||||
// Handle local file paths directly (not file:// URIs)
|
|
||||||
// Convert to absolute path
|
|
||||||
if absPath, err := filepath.Abs(target); err == nil {
|
|
||||||
actualTarget = absPath
|
|
||||||
}
|
|
||||||
|
|
||||||
|
default:
|
||||||
|
if _, err := os.Stat(target); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if abs, err := filepath.Abs(target); err == nil {
|
||||||
|
actualTarget = abs
|
||||||
|
}
|
||||||
if detectedRequestType == "url" || detectedRequestType == "" {
|
if detectedRequestType == "url" || detectedRequestType == "" {
|
||||||
detectedRequestType = "file"
|
detectedRequestType = "file"
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Infof("Detected local file path, converted to absolute: %s", actualTarget)
|
|
||||||
|
|
||||||
// Auto-detect MIME type if not provided
|
|
||||||
if detectedMimeType == "" {
|
if detectedMimeType == "" {
|
||||||
ext := filepath.Ext(actualTarget)
|
detectedMimeType = detectMimeFromPath(actualTarget)
|
||||||
if ext != "" {
|
|
||||||
detectedMimeType = mime.TypeByExtension(ext)
|
|
||||||
log.Infof("Detected MIME type from extension %s: %s", ext, detectedMimeType)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-detect categories based on MIME type if not provided
|
|
||||||
if len(detectedCategories) == 0 && detectedMimeType != "" {
|
|
||||||
detectedCategories = mimeTypeToCategories(detectedMimeType)
|
|
||||||
log.Infof("Detected categories from MIME type: %v", detectedCategories)
|
|
||||||
}
|
}
|
||||||
|
log.Infof("Detected local file path: %s", actualTarget)
|
||||||
}
|
}
|
||||||
|
|
||||||
params := map[string]any{
|
params := map[string]any{
|
||||||
@@ -174,8 +109,8 @@ func runOpen(target string) {
|
|||||||
params["mimeType"] = detectedMimeType
|
params["mimeType"] = detectedMimeType
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(detectedCategories) > 0 {
|
if len(openCategories) > 0 {
|
||||||
params["categories"] = detectedCategories
|
params["categories"] = openCategories
|
||||||
}
|
}
|
||||||
|
|
||||||
if detectedRequestType != "" {
|
if detectedRequestType != "" {
|
||||||
@@ -183,7 +118,7 @@ func runOpen(target string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
method := "apppicker.open"
|
method := "apppicker.open"
|
||||||
if detectedMimeType == "" && len(detectedCategories) == 0 && (strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") || strings.HasPrefix(target, "dms://")) {
|
if detectedMimeType == "" && len(openCategories) == 0 && (isScheme(target, "http://") || isScheme(target, "https://") || isScheme(target, "dms://")) {
|
||||||
method = "browser.open"
|
method = "browser.open"
|
||||||
params["url"] = target
|
params["url"] = target
|
||||||
}
|
}
|
||||||
@@ -203,3 +138,7 @@ func runOpen(target string) {
|
|||||||
|
|
||||||
log.Infof("Request sent successfully")
|
log.Infof("Request sent successfully")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isScheme(target, prefix string) bool {
|
||||||
|
return strings.HasPrefix(target, prefix)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var switchUserCmd = &cobra.Command{
|
||||||
|
Use: "switch-user [target]",
|
||||||
|
Short: "Switch to another active session on this seat",
|
||||||
|
Long: `Switch the active VT to another running session.
|
||||||
|
|
||||||
|
With no target, prints the list of switchable sessions. Pass a username or a
|
||||||
|
numeric session ID to switch directly. Requires the target to already be a
|
||||||
|
running session on the same seat (use the greeter for a fresh login).`,
|
||||||
|
Args: cobra.MaximumNArgs(1),
|
||||||
|
Run: runSwitchUser,
|
||||||
|
}
|
||||||
|
|
||||||
|
type sessionInfo struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Seat string
|
||||||
|
TTY string
|
||||||
|
Type string
|
||||||
|
Class string
|
||||||
|
Active bool
|
||||||
|
State string
|
||||||
|
Current bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSwitchUser(cmd *cobra.Command, args []string) {
|
||||||
|
currentID := os.Getenv("XDG_SESSION_ID")
|
||||||
|
sessions, err := listSessions(currentID)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
switchable := make([]sessionInfo, 0, len(sessions))
|
||||||
|
for _, s := range sessions {
|
||||||
|
if s.Class != "user" || s.State == "closing" || s.Current {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switchable = append(switchable, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(args) == 0 {
|
||||||
|
if len(switchable) == 0 {
|
||||||
|
fmt.Println("No other active sessions on this seat.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
printSessions(switchable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
target := args[0]
|
||||||
|
picked, err := pickSession(switchable, target)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
if len(switchable) == 0 {
|
||||||
|
fmt.Fprintln(os.Stderr, "No other active sessions on this seat. Only already-running sessions can be switched to.")
|
||||||
|
} else {
|
||||||
|
fmt.Fprintln(os.Stderr, "\nSwitchable sessions:")
|
||||||
|
printSessions(switchable)
|
||||||
|
}
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := activateSession(picked.ID); err != nil {
|
||||||
|
log.Fatalf("loginctl activate %s: %v", picked.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func listSessions(currentID string) ([]sessionInfo, error) {
|
||||||
|
listOut, err := exec.Command("loginctl", "list-sessions", "--no-legend").Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("loginctl list-sessions: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var ids []string
|
||||||
|
scanner := bufio.NewScanner(strings.NewReader(string(listOut)))
|
||||||
|
for scanner.Scan() {
|
||||||
|
fields := strings.Fields(scanner.Text())
|
||||||
|
if len(fields) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ids = append(ids, fields[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]sessionInfo, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
s, err := showSession(id)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.Current = currentID != "" && s.ID == currentID
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
sort.SliceStable(out, func(i, j int) bool {
|
||||||
|
if out[i].Name != out[j].Name {
|
||||||
|
return out[i].Name < out[j].Name
|
||||||
|
}
|
||||||
|
return out[i].ID < out[j].ID
|
||||||
|
})
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func showSession(id string) (sessionInfo, error) {
|
||||||
|
out, err := exec.Command("loginctl", "show-session", id,
|
||||||
|
"-p", "Id", "-p", "Name", "-p", "Seat", "-p", "TTY",
|
||||||
|
"-p", "Type", "-p", "Class", "-p", "Active", "-p", "State").Output()
|
||||||
|
if err != nil {
|
||||||
|
return sessionInfo{}, err
|
||||||
|
}
|
||||||
|
fields := map[string]string{}
|
||||||
|
for _, line := range strings.Split(string(out), "\n") {
|
||||||
|
idx := strings.IndexByte(line, '=')
|
||||||
|
if idx <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields[line[:idx]] = line[idx+1:]
|
||||||
|
}
|
||||||
|
if fields["Id"] == "" {
|
||||||
|
return sessionInfo{}, fmt.Errorf("session %s: no Id", id)
|
||||||
|
}
|
||||||
|
return sessionInfo{
|
||||||
|
ID: fields["Id"],
|
||||||
|
Name: fields["Name"],
|
||||||
|
Seat: fields["Seat"],
|
||||||
|
TTY: fields["TTY"],
|
||||||
|
Type: fields["Type"],
|
||||||
|
Class: fields["Class"],
|
||||||
|
Active: fields["Active"] == "yes",
|
||||||
|
State: fields["State"],
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func pickSession(sessions []sessionInfo, target string) (sessionInfo, error) {
|
||||||
|
for _, s := range sessions {
|
||||||
|
if s.ID == target {
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
matches := make([]sessionInfo, 0, 2)
|
||||||
|
for _, s := range sessions {
|
||||||
|
if s.Name == target {
|
||||||
|
matches = append(matches, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(matches) == 1 {
|
||||||
|
return matches[0], nil
|
||||||
|
}
|
||||||
|
if len(matches) > 1 {
|
||||||
|
ids := make([]string, len(matches))
|
||||||
|
for i, m := range matches {
|
||||||
|
ids[i] = m.ID
|
||||||
|
}
|
||||||
|
return sessionInfo{}, fmt.Errorf("%s has multiple active sessions (%s); pass a session ID instead", target, strings.Join(ids, ", "))
|
||||||
|
}
|
||||||
|
return sessionInfo{}, fmt.Errorf("no switchable session matches %q", target)
|
||||||
|
}
|
||||||
|
|
||||||
|
func activateSession(id string) error {
|
||||||
|
return exec.Command("loginctl", "activate", id).Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func printSessions(sessions []sessionInfo) {
|
||||||
|
fmt.Printf("%-6s %-12s %-8s %-8s %-8s\n", "ID", "USER", "TYPE", "SEAT", "TTY")
|
||||||
|
for _, s := range sessions {
|
||||||
|
tty := s.TTY
|
||||||
|
if tty == "" {
|
||||||
|
tty = "-"
|
||||||
|
}
|
||||||
|
seat := s.Seat
|
||||||
|
if seat == "" {
|
||||||
|
seat = "-"
|
||||||
|
}
|
||||||
|
fmt.Printf("%-6s %-12s %-8s %-8s %-8s\n", s.ID, s.Name, s.Type, seat, tty)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -11,6 +12,7 @@ import (
|
|||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/greeter"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/greeter"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
@@ -19,7 +21,7 @@ var setupCmd = &cobra.Command{
|
|||||||
Use: "setup",
|
Use: "setup",
|
||||||
Short: "Deploy DMS configurations",
|
Short: "Deploy DMS configurations",
|
||||||
Long: "Deploy compositor and terminal configurations with interactive prompts",
|
Long: "Deploy compositor and terminal configurations with interactive prompts",
|
||||||
PersistentPreRunE: requireMutableSystemCommand,
|
PersistentPreRunE: preRunPrivileged,
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
if err := runSetup(); err != nil {
|
if err := runSetup(); err != nil {
|
||||||
log.Fatalf("Error during setup: %v", err)
|
log.Fatalf("Error during setup: %v", err)
|
||||||
@@ -107,25 +109,25 @@ type dmsConfigSpec struct {
|
|||||||
var dmsConfigSpecs = map[string]dmsConfigSpec{
|
var dmsConfigSpecs = map[string]dmsConfigSpec{
|
||||||
"binds": {
|
"binds": {
|
||||||
niriFile: "binds.kdl",
|
niriFile: "binds.kdl",
|
||||||
hyprFile: "binds.conf",
|
hyprFile: "binds.lua",
|
||||||
niriContent: func(t string) string {
|
niriContent: func(t string) string {
|
||||||
return strings.ReplaceAll(config.NiriBindsConfig, "{{TERMINAL_COMMAND}}", t)
|
return strings.ReplaceAll(config.NiriBindsConfig, "{{TERMINAL_COMMAND}}", t)
|
||||||
},
|
},
|
||||||
hyprContent: func(t string) string {
|
hyprContent: func(t string) string {
|
||||||
return strings.ReplaceAll(config.HyprBindsConfig, "{{TERMINAL_COMMAND}}", t)
|
return strings.ReplaceAll(config.DMSBindsLuaConfig, "{{TERMINAL_COMMAND}}", t)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"layout": {
|
"layout": {
|
||||||
niriFile: "layout.kdl",
|
niriFile: "layout.kdl",
|
||||||
hyprFile: "layout.conf",
|
hyprFile: "layout.lua",
|
||||||
niriContent: func(_ string) string { return config.NiriLayoutConfig },
|
niriContent: func(_ string) string { return config.NiriLayoutConfig },
|
||||||
hyprContent: func(_ string) string { return config.HyprLayoutConfig },
|
hyprContent: func(_ string) string { return config.DMSLayoutLuaConfig },
|
||||||
},
|
},
|
||||||
"colors": {
|
"colors": {
|
||||||
niriFile: "colors.kdl",
|
niriFile: "colors.kdl",
|
||||||
hyprFile: "colors.conf",
|
hyprFile: "colors.lua",
|
||||||
niriContent: func(_ string) string { return config.NiriColorsConfig },
|
niriContent: func(_ string) string { return config.NiriColorsConfig },
|
||||||
hyprContent: func(_ string) string { return config.HyprColorsConfig },
|
hyprContent: func(_ string) string { return config.DMSColorsLuaConfig },
|
||||||
},
|
},
|
||||||
"alttab": {
|
"alttab": {
|
||||||
niriFile: "alttab.kdl",
|
niriFile: "alttab.kdl",
|
||||||
@@ -133,21 +135,21 @@ var dmsConfigSpecs = map[string]dmsConfigSpec{
|
|||||||
},
|
},
|
||||||
"outputs": {
|
"outputs": {
|
||||||
niriFile: "outputs.kdl",
|
niriFile: "outputs.kdl",
|
||||||
hyprFile: "outputs.conf",
|
hyprFile: "outputs.lua",
|
||||||
niriContent: func(_ string) string { return "" },
|
niriContent: func(_ string) string { return "" },
|
||||||
hyprContent: func(_ string) string { return "" },
|
hyprContent: func(_ string) string { return config.DMSOutputsLuaConfig },
|
||||||
},
|
},
|
||||||
"cursor": {
|
"cursor": {
|
||||||
niriFile: "cursor.kdl",
|
niriFile: "cursor.kdl",
|
||||||
hyprFile: "cursor.conf",
|
hyprFile: "cursor.lua",
|
||||||
niriContent: func(_ string) string { return "" },
|
niriContent: func(_ string) string { return "" },
|
||||||
hyprContent: func(_ string) string { return "" },
|
hyprContent: func(_ string) string { return config.DMSCursorLuaConfig },
|
||||||
},
|
},
|
||||||
"windowrules": {
|
"windowrules": {
|
||||||
niriFile: "windowrules.kdl",
|
niriFile: "windowrules.kdl",
|
||||||
hyprFile: "windowrules.conf",
|
hyprFile: "windowrules.lua",
|
||||||
niriContent: func(_ string) string { return "" },
|
niriContent: func(_ string) string { return "" },
|
||||||
hyprContent: func(_ string) string { return "" },
|
hyprContent: func(_ string) string { return config.DMSWindowRulesLuaConfig },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,6 +269,8 @@ func runSetupDmsConfig(name string) error {
|
|||||||
func runSetup() error {
|
func runSetup() error {
|
||||||
fmt.Println("=== DMS Configuration Setup ===")
|
fmt.Println("=== DMS Configuration Setup ===")
|
||||||
|
|
||||||
|
ensureInputGroup()
|
||||||
|
|
||||||
wm, wmSelected := promptCompositor()
|
wm, wmSelected := promptCompositor()
|
||||||
terminal, terminalSelected := promptTerminal()
|
terminal, terminalSelected := promptTerminal()
|
||||||
useSystemd := promptSystemd()
|
useSystemd := promptSystemd()
|
||||||
@@ -340,6 +344,37 @@ func runSetup() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add user to the input group for the evdev manager for inut state tracking.
|
||||||
|
// Caps Lock OSD and the Caps Lock bar indicator.
|
||||||
|
func ensureInputGroup() {
|
||||||
|
if !utils.HasGroup("input") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
currentUser := os.Getenv("USER")
|
||||||
|
if currentUser == "" {
|
||||||
|
currentUser = os.Getenv("LOGNAME")
|
||||||
|
}
|
||||||
|
if currentUser == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := execGroups(currentUser)
|
||||||
|
if err == nil && strings.Contains(out, "input") {
|
||||||
|
fmt.Printf("✓ %s is already in the input group (Caps Lock OSD enabled)\n", currentUser)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println("Adding user to input group for Caps Lock OSD support...")
|
||||||
|
if err := privesc.Run(context.Background(), "", "usermod", "-aG", "input", currentUser); err != nil {
|
||||||
|
fmt.Printf("⚠ Could not add %s to input group (Caps Lock OSD will be unavailable): %v\n", currentUser, err)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("✓ Added %s to input group (logout/login required to take effect)\n", currentUser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func execGroups(user string) (string, error) {
|
||||||
|
out, err := exec.Command("groups", user).Output()
|
||||||
|
return string(out), err
|
||||||
|
}
|
||||||
|
|
||||||
func promptCompositor() (deps.WindowManager, bool) {
|
func promptCompositor() (deps.WindowManager, bool) {
|
||||||
fmt.Println("Select compositor:")
|
fmt.Println("Select compositor:")
|
||||||
fmt.Println("1) Niri")
|
fmt.Println("1) Niri")
|
||||||
@@ -403,16 +438,22 @@ func checkExistingConfigs(wm deps.WindowManager, wmSelected bool, terminal deps.
|
|||||||
willBackup := false
|
willBackup := false
|
||||||
|
|
||||||
if wmSelected {
|
if wmSelected {
|
||||||
var configPath string
|
var configPaths []string
|
||||||
switch wm {
|
switch wm {
|
||||||
case deps.WindowManagerNiri:
|
case deps.WindowManagerNiri:
|
||||||
configPath = filepath.Join(homeDir, ".config", "niri", "config.kdl")
|
configPaths = []string{filepath.Join(homeDir, ".config", "niri", "config.kdl")}
|
||||||
case deps.WindowManagerHyprland:
|
case deps.WindowManagerHyprland:
|
||||||
configPath = filepath.Join(homeDir, ".config", "hypr", "hyprland.conf")
|
configPaths = []string{
|
||||||
|
filepath.Join(homeDir, ".config", "hypr", "hyprland.lua"),
|
||||||
|
filepath.Join(homeDir, ".config", "hypr", "hyprland.conf"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, configPath := range configPaths {
|
||||||
if _, err := os.Stat(configPath); err == nil {
|
if _, err := os.Stat(configPath); err == nil {
|
||||||
willBackup = true
|
willBackup = true
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,365 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/sysupdate"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var systemCmd = &cobra.Command{
|
||||||
|
Use: "system",
|
||||||
|
Short: "System operations",
|
||||||
|
Long: "System-level operations (updates, etc.). Runs against installed package managers directly; does not require the DMS server.",
|
||||||
|
}
|
||||||
|
|
||||||
|
var systemUpdateCmd = &cobra.Command{
|
||||||
|
Use: "update",
|
||||||
|
Short: "Apply or list system updates",
|
||||||
|
Long: `Apply or list system updates across detected package managers.
|
||||||
|
|
||||||
|
Default behavior is to apply available updates after prompting for confirmation.
|
||||||
|
Use --check to list updates without applying.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
dms system update --check # list available updates
|
||||||
|
dms system update # apply updates (interactive prompt)
|
||||||
|
dms system update --noconfirm # apply updates without prompting
|
||||||
|
dms system update --dry # simulate without changing anything
|
||||||
|
dms system update --no-flatpak --noconfirm # apply system updates only
|
||||||
|
dms system update --interval 3600 # set the server poll interval to 1h`,
|
||||||
|
Run: runSystemUpdate,
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
sysUpdateCheck bool
|
||||||
|
sysUpdateNoConfirm bool
|
||||||
|
sysUpdateDry bool
|
||||||
|
sysUpdateJSON bool
|
||||||
|
sysUpdateNoFlatpak bool
|
||||||
|
sysUpdateNoAUR bool
|
||||||
|
sysUpdateIntervalS int
|
||||||
|
sysUpdateListPmTime = 5 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
systemUpdateCmd.Flags().BoolVar(&sysUpdateCheck, "check", false, "List available updates without applying")
|
||||||
|
systemUpdateCmd.Flags().BoolVarP(&sysUpdateNoConfirm, "noconfirm", "y", false, "Apply updates without prompting")
|
||||||
|
systemUpdateCmd.Flags().BoolVar(&sysUpdateDry, "dry", false, "Simulate the upgrade without applying changes")
|
||||||
|
systemUpdateCmd.Flags().BoolVar(&sysUpdateJSON, "json", false, "Output as JSON (with --check)")
|
||||||
|
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoFlatpak, "no-flatpak", false, "Skip the Flatpak overlay")
|
||||||
|
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoAUR, "no-aur", false, "Skip the AUR (paru/yay only)")
|
||||||
|
systemUpdateCmd.Flags().IntVar(&sysUpdateIntervalS, "interval", -1, "Set the DMS server poll interval in seconds and exit (requires running server)")
|
||||||
|
|
||||||
|
systemCmd.AddCommand(systemUpdateCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSystemUpdate(cmd *cobra.Command, args []string) {
|
||||||
|
switch {
|
||||||
|
case sysUpdateIntervalS >= 0:
|
||||||
|
runSystemUpdateSetInterval(sysUpdateIntervalS)
|
||||||
|
case sysUpdateCheck:
|
||||||
|
runSystemUpdateCheck()
|
||||||
|
default:
|
||||||
|
runSystemUpdateApply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectBackends(ctx context.Context) []sysupdate.Backend {
|
||||||
|
sel := sysupdate.Select(ctx)
|
||||||
|
backends := sel.All()
|
||||||
|
if !sysUpdateNoFlatpak {
|
||||||
|
return backends
|
||||||
|
}
|
||||||
|
out := backends[:0]
|
||||||
|
for _, b := range backends {
|
||||||
|
if b.Repo() == sysupdate.RepoFlatpak {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, b)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSystemUpdateCheck() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), sysUpdateListPmTime)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
backends := selectBackends(ctx)
|
||||||
|
if len(backends) == 0 {
|
||||||
|
log.Fatal("No supported package manager found")
|
||||||
|
}
|
||||||
|
|
||||||
|
stopSpin := startSpinner("Checking for updates… ")
|
||||||
|
allPkgs, firstErr := collectUpdates(ctx, backends)
|
||||||
|
stopSpin()
|
||||||
|
allPkgs = filterUpdateTargets(allPkgs)
|
||||||
|
|
||||||
|
if sysUpdateJSON {
|
||||||
|
out, _ := json.MarshalIndent(map[string]any{
|
||||||
|
"backends": backendResults(backends, allPkgs),
|
||||||
|
"packages": allPkgs,
|
||||||
|
"error": errOrEmpty(firstErr),
|
||||||
|
"count": len(allPkgs),
|
||||||
|
}, "", " ")
|
||||||
|
fmt.Println(string(out))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
printBackends(backends)
|
||||||
|
fmt.Printf("Updates: %d\n", len(allPkgs))
|
||||||
|
if firstErr != nil {
|
||||||
|
fmt.Printf("Error: %v\n", firstErr)
|
||||||
|
}
|
||||||
|
if len(allPkgs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
for _, p := range allPkgs {
|
||||||
|
printPackage(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type backendResult struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Display string `json:"displayName"`
|
||||||
|
Packages []sysupdate.Package `json:"packages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func backendResults(backends []sysupdate.Backend, pkgs []sysupdate.Package) []backendResult {
|
||||||
|
results := make([]backendResult, 0, len(backends))
|
||||||
|
for _, b := range backends {
|
||||||
|
var backendPkgs []sysupdate.Package
|
||||||
|
for _, p := range pkgs {
|
||||||
|
if sysupdate.BackendHasTargets(b, []sysupdate.Package{p}, true, true) {
|
||||||
|
backendPkgs = append(backendPkgs, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
results = append(results, backendResult{ID: b.ID(), Display: b.DisplayName(), Packages: backendPkgs})
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSystemUpdateApply() {
|
||||||
|
checkCtx, checkCancel := context.WithTimeout(context.Background(), sysUpdateListPmTime)
|
||||||
|
defer checkCancel()
|
||||||
|
|
||||||
|
backends := selectBackends(checkCtx)
|
||||||
|
if len(backends) == 0 {
|
||||||
|
log.Fatal("No supported package manager found")
|
||||||
|
}
|
||||||
|
|
||||||
|
stopSpin := startSpinner("Checking for updates…")
|
||||||
|
pkgs, firstErr := collectUpdates(checkCtx, backends)
|
||||||
|
stopSpin()
|
||||||
|
pkgs = filterUpdateTargets(pkgs)
|
||||||
|
if firstErr != nil {
|
||||||
|
fmt.Printf("Warning: %v\n\n", firstErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
printBackends(backends)
|
||||||
|
fmt.Printf("Updates: %d\n", len(pkgs))
|
||||||
|
if len(pkgs) == 0 {
|
||||||
|
fmt.Println("Nothing to upgrade.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
for _, p := range pkgs {
|
||||||
|
printPackage(p)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
if !sysUpdateNoConfirm && !sysUpdateDry {
|
||||||
|
if !promptYesNo("Proceed with upgrade? [Y/n]: ") {
|
||||||
|
fmt.Println("Aborted.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
opts := sysupdate.UpgradeOptions{
|
||||||
|
Targets: pkgs,
|
||||||
|
IncludeFlatpak: !sysUpdateNoFlatpak,
|
||||||
|
IncludeAUR: !sysUpdateNoAUR,
|
||||||
|
DryRun: sysUpdateDry,
|
||||||
|
UseSudo: true,
|
||||||
|
}
|
||||||
|
opts.AttachStdio = sysupdate.UpgradeNeedsPrivilege(backends, pkgs, opts)
|
||||||
|
|
||||||
|
onLine := func(line string) { fmt.Println(line) }
|
||||||
|
ran := false
|
||||||
|
for _, b := range backends {
|
||||||
|
if !sysupdate.BackendHasTargets(b, pkgs, opts.IncludeAUR, opts.IncludeFlatpak) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ran = true
|
||||||
|
fmt.Printf("\n== %s ==\n", b.DisplayName())
|
||||||
|
if err := b.Upgrade(ctx, opts, onLine); err != nil {
|
||||||
|
log.Fatalf("%s upgrade failed: %v", b.ID(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ran {
|
||||||
|
fmt.Println("Nothing to upgrade.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if sysUpdateDry {
|
||||||
|
fmt.Println("\nDry run complete (no changes applied).")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println("\nUpgrade complete.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectUpdates(ctx context.Context, backends []sysupdate.Backend) ([]sysupdate.Package, error) {
|
||||||
|
var all []sysupdate.Package
|
||||||
|
var firstErr error
|
||||||
|
for _, b := range backends {
|
||||||
|
pkgs, err := b.CheckUpdates(ctx)
|
||||||
|
if err != nil && firstErr == nil {
|
||||||
|
firstErr = fmt.Errorf("%s: %w", b.ID(), err)
|
||||||
|
}
|
||||||
|
all = append(all, pkgs...)
|
||||||
|
}
|
||||||
|
return all, firstErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterUpdateTargets(pkgs []sysupdate.Package) []sysupdate.Package {
|
||||||
|
if !sysUpdateNoAUR {
|
||||||
|
return pkgs
|
||||||
|
}
|
||||||
|
out := pkgs[:0]
|
||||||
|
for _, p := range pkgs {
|
||||||
|
if p.Repo == sysupdate.RepoAUR {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSystemUpdateSetInterval(seconds int) {
|
||||||
|
resp, err := sendServerRequest(models.Request{
|
||||||
|
ID: 1,
|
||||||
|
Method: "sysupdate.setInterval",
|
||||||
|
Params: map[string]any{"seconds": float64(seconds)},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed: %v (is dms server running?)", err)
|
||||||
|
}
|
||||||
|
if resp.Error != "" {
|
||||||
|
log.Fatalf("Error: %s", resp.Error)
|
||||||
|
}
|
||||||
|
fmt.Printf("Interval set to %d seconds.\n", seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
func promptYesNo(prompt string) bool {
|
||||||
|
if !stdinIsTTY() {
|
||||||
|
log.Fatal("Refusing to apply updates non-interactively. Re-run with --noconfirm or --check.")
|
||||||
|
}
|
||||||
|
fmt.Print(prompt)
|
||||||
|
reader := bufio.NewReader(os.Stdin)
|
||||||
|
line, err := reader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch strings.ToLower(strings.TrimSpace(line)) {
|
||||||
|
case "n", "no":
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func printBackends(backends []sysupdate.Backend) {
|
||||||
|
if len(backends) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(backends))
|
||||||
|
for _, b := range backends {
|
||||||
|
names = append(names, b.DisplayName())
|
||||||
|
}
|
||||||
|
fmt.Printf("Backends: %s\n", strings.Join(names, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
func stdinIsTTY() bool {
|
||||||
|
fi, err := os.Stdin.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return (fi.Mode() & os.ModeCharDevice) != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func stdoutIsTTY() bool {
|
||||||
|
fi, err := os.Stdout.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return (fi.Mode() & os.ModeCharDevice) != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// startSpinner prints an animated spinner to stdout for progress indication
|
||||||
|
func startSpinner(msg string) func() {
|
||||||
|
if !stdoutIsTTY() {
|
||||||
|
return func() {}
|
||||||
|
}
|
||||||
|
frames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
for i := 0; ; i++ {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
fmt.Print("\r\033[K")
|
||||||
|
return
|
||||||
|
case <-time.After(80 * time.Millisecond):
|
||||||
|
fmt.Printf("\r%s %s", frames[i%len(frames)], msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return func() { close(done) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
styleRepo = lipgloss.NewStyle().Foreground(lipgloss.Color("244")).Bold(false)
|
||||||
|
styleName = lipgloss.NewStyle().Foreground(lipgloss.Color("39")).Bold(true)
|
||||||
|
styleFrom = lipgloss.NewStyle().Foreground(lipgloss.Color("243"))
|
||||||
|
styleArrow = lipgloss.NewStyle().Foreground(lipgloss.Color("244"))
|
||||||
|
styleTo = lipgloss.NewStyle().Foreground(lipgloss.Color("76")).Bold(true)
|
||||||
|
)
|
||||||
|
|
||||||
|
func printPackage(p sysupdate.Package) {
|
||||||
|
if !stdoutIsTTY() {
|
||||||
|
fmt.Printf(" [%s] %s %s -> %s\n", p.Repo, p.Name, defaultIfEmpty(p.FromVersion, "?"), defaultIfEmpty(p.ToVersion, "?"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf(" %s %s %s %s %s\n",
|
||||||
|
styleRepo.Render("["+string(p.Repo)+"]"),
|
||||||
|
styleName.Render(p.Name),
|
||||||
|
styleFrom.Render(defaultIfEmpty(p.FromVersion, "?")),
|
||||||
|
styleArrow.Render("->"),
|
||||||
|
styleTo.Render(defaultIfEmpty(p.ToVersion, "?")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func errOrEmpty(err error) string {
|
||||||
|
if err == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultIfEmpty(s, def string) string {
|
||||||
|
if s == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/trash"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var trashCmd = &cobra.Command{
|
||||||
|
Use: "trash",
|
||||||
|
Short: "Manage the user's trash (XDG Trash spec 1.0)",
|
||||||
|
}
|
||||||
|
|
||||||
|
var trashPutCmd = &cobra.Command{
|
||||||
|
Use: "put <path...>",
|
||||||
|
Short: "Move files or directories into the trash",
|
||||||
|
Args: cobra.MinimumNArgs(1),
|
||||||
|
Run: runTrashPut,
|
||||||
|
}
|
||||||
|
|
||||||
|
var trashListCmd = &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List trashed items across all known trash directories",
|
||||||
|
Run: runTrashList,
|
||||||
|
}
|
||||||
|
|
||||||
|
var trashCountCmd = &cobra.Command{
|
||||||
|
Use: "count",
|
||||||
|
Short: "Print the total number of trashed items",
|
||||||
|
Run: runTrashCount,
|
||||||
|
}
|
||||||
|
|
||||||
|
var trashEmptyCmd = &cobra.Command{
|
||||||
|
Use: "empty",
|
||||||
|
Short: "Permanently delete every trashed item",
|
||||||
|
Run: runTrashEmpty,
|
||||||
|
}
|
||||||
|
|
||||||
|
var trashRestoreCmd = &cobra.Command{
|
||||||
|
Use: "restore <name>",
|
||||||
|
Short: "Restore a trashed item to its original location",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
Run: runTrashRestore,
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
trashJSONOutput bool
|
||||||
|
trashRestoreDir string
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
trashListCmd.Flags().BoolVar(&trashJSONOutput, "json", false, "Output as JSON")
|
||||||
|
trashRestoreCmd.Flags().StringVar(&trashRestoreDir, "trash-dir", "", "Trash directory containing the item (default: home trash)")
|
||||||
|
trashCmd.AddCommand(trashPutCmd, trashListCmd, trashCountCmd, trashEmptyCmd, trashRestoreCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runTrashPut(cmd *cobra.Command, args []string) {
|
||||||
|
var failed int
|
||||||
|
for _, p := range args {
|
||||||
|
if _, err := trash.Put(p); err != nil {
|
||||||
|
log.Errorf("trash %s: %v", p, err)
|
||||||
|
failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Println(p)
|
||||||
|
}
|
||||||
|
if failed > 0 {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runTrashList(cmd *cobra.Command, args []string) {
|
||||||
|
entries, err := trash.List()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("list trash: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if trashJSONOutput {
|
||||||
|
if entries == nil {
|
||||||
|
entries = []trash.Entry{}
|
||||||
|
}
|
||||||
|
out, _ := json.MarshalIndent(entries, "", " ")
|
||||||
|
fmt.Println(string(out))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(entries) == 0 {
|
||||||
|
fmt.Println("Trash is empty")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
marker := "F"
|
||||||
|
if e.IsDir {
|
||||||
|
marker = "D"
|
||||||
|
}
|
||||||
|
fmt.Printf("%s %s %s %s\n", marker, e.DeletionDate, e.Name, e.OriginalPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runTrashCount(cmd *cobra.Command, args []string) {
|
||||||
|
n, err := trash.Count()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("count trash: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Println(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runTrashEmpty(cmd *cobra.Command, args []string) {
|
||||||
|
if err := trash.Empty(); err != nil {
|
||||||
|
log.Fatalf("empty trash: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runTrashRestore(cmd *cobra.Command, args []string) {
|
||||||
|
if err := trash.Restore(args[0], trashRestoreDir); err != nil {
|
||||||
|
log.Fatalf("restore: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ var windowrulesListCmd = &cobra.Command{
|
|||||||
Args: cobra.MaximumNArgs(1),
|
Args: cobra.MaximumNArgs(1),
|
||||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp
|
return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
|
||||||
}
|
}
|
||||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
},
|
},
|
||||||
@@ -40,8 +40,7 @@ var windowrulesAddCmd = &cobra.Command{
|
|||||||
Args: cobra.ExactArgs(2),
|
Args: cobra.ExactArgs(2),
|
||||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
// ! disabled hyprland return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
|
return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
|
||||||
return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp
|
|
||||||
}
|
}
|
||||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
},
|
},
|
||||||
@@ -55,7 +54,7 @@ var windowrulesUpdateCmd = &cobra.Command{
|
|||||||
Args: cobra.ExactArgs(3),
|
Args: cobra.ExactArgs(3),
|
||||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp
|
return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
|
||||||
}
|
}
|
||||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
},
|
},
|
||||||
@@ -69,7 +68,7 @@ var windowrulesRemoveCmd = &cobra.Command{
|
|||||||
Args: cobra.ExactArgs(2),
|
Args: cobra.ExactArgs(2),
|
||||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp
|
return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
|
||||||
}
|
}
|
||||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
},
|
},
|
||||||
@@ -83,7 +82,7 @@ var windowrulesReorderCmd = &cobra.Command{
|
|||||||
Args: cobra.ExactArgs(2),
|
Args: cobra.ExactArgs(2),
|
||||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp
|
return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
|
||||||
}
|
}
|
||||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
},
|
},
|
||||||
@@ -118,9 +117,9 @@ func getCompositor(args []string) string {
|
|||||||
if os.Getenv("NIRI_SOCKET") != "" {
|
if os.Getenv("NIRI_SOCKET") != "" {
|
||||||
return "niri"
|
return "niri"
|
||||||
}
|
}
|
||||||
// if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") != "" {
|
if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") != "" {
|
||||||
// return "hyprland"
|
return "hyprland"
|
||||||
// }
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,7 +182,6 @@ func runWindowrulesList(cmd *cobra.Command, args []string) {
|
|||||||
result.DMSStatus = parseResult.DMSStatus
|
result.DMSStatus = parseResult.DMSStatus
|
||||||
|
|
||||||
case "hyprland":
|
case "hyprland":
|
||||||
log.Fatalf("Hyprland support is currently disabled.") // ! disabled hyprland
|
|
||||||
configDir, err := utils.ExpandPath("$HOME/.config/hypr")
|
configDir, err := utils.ExpandPath("$HOME/.config/hypr")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to expand hyprland config path: %v", err)
|
log.Fatalf("Failed to expand hyprland config path: %v", err)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -269,3 +270,16 @@ func requireMutableSystemCommand(cmd *cobra.Command, _ []string) error {
|
|||||||
|
|
||||||
return fmt.Errorf("%s%s\nCommand: dms %s\nPolicy files:\n %s\n %s", reason, policy.Message, commandPath, cliPolicyPackagedPath, cliPolicyAdminPath)
|
return fmt.Errorf("%s%s\nCommand: dms %s\nPolicy files:\n %s\n %s", reason, policy.Message, commandPath, cliPolicyPackagedPath, cliPolicyAdminPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// preRunPrivileged combines the immutable-system check with a privesc tool
|
||||||
|
// selection prompt (shown only when multiple tools are available and the
|
||||||
|
// $DMS_PRIVESC env var isn't set).
|
||||||
|
func preRunPrivileged(cmd *cobra.Command, args []string) error {
|
||||||
|
if err := requireMutableSystemCommand(cmd, args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := privesc.PromptCLI(os.Stdout, os.Stdin); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ func init() {
|
|||||||
runCmd.Flags().BoolP("daemon", "d", false, "Run in daemon mode")
|
runCmd.Flags().BoolP("daemon", "d", false, "Run in daemon mode")
|
||||||
runCmd.Flags().Bool("daemon-child", false, "Internal flag for daemon child process")
|
runCmd.Flags().Bool("daemon-child", false, "Internal flag for daemon child process")
|
||||||
runCmd.Flags().Bool("session", false, "Session managed (like as a systemd unit)")
|
runCmd.Flags().Bool("session", false, "Session managed (like as a systemd unit)")
|
||||||
|
runCmd.Flags().String("log-level", "", "Log level: debug, info, warn, error, fatal (overrides DMS_LOG_LEVEL)")
|
||||||
|
runCmd.Flags().String("log-file", "", "Append logs to this file in addition to stderr (overrides DMS_LOG_FILE)")
|
||||||
runCmd.Flags().MarkHidden("daemon-child")
|
runCmd.Flags().MarkHidden("daemon-child")
|
||||||
|
|
||||||
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd)
|
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd)
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ func init() {
|
|||||||
runCmd.Flags().BoolP("daemon", "d", false, "Run in daemon mode")
|
runCmd.Flags().BoolP("daemon", "d", false, "Run in daemon mode")
|
||||||
runCmd.Flags().Bool("daemon-child", false, "Internal flag for daemon child process")
|
runCmd.Flags().Bool("daemon-child", false, "Internal flag for daemon child process")
|
||||||
runCmd.Flags().Bool("session", false, "Session managed (like as a systemd unit)")
|
runCmd.Flags().Bool("session", false, "Session managed (like as a systemd unit)")
|
||||||
|
runCmd.Flags().String("log-level", "", "Log level: debug, info, warn, error, fatal (overrides DMS_LOG_LEVEL)")
|
||||||
|
runCmd.Flags().String("log-file", "", "Append logs to this file in addition to stderr (overrides DMS_LOG_FILE)")
|
||||||
runCmd.Flags().MarkHidden("daemon-child")
|
runCmd.Flags().MarkHidden("daemon-child")
|
||||||
|
|
||||||
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd)
|
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd)
|
||||||
|
|||||||
+14
-3
@@ -80,6 +80,16 @@ func getRuntimeDir() string {
|
|||||||
return os.TempDir()
|
return os.TempDir()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func appendLogEnv(env []string) []string {
|
||||||
|
if v := os.Getenv("DMS_LOG_LEVEL"); v != "" {
|
||||||
|
env = append(env, "DMS_LOG_LEVEL="+v)
|
||||||
|
}
|
||||||
|
if v := os.Getenv("DMS_LOG_FILE"); v != "" {
|
||||||
|
env = append(env, "DMS_LOG_FILE="+v)
|
||||||
|
}
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
func hasSystemdRun() bool {
|
func hasSystemdRun() bool {
|
||||||
_, err := exec.LookPath("systemd-run")
|
_, err := exec.LookPath("systemd-run")
|
||||||
return err == nil
|
return err == nil
|
||||||
@@ -192,9 +202,6 @@ func runShellInteractive(session bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ! TODO - remove when QS 0.3 is up and we can use the pragma
|
|
||||||
cmd.Env = append(cmd.Env, "QS_APP_ID=com.danklinux.dms")
|
|
||||||
|
|
||||||
if isSessionManaged && hasSystemdRun() {
|
if isSessionManaged && hasSystemdRun() {
|
||||||
cmd.Env = append(cmd.Env, "DMS_DEFAULT_LAUNCH_PREFIX=systemd-run --user --scope")
|
cmd.Env = append(cmd.Env, "DMS_DEFAULT_LAUNCH_PREFIX=systemd-run --user --scope")
|
||||||
}
|
}
|
||||||
@@ -216,6 +223,8 @@ func runShellInteractive(session bool) {
|
|||||||
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland;xcb")
|
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland;xcb")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cmd.Env = appendLogEnv(cmd.Env)
|
||||||
|
|
||||||
cmd.Stdin = os.Stdin
|
cmd.Stdin = os.Stdin
|
||||||
cmd.Stdout = os.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
@@ -459,6 +468,8 @@ func runShellDaemon(session bool) {
|
|||||||
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland;xcb")
|
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland;xcb")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cmd.Env = appendLogEnv(cmd.Env)
|
||||||
|
|
||||||
devNull, err := os.OpenFile("/dev/null", os.O_RDWR, 0)
|
devNull, err := os.OpenFile("/dev/null", os.O_RDWR, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Error opening /dev/null: %v", err)
|
log.Fatalf("Error opening /dev/null: %v", err)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ func isReadOnlyCommand(args []string) bool {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
switch arg {
|
switch arg {
|
||||||
case "completion", "help", "__complete":
|
case "completion", "help", "__complete", "system":
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
|
|||||||
+45
-29
@@ -1,84 +1,100 @@
|
|||||||
module github.com/AvengeMedia/DankMaterialShell/core
|
module github.com/AvengeMedia/DankMaterialShell/core
|
||||||
|
|
||||||
go 1.26.0
|
go 1.26.1
|
||||||
|
|
||||||
toolchain go1.26.1
|
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/Wifx/gonetworkmanager/v2 v2.2.0
|
github.com/Wifx/gonetworkmanager/v2 v2.2.0
|
||||||
github.com/alecthomas/chroma/v2 v2.23.1
|
github.com/alecthomas/chroma/v2 v2.24.1
|
||||||
github.com/charmbracelet/bubbles v1.0.0
|
github.com/charmbracelet/bubbles v1.0.0
|
||||||
github.com/charmbracelet/bubbletea v1.3.10
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
github.com/charmbracelet/lipgloss v1.1.0
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
github.com/charmbracelet/log v0.4.2
|
github.com/charmbracelet/log v1.0.0
|
||||||
github.com/fsnotify/fsnotify v1.9.0
|
github.com/fsnotify/fsnotify v1.10.1
|
||||||
github.com/godbus/dbus/v5 v5.2.2
|
github.com/godbus/dbus/v5 v5.2.2
|
||||||
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83
|
github.com/holoplot/go-evdev v0.0.0-20260504100651-66d1748fe847
|
||||||
github.com/pilebones/go-udev v0.9.1
|
github.com/pilebones/go-udev v0.9.1
|
||||||
github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6
|
github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/yeqown/go-qrcode/v2 v2.2.5
|
github.com/yeqown/go-qrcode/v2 v2.2.5
|
||||||
github.com/yeqown/go-qrcode/writer/standard v1.3.0
|
github.com/yeqown/go-qrcode/writer/standard v1.3.0
|
||||||
github.com/yuin/goldmark v1.7.16
|
github.com/yuin/goldmark v1.8.2
|
||||||
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
|
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
|
||||||
go.etcd.io/bbolt v1.4.3
|
go.etcd.io/bbolt v1.4.3
|
||||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a
|
go4.org/mem v0.0.0-20240501181205-ae6ca9944745
|
||||||
golang.org/x/image v0.36.0
|
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f
|
||||||
|
golang.org/x/image v0.39.0
|
||||||
|
tailscale.com v1.96.5
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
filippo.io/edwards25519 v1.2.0 // indirect
|
||||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||||
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
github.com/ProtonMail/go-crypto v1.4.1 // indirect
|
||||||
github.com/clipperhouse/displaywidth v0.10.0 // indirect
|
github.com/akutz/memconn v0.1.0 // indirect
|
||||||
|
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||||
github.com/cloudflare/circl v1.6.3 // indirect
|
github.com/cloudflare/circl v1.6.3 // indirect
|
||||||
|
github.com/coder/websocket v1.8.14 // indirect
|
||||||
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
|
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
|
||||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
github.com/dblohm7/wingoes v0.0.0-20250822163801-6d8e6105c62d // indirect
|
||||||
|
github.com/dlclark/regexp2 v1.12.0 // indirect
|
||||||
github.com/emirpasic/gods v1.18.1 // indirect
|
github.com/emirpasic/gods v1.18.1 // indirect
|
||||||
github.com/fogleman/gg v1.3.0 // indirect
|
github.com/fogleman/gg v1.3.0 // indirect
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
|
||||||
github.com/go-git/gcfg/v2 v2.0.2 // indirect
|
github.com/go-git/gcfg/v2 v2.0.2 // indirect
|
||||||
github.com/go-git/go-billy/v6 v6.0.0-20260209124918-37866f83c2d3 // indirect
|
github.com/go-git/go-billy/v6 v6.0.0-20260504142752-cb8e9d337266 // indirect
|
||||||
|
github.com/go-json-experiment/json v0.0.0-20260430182902-b6187a392ed4 // indirect
|
||||||
github.com/go-logfmt/logfmt v0.6.1 // indirect
|
github.com/go-logfmt/logfmt v0.6.1 // indirect
|
||||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
github.com/google/go-cmp v0.7.0 // indirect
|
||||||
|
github.com/hdevalence/ed25519consensus v0.2.0 // indirect
|
||||||
|
github.com/jsimonetti/rtnetlink v1.4.2 // indirect
|
||||||
github.com/kevinburke/ssh_config v1.6.0 // indirect
|
github.com/kevinburke/ssh_config v1.6.0 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
github.com/pjbgf/sha1cd v0.5.0 // indirect
|
github.com/mdlayher/netlink v1.11.1 // indirect
|
||||||
|
github.com/mdlayher/socket v0.6.0 // indirect
|
||||||
|
github.com/mitchellh/go-ps v1.0.0 // indirect
|
||||||
|
github.com/pjbgf/sha1cd v0.6.0 // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/sergi/go-diff v1.4.0 // indirect
|
github.com/sergi/go-diff v1.4.0 // indirect
|
||||||
github.com/stretchr/objx v0.5.3 // indirect
|
github.com/stretchr/objx v0.5.3 // indirect
|
||||||
|
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect
|
||||||
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
github.com/yeqown/reedsolomon v1.0.0 // indirect
|
github.com/yeqown/reedsolomon v1.0.0 // indirect
|
||||||
golang.org/x/crypto v0.48.0 // indirect
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
|
||||||
golang.org/x/net v0.50.0 // indirect
|
golang.org/x/crypto v0.50.0 // indirect
|
||||||
|
golang.org/x/net v0.53.0 // indirect
|
||||||
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
|
golang.zx2c4.com/wireguard/windows v1.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/atotto/clipboard v0.1.4 // indirect
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
github.com/charmbracelet/colorprofile v0.4.2 // indirect
|
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||||
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
||||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
github.com/charmbracelet/x/ansi v0.11.7 // indirect
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
github.com/go-git/go-git/v6 v6.0.0-20260216160506-e6a3f881772f
|
github.com/go-git/go-git/v6 v6.0.0-alpha.2
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0
|
github.com/lucasb-eyer/go-colorful v1.4.0
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.22
|
||||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
github.com/mattn/go-runewidth v0.0.23 // indirect
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
github.com/muesli/termenv v0.16.0 // indirect
|
github.com/muesli/termenv v0.16.0
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/spf13/afero v1.15.0
|
github.com/spf13/afero v1.15.0
|
||||||
github.com/spf13/pflag v1.0.10 // indirect
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
golang.org/x/sys v0.41.0
|
golang.org/x/sys v0.43.0
|
||||||
golang.org/x/text v0.34.0
|
golang.org/x/text v0.36.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+103
-55
@@ -1,14 +1,18 @@
|
|||||||
|
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||||
|
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
|
github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM=
|
||||||
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
|
github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
|
||||||
github.com/Wifx/gonetworkmanager/v2 v2.2.0 h1:kPstgsQtY8CmDOOFZd81ytM9Gi3f6ImzPCKF7nNhQ2U=
|
github.com/Wifx/gonetworkmanager/v2 v2.2.0 h1:kPstgsQtY8CmDOOFZd81ytM9Gi3f6ImzPCKF7nNhQ2U=
|
||||||
github.com/Wifx/gonetworkmanager/v2 v2.2.0/go.mod h1:fMDb//SHsKWxyDUAwXvCqurV3npbIyyaQWenGpZ/uXg=
|
github.com/Wifx/gonetworkmanager/v2 v2.2.0/go.mod h1:fMDb//SHsKWxyDUAwXvCqurV3npbIyyaQWenGpZ/uXg=
|
||||||
|
github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A=
|
||||||
|
github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw=
|
||||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||||
github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
|
github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
|
||||||
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
|
github.com/alecthomas/chroma/v2 v2.24.1 h1:m5ffpfZbIb++k8AqFEKy9uVgY12xIQtBsQlc6DfZJQM=
|
||||||
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
|
github.com/alecthomas/chroma/v2 v2.24.1/go.mod h1:l+ohZ9xRXIbGe7cIW+YZgOGbvuVLjMps/FYN/CwuabI=
|
||||||
github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
|
github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
|
||||||
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
|
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
|
||||||
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||||
@@ -24,54 +28,71 @@ github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5f
|
|||||||
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||||
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
|
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||||
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
|
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||||
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
|
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
|
||||||
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
||||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||||
github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
|
github.com/charmbracelet/log v1.0.0 h1:HVVVMmfOorfj3BA9i8X8UL69Hoz9lI0PYwXfJvOdRc4=
|
||||||
github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
|
github.com/charmbracelet/log v1.0.0/go.mod h1:uYgY3SmLpwJWxmlrPwXvzVYujxis1vAKRV/0VQB7yWA=
|
||||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
|
||||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||||
github.com/clipperhouse/displaywidth v0.10.0 h1:GhBG8WuerxjFQQYeuZAeVTuyxuX+UraiZGD4HJQ3Y8g=
|
github.com/cilium/ebpf v0.16.0 h1:+BiEnHL6Z7lXnlGUsXQPPAE7+kenAd4ES8MQ5min0Ok=
|
||||||
github.com/clipperhouse/displaywidth v0.10.0/go.mod h1:XqJajYsaiEwkxOj4bowCTMcT1SgvHo9flfF3jQasdbs=
|
github.com/cilium/ebpf v0.16.0/go.mod h1:L7u2Blt2jMM/vLAVgjxluxtBKlz3/GWjB0dMOEngfwE=
|
||||||
|
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||||
|
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||||
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||||
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||||
|
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||||
|
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
|
github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc=
|
||||||
|
github.com/creachadair/taskgroup v0.13.2/go.mod h1:i3V1Zx7H8RjwljUEeUWYT30Lmb9poewSb2XI1yTwD0g=
|
||||||
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
|
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
|
||||||
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
|
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dblohm7/wingoes v0.0.0-20250822163801-6d8e6105c62d h1:QRKpU+9ZBDs62LyBfwhZkJdB5DJX2Sm3p4kUh7l1aA0=
|
||||||
|
github.com/dblohm7/wingoes v0.0.0-20250822163801-6d8e6105c62d/go.mod h1:SUxUaAK/0UG5lYyZR1L1nC4AaYYvSSYTWQSH3FPcxKU=
|
||||||
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
|
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
|
||||||
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
||||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||||
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||||
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
|
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
|
||||||
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
|
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
|
||||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
|
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||||
|
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||||
|
github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo=
|
||||||
|
github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
|
||||||
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||||
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||||
github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo=
|
github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo=
|
||||||
github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs=
|
github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs=
|
||||||
github.com/go-git/go-billy/v6 v6.0.0-20260209124918-37866f83c2d3 h1:UU7oARtwQ5g85aFiCSwIUA6PBmAshYj0sytl/5CCBgs=
|
github.com/go-git/go-billy/v6 v6.0.0-20260504142752-cb8e9d337266 h1:wH21vHuv323v9x78JNFNJ6P7HEAsdwr9yq2k9/o4zEE=
|
||||||
github.com/go-git/go-billy/v6 v6.0.0-20260209124918-37866f83c2d3/go.mod h1:ZW9JC5gionMP1kv5uiaOaV23q0FFmNrVOV8VW+y/acc=
|
github.com/go-git/go-billy/v6 v6.0.0-20260504142752-cb8e9d337266/go.mod h1:CdBVp7CXl9l3sOyNEog46cP1Pvx/hjCe9AD0mtaIUYU=
|
||||||
github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20260122163445-0622d7459a67 h1:3hutPZF+/FBjR/9MdsLJ7e1mlt9pwHgwxMW7CrbmWII=
|
github.com/go-git/go-git-fixtures/v6 v6.0.0-20260405195209-b16dd39735e0 h1:XoTsdvaghuVfIr7HpNTmFDLu2nz3I2iGqyn6Uk6MkJc=
|
||||||
github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20260122163445-0622d7459a67/go.mod h1:xKt0pNHST9tYHvbiLxSY27CQWFwgIxBJuDrOE0JvbZw=
|
github.com/go-git/go-git-fixtures/v6 v6.0.0-20260405195209-b16dd39735e0/go.mod h1:1Lr7/vYEYyl6Ir9Ku0tKrCIRreM5zovv0Jdx2MPSM4s=
|
||||||
github.com/go-git/go-git/v6 v6.0.0-20260216160506-e6a3f881772f h1:TBkCJv9YwPOuXq1OG0r01bcxRrvs15Hp/DtZuPt4H6s=
|
github.com/go-git/go-git/v6 v6.0.0-alpha.2 h1:T3loNtDuAixNzXtlQxZhnYiYpaQ3CA4vn9RssAniEeI=
|
||||||
github.com/go-git/go-git/v6 v6.0.0-20260216160506-e6a3f881772f/go.mod h1:B88nWzfnhTlIikoJ4d84Nc9noKS5mJoA7SgDdkt0aPU=
|
github.com/go-git/go-git/v6 v6.0.0-alpha.2/go.mod h1:oCD3i19CTz7gBpeb11ZZqL91WzqbMq9avn5KpUYy/Ak=
|
||||||
|
github.com/go-json-experiment/json v0.0.0-20260430182902-b6187a392ed4 h1:2WmHkJINIjgXXYDGik8d3oJvFA3DAwPy00csDJ3vo+o=
|
||||||
|
github.com/go-json-experiment/json v0.0.0-20260430182902-b6187a392ed4/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
|
||||||
github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
|
github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
|
||||||
github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
|
github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
|
||||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
@@ -84,12 +105,16 @@ github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUv
|
|||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU=
|
||||||
|
github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo=
|
||||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||||
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83 h1:B+A58zGFuDrvEZpPN+yS6swJA0nzqgZvDzgl/OPyefU=
|
github.com/holoplot/go-evdev v0.0.0-20260504100651-66d1748fe847 h1:1rQ5UQXFm02DXEtsIpotfA32WJ9KceS6t8w5K8QtFqc=
|
||||||
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83/go.mod h1:iHAf8OIncO2gcQ8XOjS7CMJ2aPbX2Bs0wl5pZyanEqk=
|
github.com/holoplot/go-evdev v0.0.0-20260504100651-66d1748fe847/go.mod h1:iHAf8OIncO2gcQ8XOjS7CMJ2aPbX2Bs0wl5pZyanEqk=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/jsimonetti/rtnetlink v1.4.2 h1:Df9w9TZ3npHTyDn0Ev9e1uzmN2odmXd0QX+J5GTEn90=
|
||||||
|
github.com/jsimonetti/rtnetlink v1.4.2/go.mod h1:92s6LJdE+1iOrw+F2/RO7LYI2Qd8pPpFNNUYW06gcoM=
|
||||||
github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY=
|
github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY=
|
||||||
github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
|
github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
@@ -101,14 +126,20 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
|||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||||
github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75 h1:P8UmIzZMYDR+NGImiFvErt6VWfIRPuGM+vyjiEdkmIw=
|
github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75 h1:P8UmIzZMYDR+NGImiFvErt6VWfIRPuGM+vyjiEdkmIw=
|
||||||
github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
|
||||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
|
github.com/mdlayher/netlink v1.11.1 h1:T136gDS6Gkt+hLncaBwKdW5GpEC8Z0ykqimOebVoal0=
|
||||||
|
github.com/mdlayher/netlink v1.11.1/go.mod h1:ao4LjamyK4Uq9L8+fQzqFYpAncbeCdwbvd9Edv/pYnc=
|
||||||
|
github.com/mdlayher/socket v0.6.0 h1:ScZPaAGyO1icQnbFrhPM8mnXyMu9qukC1K4ZoM2IQKU=
|
||||||
|
github.com/mdlayher/socket v0.6.0/go.mod h1:q7vozUAnxSqnjHc12Fik5yUKIzfZ8ITCfMkhOtE9z18=
|
||||||
|
github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc=
|
||||||
|
github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg=
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
@@ -117,12 +148,13 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc
|
|||||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||||
github.com/pilebones/go-udev v0.9.1 h1:uN72M1C1fgzhsVmBGEM8w9RD1JY4iVsPZpr+Z6rb3O8=
|
github.com/pilebones/go-udev v0.9.1 h1:uN72M1C1fgzhsVmBGEM8w9RD1JY4iVsPZpr+Z6rb3O8=
|
||||||
github.com/pilebones/go-udev v0.9.1/go.mod h1:Bgcl07crebF3JSeS4+nuaRvhWFdCeFoBhXXeAp93XNo=
|
github.com/pilebones/go-udev v0.9.1/go.mod h1:Bgcl07crebF3JSeS4+nuaRvhWFdCeFoBhXXeAp93XNo=
|
||||||
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
|
github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
|
||||||
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
|
github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
@@ -146,6 +178,12 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P
|
|||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4=
|
||||||
|
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg=
|
||||||
|
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da h1:jVRUZPRs9sqyKlYHHzHjAqKN+6e/Vog6NpHYeNPJqOw=
|
||||||
|
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
|
||||||
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
github.com/yeqown/go-qrcode/v2 v2.2.5 h1:HCOe2bSjkhZyYoyyNaXNzh4DJZll6inVJQQw+8228Zk=
|
github.com/yeqown/go-qrcode/v2 v2.2.5 h1:HCOe2bSjkhZyYoyyNaXNzh4DJZll6inVJQQw+8228Zk=
|
||||||
@@ -155,37 +193,47 @@ github.com/yeqown/go-qrcode/writer/standard v1.3.0/go.mod h1:O4MbzsotGCvy8upYPCR
|
|||||||
github.com/yeqown/reedsolomon v1.0.0 h1:x1h/Ej/uJnNu8jaX7GLHBWmZKCAWjEJTetkqaabr4B0=
|
github.com/yeqown/reedsolomon v1.0.0 h1:x1h/Ej/uJnNu8jaX7GLHBWmZKCAWjEJTetkqaabr4B0=
|
||||||
github.com/yeqown/reedsolomon v1.0.0/go.mod h1:P76zpcn2TCuL0ul1Fso373qHRc69LKwAw/Iy6g1WiiM=
|
github.com/yeqown/reedsolomon v1.0.0/go.mod h1:P76zpcn2TCuL0ul1Fso373qHRc69LKwAw/Iy6g1WiiM=
|
||||||
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
|
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||||
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||||
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
|
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
|
||||||
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
|
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
|
||||||
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
||||||
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek=
|
||||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
|
||||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
|
||||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
|
||||||
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
|
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||||
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
|
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
|
||||||
|
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||||
|
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||||
|
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||||
|
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||||
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
||||||
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
||||||
|
golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH8ysFIbw8=
|
||||||
|
golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
tailscale.com v1.96.5 h1:gNkfA/KSZAl6jCH9cj8urq00HRWItDDTtGsyATI89jA=
|
||||||
|
tailscale.com v1.96.5/go.mod h1:/3lnZBYb2UEwnN0MNu2SDXUtT06AGd5k0s+OWx3WmcY=
|
||||||
|
|||||||
+173
-104
@@ -12,6 +12,8 @@ import (
|
|||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const hyprlandBackupDirName = ".dms-backups"
|
||||||
|
|
||||||
type ConfigDeployer struct {
|
type ConfigDeployer struct {
|
||||||
logChan chan<- string
|
logChan chan<- string
|
||||||
}
|
}
|
||||||
@@ -63,12 +65,23 @@ func (cd *ConfigDeployer) deployConfigurationsInternal(ctx context.Context, wm d
|
|||||||
var results []DeploymentResult
|
var results []DeploymentResult
|
||||||
|
|
||||||
// Primary config file paths used to detect fresh installs.
|
// Primary config file paths used to detect fresh installs.
|
||||||
configPrimaryPaths := map[string]string{
|
configPrimaryPaths := map[string][]string{
|
||||||
"Niri": filepath.Join(os.Getenv("HOME"), ".config", "niri", "config.kdl"),
|
"Niri": {
|
||||||
"Hyprland": filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.conf"),
|
filepath.Join(os.Getenv("HOME"), ".config", "niri", "config.kdl"),
|
||||||
"Ghostty": filepath.Join(os.Getenv("HOME"), ".config", "ghostty", "config"),
|
},
|
||||||
"Kitty": filepath.Join(os.Getenv("HOME"), ".config", "kitty", "kitty.conf"),
|
"Hyprland": {
|
||||||
"Alacritty": filepath.Join(os.Getenv("HOME"), ".config", "alacritty", "alacritty.toml"),
|
filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.lua"),
|
||||||
|
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 {
|
shouldReplaceConfig := func(configType string) bool {
|
||||||
@@ -81,8 +94,15 @@ func (cd *ConfigDeployer) deployConfigurationsInternal(ctx context.Context, wm d
|
|||||||
}
|
}
|
||||||
// Config is explicitly set to "don't replace" — but still deploy
|
// Config is explicitly set to "don't replace" — but still deploy
|
||||||
// if the config file doesn't exist yet (fresh install scenario).
|
// if the config file doesn't exist yet (fresh install scenario).
|
||||||
if primaryPath, ok := configPrimaryPaths[configType]; ok {
|
if primaryPaths, ok := configPrimaryPaths[configType]; ok {
|
||||||
if _, err := os.Stat(primaryPath); os.IsNotExist(err) {
|
exists := false
|
||||||
|
for _, primaryPath := range primaryPaths {
|
||||||
|
if _, err := os.Stat(primaryPath); err == nil {
|
||||||
|
exists = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -495,7 +515,7 @@ func (cd *ConfigDeployer) mergeNiriOutputSections(newConfig, existingConfig, dms
|
|||||||
func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystemd bool) (DeploymentResult, error) {
|
func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystemd bool) (DeploymentResult, error) {
|
||||||
result := DeploymentResult{
|
result := DeploymentResult{
|
||||||
ConfigType: "Hyprland",
|
ConfigType: "Hyprland",
|
||||||
Path: filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.conf"),
|
Path: filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.lua"),
|
||||||
}
|
}
|
||||||
|
|
||||||
configDir := filepath.Dir(result.Path)
|
configDir := filepath.Dir(result.Path)
|
||||||
@@ -510,20 +530,20 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
|
|||||||
return result, result.Error
|
return result, result.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
timestamp := time.Now().Format("2006-01-02_15-04-05")
|
||||||
|
backupDir := filepath.Join(configDir, hyprlandBackupDirName, timestamp)
|
||||||
var existingConfig string
|
var existingConfig string
|
||||||
if _, err := os.Stat(result.Path); err == nil {
|
existingData, existingPath, err := readExistingHyprlandConfig(configDir)
|
||||||
cd.log("Found existing Hyprland configuration")
|
|
||||||
|
|
||||||
existingData, err := os.ReadFile(result.Path)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result.Error = fmt.Errorf("failed to read existing config: %w", err)
|
result.Error = err
|
||||||
return result, result.Error
|
return result, result.Error
|
||||||
}
|
}
|
||||||
existingConfig = string(existingData)
|
if existingData != "" {
|
||||||
|
existingConfig = existingData
|
||||||
|
cd.log(fmt.Sprintf("Found existing Hyprland configuration at %s", existingPath))
|
||||||
|
|
||||||
timestamp := time.Now().Format("2006-01-02_15-04-05")
|
result.BackupPath = filepath.Join(backupDir, filepath.Base(existingPath))
|
||||||
result.BackupPath = result.Path + ".backup." + timestamp
|
if err := backupHyprlandConfigFile(existingPath, result.BackupPath, []byte(existingData), strings.EqualFold(filepath.Ext(existingPath), ".conf")); err != nil {
|
||||||
if err := os.WriteFile(result.BackupPath, existingData, 0o644); err != nil {
|
|
||||||
result.Error = fmt.Errorf("failed to create backup: %w", err)
|
result.Error = fmt.Errorf("failed to create backup: %w", err)
|
||||||
return result, result.Error
|
return result, result.Error
|
||||||
}
|
}
|
||||||
@@ -542,10 +562,10 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
|
|||||||
terminalCommand = "ghostty"
|
terminalCommand = "ghostty"
|
||||||
}
|
}
|
||||||
|
|
||||||
newConfig := strings.ReplaceAll(HyprlandConfig, "{{TERMINAL_COMMAND}}", terminalCommand)
|
newConfig := strings.ReplaceAll(HyprlandLuaConfig, "{{TERMINAL_COMMAND}}", terminalCommand)
|
||||||
|
|
||||||
if !useSystemd {
|
if !useSystemd {
|
||||||
newConfig = cd.transformHyprlandConfigForNonSystemd(newConfig, terminalCommand)
|
newConfig = transformHyprlandLuaForNonSystemd(newConfig, terminalCommand)
|
||||||
}
|
}
|
||||||
|
|
||||||
if existingConfig != "" {
|
if existingConfig != "" {
|
||||||
@@ -563,6 +583,18 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
|
|||||||
return result, result.Error
|
return result, result.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
movedLegacy, err := backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir)
|
||||||
|
if err != nil {
|
||||||
|
result.Error = fmt.Errorf("failed to back up legacy hyprlang configs: %w", err)
|
||||||
|
return result, result.Error
|
||||||
|
}
|
||||||
|
if movedLegacy > 0 {
|
||||||
|
if result.BackupPath == "" {
|
||||||
|
result.BackupPath = backupDir
|
||||||
|
}
|
||||||
|
cd.log(fmt.Sprintf("Moved %d legacy hyprlang config(s) to %s", movedLegacy, backupDir))
|
||||||
|
}
|
||||||
|
|
||||||
if err := cd.deployHyprlandDmsConfigs(dmsDir, terminalCommand); err != nil {
|
if err := cd.deployHyprlandDmsConfigs(dmsDir, terminalCommand); err != nil {
|
||||||
result.Error = fmt.Errorf("failed to deploy dms configs: %w", err)
|
result.Error = fmt.Errorf("failed to deploy dms configs: %w", err)
|
||||||
return result, result.Error
|
return result, result.Error
|
||||||
@@ -573,29 +605,118 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func backupHyprlandConfigFile(src, dst string, data []byte, removeSource bool) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(dst, data, 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if removeSource {
|
||||||
|
if err := os.Remove(src); err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir string) (int, error) {
|
||||||
|
legacyPaths := []string{filepath.Join(configDir, "hyprland.conf")}
|
||||||
|
dmsConfPaths, err := filepath.Glob(filepath.Join(dmsDir, "*.conf"))
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
legacyPaths = append(legacyPaths, dmsConfPaths...)
|
||||||
|
backupPaths, err := adjacentHyprlandBackupFiles(configDir, dmsDir)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
legacyPaths = append(legacyPaths, backupPaths...)
|
||||||
|
|
||||||
|
moved := 0
|
||||||
|
for _, src := range legacyPaths {
|
||||||
|
info, err := os.Lstat(src)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return moved, err
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
rel, err := filepath.Rel(configDir, src)
|
||||||
|
if err != nil {
|
||||||
|
rel = filepath.Base(src)
|
||||||
|
}
|
||||||
|
dst := filepath.Join(backupDir, rel)
|
||||||
|
if err := moveHyprlandConfigFile(src, dst); err != nil {
|
||||||
|
return moved, err
|
||||||
|
}
|
||||||
|
moved++
|
||||||
|
}
|
||||||
|
|
||||||
|
return moved, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func moveHyprlandConfigFile(src, dst string) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(src, dst)
|
||||||
|
}
|
||||||
|
|
||||||
|
func adjacentHyprlandBackupFiles(configDir, dmsDir string) ([]string, error) {
|
||||||
|
var paths []string
|
||||||
|
patterns := []string{
|
||||||
|
filepath.Join(configDir, "hyprland.conf.backup.*"),
|
||||||
|
filepath.Join(configDir, "hyprland.lua.backup.*"),
|
||||||
|
filepath.Join(dmsDir, "*.conf.backup.*"),
|
||||||
|
filepath.Join(dmsDir, "*.lua.backup.*"),
|
||||||
|
}
|
||||||
|
for _, pattern := range patterns {
|
||||||
|
matches, err := filepath.Glob(pattern)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
paths = append(paths, matches...)
|
||||||
|
}
|
||||||
|
return paths, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (cd *ConfigDeployer) deployHyprlandDmsConfigs(dmsDir string, terminalCommand string) error {
|
func (cd *ConfigDeployer) deployHyprlandDmsConfigs(dmsDir string, terminalCommand string) error {
|
||||||
configs := []struct {
|
configs := []struct {
|
||||||
name string
|
name string
|
||||||
content string
|
content string
|
||||||
|
overwrite bool
|
||||||
}{
|
}{
|
||||||
{"colors.conf", HyprColorsConfig},
|
{name: "colors.lua", content: DMSColorsLuaConfig},
|
||||||
{"layout.conf", HyprLayoutConfig},
|
{name: "layout.lua", content: DMSLayoutLuaConfig},
|
||||||
{"binds.conf", strings.ReplaceAll(HyprBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand)},
|
{name: "binds.lua", content: strings.ReplaceAll(DMSBindsLuaConfig, "{{TERMINAL_COMMAND}}", terminalCommand), overwrite: true},
|
||||||
{"outputs.conf", ""},
|
{name: "binds-user.lua", content: DMSBindsUserLuaConfig},
|
||||||
{"cursor.conf", ""},
|
{name: "outputs.lua", content: DMSOutputsLuaConfig},
|
||||||
{"windowrules.conf", ""},
|
{name: "cursor.lua", content: DMSCursorLuaConfig},
|
||||||
|
{name: "windowrules.lua", content: DMSWindowRulesLuaConfig},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, cfg := range configs {
|
for _, cfg := range configs {
|
||||||
path := filepath.Join(dmsDir, cfg.name)
|
path := filepath.Join(dmsDir, cfg.name)
|
||||||
// Skip if file already exists and is not empty to preserve user modifications
|
existed := false
|
||||||
if info, err := os.Stat(path); err == nil && info.Size() > 0 {
|
if info, err := os.Stat(path); err == nil && info.Size() > 0 {
|
||||||
|
existed = true
|
||||||
|
}
|
||||||
|
if existed && !cfg.overwrite {
|
||||||
cd.log(fmt.Sprintf("Skipping %s (already exists)", cfg.name))
|
cd.log(fmt.Sprintf("Skipping %s (already exists)", cfg.name))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(path, []byte(cfg.content), 0o644); err != nil {
|
if err := os.WriteFile(path, []byte(cfg.content), 0o644); err != nil {
|
||||||
return fmt.Errorf("failed to write %s: %w", cfg.name, err)
|
return fmt.Errorf("failed to write %s: %w", cfg.name, err)
|
||||||
}
|
}
|
||||||
|
if existed {
|
||||||
|
cd.log(fmt.Sprintf("Updated %s", cfg.name))
|
||||||
|
continue
|
||||||
|
}
|
||||||
cd.log(fmt.Sprintf("Deployed %s", cfg.name))
|
cd.log(fmt.Sprintf("Deployed %s", cfg.name))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -603,94 +724,42 @@ func (cd *ConfigDeployer) deployHyprlandDmsConfigs(dmsDir string, terminalComman
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (cd *ConfigDeployer) mergeHyprlandMonitorSections(newConfig, existingConfig, dmsDir string) (string, error) {
|
func (cd *ConfigDeployer) mergeHyprlandMonitorSections(newConfig, existingConfig, dmsDir string) (string, error) {
|
||||||
monitorRegex := regexp.MustCompile(`(?m)^#?\s*monitor\s*=.*$`)
|
_ = newConfig
|
||||||
existingMonitors := monitorRegex.FindAllString(existingConfig, -1)
|
lines := extractHyprlangMonitorLines(existingConfig)
|
||||||
|
if len(lines) == 0 {
|
||||||
if len(existingMonitors) == 0 {
|
|
||||||
return newConfig, nil
|
return newConfig, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
outputsPath := filepath.Join(dmsDir, "outputs.conf")
|
outputsPath := filepath.Join(dmsDir, "outputs.lua")
|
||||||
if _, err := os.Stat(outputsPath); err != nil {
|
if info, err := os.Stat(outputsPath); err == nil && info.Size() > 0 {
|
||||||
var outputsContent strings.Builder
|
cd.log("Skipping monitor migration: dms/outputs.lua already exists")
|
||||||
for _, monitor := range existingMonitors {
|
return newConfig, nil
|
||||||
outputsContent.WriteString(monitor)
|
|
||||||
outputsContent.WriteString("\n")
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(outputsPath, []byte(outputsContent.String()), 0o644); err != nil {
|
|
||||||
cd.log(fmt.Sprintf("Warning: Failed to migrate monitors to %s: %v", outputsPath, err))
|
|
||||||
} else {
|
|
||||||
cd.log("Migrated monitor sections to dms/outputs.conf")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
exampleMonitorRegex := regexp.MustCompile(`(?m)^# monitor = eDP-2.*$`)
|
var b strings.Builder
|
||||||
mergedConfig := exampleMonitorRegex.ReplaceAllString(newConfig, "")
|
b.WriteString("-- Migrated from existing hyprlang monitor lines\n\n")
|
||||||
|
ok := 0
|
||||||
monitorHeaderRegex := regexp.MustCompile(`(?m)^# MONITOR CONFIG\n# ==================$`)
|
|
||||||
headerMatch := monitorHeaderRegex.FindStringIndex(mergedConfig)
|
|
||||||
|
|
||||||
if headerMatch == nil {
|
|
||||||
return "", fmt.Errorf("could not find MONITOR CONFIG section")
|
|
||||||
}
|
|
||||||
|
|
||||||
insertPos := headerMatch[1] + 1
|
|
||||||
|
|
||||||
var builder strings.Builder
|
|
||||||
builder.WriteString(mergedConfig[:insertPos])
|
|
||||||
builder.WriteString("# Monitors from existing configuration\n")
|
|
||||||
|
|
||||||
for _, monitor := range existingMonitors {
|
|
||||||
builder.WriteString(monitor)
|
|
||||||
builder.WriteString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
builder.WriteString(mergedConfig[insertPos:])
|
|
||||||
|
|
||||||
return builder.String(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cd *ConfigDeployer) transformHyprlandConfigForNonSystemd(config, terminalCommand string) string {
|
|
||||||
lines := strings.Split(config, "\n")
|
|
||||||
var result []string
|
|
||||||
startupSectionFound := false
|
|
||||||
|
|
||||||
for _, line := range lines {
|
for _, line := range lines {
|
||||||
trimmed := strings.TrimSpace(line)
|
lua, err := hyprlangMonitorLineToLua(line)
|
||||||
if strings.HasPrefix(trimmed, "exec-once = dbus-update-activation-environment") {
|
if err != nil {
|
||||||
|
cd.log(fmt.Sprintf("Warning: could not migrate monitor line %q: %v", line, err))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(trimmed, "exec-once = systemctl --user start") {
|
b.WriteString(lua)
|
||||||
startupSectionFound = true
|
b.WriteByte('\n')
|
||||||
result = append(result, "exec-once = dms run")
|
ok++
|
||||||
result = append(result, "env = QT_QPA_PLATFORM,wayland;xcb")
|
|
||||||
result = append(result, "env = ELECTRON_OZONE_PLATFORM_HINT,auto")
|
|
||||||
result = append(result, "env = QT_QPA_PLATFORMTHEME,gtk3")
|
|
||||||
result = append(result, "env = QT_QPA_PLATFORMTHEME_QT6,gtk3")
|
|
||||||
result = append(result, fmt.Sprintf("env = TERMINAL,%s", terminalCommand))
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
result = append(result, line)
|
if ok == 0 {
|
||||||
|
return newConfig, nil
|
||||||
}
|
}
|
||||||
|
b.WriteByte('\n')
|
||||||
if !startupSectionFound {
|
b.WriteString("-- Default fallback\n")
|
||||||
for i, line := range result {
|
b.WriteString("hl.monitor({ output = \"\", mode = \"preferred\", position = \"auto\", scale = \"auto\" })\n")
|
||||||
if strings.Contains(line, "STARTUP APPS") {
|
if err := os.WriteFile(outputsPath, []byte(b.String()), 0o644); err != nil {
|
||||||
insertLines := []string{
|
return newConfig, err
|
||||||
"exec-once = dms run",
|
|
||||||
"env = QT_QPA_PLATFORM,wayland;xcb",
|
|
||||||
"env = ELECTRON_OZONE_PLATFORM_HINT,auto",
|
|
||||||
"env = QT_QPA_PLATFORMTHEME,gtk3",
|
|
||||||
"env = QT_QPA_PLATFORMTHEME_QT6,gtk3",
|
|
||||||
fmt.Sprintf("env = TERMINAL,%s", terminalCommand),
|
|
||||||
}
|
}
|
||||||
result = append(result[:i+2], append(insertLines, result[i+2:]...)...)
|
cd.log("Migrated monitor sections to dms/outputs.lua")
|
||||||
break
|
return newConfig, nil
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.Join(result, "\n")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cd *ConfigDeployer) transformNiriConfigForNonSystemd(config, terminalCommand string) string {
|
func (cd *ConfigDeployer) transformNiriConfigForNonSystemd(config, terminalCommand string) string {
|
||||||
|
|||||||
@@ -11,6 +11,46 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestCleanupStrayHyprlandConfFile(t *testing.T) {
|
||||||
|
if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") == "" {
|
||||||
|
t.Setenv("HYPRLAND_INSTANCE_SIGNATURE", "test-signature")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("leaves conf alone when no hyprland.lua present", func(t *testing.T) {
|
||||||
|
td := t.TempDir()
|
||||||
|
t.Setenv("HOME", td)
|
||||||
|
configDir := filepath.Join(td, ".config", "hypr")
|
||||||
|
require.NoError(t, os.MkdirAll(configDir, 0o755))
|
||||||
|
confPath := filepath.Join(configDir, "hyprland.conf")
|
||||||
|
require.NoError(t, os.WriteFile(confPath, []byte("# legacy user config\n"), 0o644))
|
||||||
|
|
||||||
|
CleanupStrayHyprlandConfFile(nil)
|
||||||
|
|
||||||
|
assert.FileExists(t, confPath, "must not touch hyprland.conf when user has not migrated")
|
||||||
|
assert.NoDirExists(t, filepath.Join(configDir, hyprlandBackupDirName))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("moves stray conf into backup when hyprland.lua exists", func(t *testing.T) {
|
||||||
|
td := t.TempDir()
|
||||||
|
t.Setenv("HOME", td)
|
||||||
|
configDir := filepath.Join(td, ".config", "hypr")
|
||||||
|
require.NoError(t, os.MkdirAll(configDir, 0o755))
|
||||||
|
luaPath := filepath.Join(configDir, "hyprland.lua")
|
||||||
|
require.NoError(t, os.WriteFile(luaPath, []byte("-- dms managed\n"), 0o644))
|
||||||
|
confPath := filepath.Join(configDir, "hyprland.conf")
|
||||||
|
require.NoError(t, os.WriteFile(confPath, []byte("# autogen\n"), 0o644))
|
||||||
|
|
||||||
|
CleanupStrayHyprlandConfFile(nil)
|
||||||
|
|
||||||
|
assert.NoFileExists(t, confPath)
|
||||||
|
assert.FileExists(t, luaPath)
|
||||||
|
entries, err := os.ReadDir(filepath.Join(configDir, hyprlandBackupDirName))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, entries, 1)
|
||||||
|
assert.FileExists(t, filepath.Join(configDir, hyprlandBackupDirName, entries[0].Name(), "hyprland.conf"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestMergeNiriOutputSections(t *testing.T) {
|
func TestMergeNiriOutputSections(t *testing.T) {
|
||||||
cd := &ConfigDeployer{}
|
cd := &ConfigDeployer{}
|
||||||
|
|
||||||
@@ -259,130 +299,56 @@ func getGhosttyPath() string {
|
|||||||
func TestMergeHyprlandMonitorSections(t *testing.T) {
|
func TestMergeHyprlandMonitorSections(t *testing.T) {
|
||||||
cd := &ConfigDeployer{}
|
cd := &ConfigDeployer{}
|
||||||
|
|
||||||
tests := []struct {
|
t.Run("no monitors in existing", func(t *testing.T) {
|
||||||
name string
|
tmp := t.TempDir()
|
||||||
newConfig string
|
out, err := cd.mergeHyprlandMonitorSections(`hl.config({})`, `input { kb_layout = us }`, tmp)
|
||||||
existingConfig string
|
require.NoError(t, err)
|
||||||
wantError bool
|
assert.Equal(t, `hl.config({})`, out)
|
||||||
wantContains []string
|
_, e := os.Stat(filepath.Join(tmp, "outputs.lua"))
|
||||||
wantNotContains []string
|
assert.True(t, os.IsNotExist(e))
|
||||||
}{
|
})
|
||||||
{
|
|
||||||
name: "no existing monitors",
|
|
||||||
newConfig: `# ==================
|
|
||||||
# MONITOR CONFIG
|
|
||||||
# ==================
|
|
||||||
# monitor = eDP-2, 2560x1600@239.998993, 2560x0, 1, vrr, 1
|
|
||||||
|
|
||||||
# ==================
|
t.Run("writes outputs lua from hyprlang monitors", func(t *testing.T) {
|
||||||
# ENVIRONMENT VARS
|
tmp := t.TempDir()
|
||||||
# ==================
|
existing := `monitor = DP-1, 1920x1080@144, 0x0, 1
|
||||||
env = XDG_CURRENT_DESKTOP,niri`,
|
|
||||||
existingConfig: `# Some other config
|
|
||||||
input {
|
|
||||||
kb_layout = us
|
|
||||||
}`,
|
|
||||||
wantError: false,
|
|
||||||
wantContains: []string{"MONITOR CONFIG", "ENVIRONMENT VARS"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "merge single monitor",
|
|
||||||
newConfig: `# ==================
|
|
||||||
# MONITOR CONFIG
|
|
||||||
# ==================
|
|
||||||
# monitor = eDP-2, 2560x1600@239.998993, 2560x0, 1, vrr, 1
|
|
||||||
|
|
||||||
# ==================
|
|
||||||
# ENVIRONMENT VARS
|
|
||||||
# ==================`,
|
|
||||||
existingConfig: `# My config
|
|
||||||
monitor = DP-1, 1920x1080@144, 0x0, 1
|
|
||||||
input {
|
|
||||||
kb_layout = us
|
|
||||||
}`,
|
|
||||||
wantError: false,
|
|
||||||
wantContains: []string{
|
|
||||||
"MONITOR CONFIG",
|
|
||||||
"monitor = DP-1, 1920x1080@144, 0x0, 1",
|
|
||||||
"Monitors from existing configuration",
|
|
||||||
},
|
|
||||||
wantNotContains: []string{
|
|
||||||
"monitor = eDP-2", // Example monitor should be removed
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "merge multiple monitors",
|
|
||||||
newConfig: `# ==================
|
|
||||||
# MONITOR CONFIG
|
|
||||||
# ==================
|
|
||||||
# monitor = eDP-2, 2560x1600@239.998993, 2560x0, 1, vrr, 1
|
|
||||||
|
|
||||||
# ==================
|
|
||||||
# ENVIRONMENT VARS
|
|
||||||
# ==================`,
|
|
||||||
existingConfig: `monitor = DP-1, 1920x1080@144, 0x0, 1
|
|
||||||
# monitor = HDMI-A-1, 1920x1080@60, 1920x0, 1
|
# monitor = HDMI-A-1, 1920x1080@60, 1920x0, 1
|
||||||
monitor = eDP-1, 2560x1440@165, auto, 1.25`,
|
monitor = eDP-1, 2560x1440@165, auto, 1.25`
|
||||||
wantError: false,
|
out, err := cd.mergeHyprlandMonitorSections(`return`, existing, tmp)
|
||||||
wantContains: []string{
|
require.NoError(t, err)
|
||||||
"monitor = DP-1",
|
assert.Equal(t, `return`, out)
|
||||||
"# monitor = HDMI-A-1", // Commented monitor preserved
|
b, err := os.ReadFile(filepath.Join(tmp, "outputs.lua"))
|
||||||
"monitor = eDP-1",
|
require.NoError(t, err)
|
||||||
"Monitors from existing configuration",
|
s := string(b)
|
||||||
},
|
assert.Contains(t, s, "hl.monitor")
|
||||||
wantNotContains: []string{
|
assert.Contains(t, s, "DP-1")
|
||||||
"monitor = eDP-2", // Example monitor should be removed
|
assert.Contains(t, s, "HDMI-A-1")
|
||||||
},
|
assert.Contains(t, s, "eDP-1")
|
||||||
},
|
assert.Contains(t, s, "preferred") // fallback rule at end
|
||||||
{
|
})
|
||||||
name: "preserve commented monitors",
|
|
||||||
newConfig: `# ==================
|
|
||||||
# MONITOR CONFIG
|
|
||||||
# ==================
|
|
||||||
# monitor = eDP-2, 2560x1600@239.998993, 2560x0, 1, vrr, 1
|
|
||||||
|
|
||||||
# ==================`,
|
t.Run("skips when outputs lua already exists", func(t *testing.T) {
|
||||||
existingConfig: `# monitor = DP-1, 1920x1080@144, 0x0, 1
|
tmp := t.TempDir()
|
||||||
# monitor = HDMI-A-1, 1920x1080@60, 1920x0, 1`,
|
path := filepath.Join(tmp, "outputs.lua")
|
||||||
wantError: false,
|
require.NoError(t, os.WriteFile(path, []byte("-- keep\n"), 0o644))
|
||||||
wantContains: []string{
|
_, err := cd.mergeHyprlandMonitorSections(`x`, `monitor = DP-1, 1920x1080@144, 0x0, 1`, tmp)
|
||||||
"# monitor = DP-1",
|
require.NoError(t, err)
|
||||||
"# monitor = HDMI-A-1",
|
b, err := os.ReadFile(path)
|
||||||
"Monitors from existing configuration",
|
require.NoError(t, err)
|
||||||
},
|
assert.Equal(t, "-- keep\n", string(b))
|
||||||
},
|
})
|
||||||
{
|
}
|
||||||
name: "no monitor config section",
|
|
||||||
newConfig: `# Some config without monitor section
|
|
||||||
input {
|
|
||||||
kb_layout = us
|
|
||||||
}`,
|
|
||||||
existingConfig: `monitor = DP-1, 1920x1080@144, 0x0, 1`,
|
|
||||||
wantError: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
result, err := cd.mergeHyprlandMonitorSections(tt.newConfig, tt.existingConfig, tmpDir)
|
|
||||||
|
|
||||||
if tt.wantError {
|
|
||||||
assert.Error(t, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
|
func TestHyprlangMonitorLineToLuaPreservesOptions(t *testing.T) {
|
||||||
|
got, err := hyprlangMonitorLineToLua(`monitor = DP-1, 1920x1080@144, 0x0, 1, transform, 1, vrr, 2, bitdepth, 10, cm, hdr, sdrbrightness, 1.2, sdrsaturation, 0.98`)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
for _, want := range tt.wantContains {
|
assert.Contains(t, got, `output = "DP-1"`)
|
||||||
assert.Contains(t, result, want, "merged config should contain: %s", want)
|
assert.Contains(t, got, `transform = 1`)
|
||||||
}
|
assert.Contains(t, got, `vrr = 2`)
|
||||||
|
assert.Contains(t, got, `bitdepth = 10`)
|
||||||
for _, notWant := range tt.wantNotContains {
|
assert.Contains(t, got, `cm = "hdr"`)
|
||||||
assert.NotContains(t, result, notWant, "merged config should NOT contain: %s", notWant)
|
assert.Contains(t, got, `sdrbrightness = 1.2`)
|
||||||
}
|
assert.Contains(t, got, `sdrsaturation = 0.98`)
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHyprlandConfigDeployment(t *testing.T) {
|
func TestHyprlandConfigDeployment(t *testing.T) {
|
||||||
@@ -398,6 +364,10 @@ func TestHyprlandConfigDeployment(t *testing.T) {
|
|||||||
cd := NewConfigDeployer(logChan)
|
cd := NewConfigDeployer(logChan)
|
||||||
|
|
||||||
t.Run("deploy hyprland config to empty directory", func(t *testing.T) {
|
t.Run("deploy hyprland config to empty directory", func(t *testing.T) {
|
||||||
|
td, err := os.MkdirTemp("", "dankinstall-hyprland-empty")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer os.RemoveAll(td)
|
||||||
|
os.Setenv("HOME", td)
|
||||||
result, err := cd.deployHyprlandConfig(deps.TerminalGhostty, true)
|
result, err := cd.deployHyprlandConfig(deps.TerminalGhostty, true)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -408,12 +378,16 @@ func TestHyprlandConfigDeployment(t *testing.T) {
|
|||||||
|
|
||||||
content, err := os.ReadFile(result.Path)
|
content, err := os.ReadFile(result.Path)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Contains(t, string(content), "# MONITOR CONFIG")
|
assert.Contains(t, string(content), `require("dms.binds")`)
|
||||||
assert.Contains(t, string(content), "source = ./dms/binds.conf")
|
assert.Contains(t, string(content), "DMS_STARTUP_BEGIN")
|
||||||
assert.Contains(t, string(content), "exec-once = ")
|
assert.Contains(t, string(content), "hl.config(")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("deploy hyprland config with existing monitors", func(t *testing.T) {
|
t.Run("deploy hyprland config with existing monitors", func(t *testing.T) {
|
||||||
|
td, err := os.MkdirTemp("", "dankinstall-hyprland-merge")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer os.RemoveAll(td)
|
||||||
|
os.Setenv("HOME", td)
|
||||||
existingContent := `# My existing Hyprland config
|
existingContent := `# My existing Hyprland config
|
||||||
monitor = DP-1, 1920x1080@144, 0x0, 1
|
monitor = DP-1, 1920x1080@144, 0x0, 1
|
||||||
monitor = HDMI-A-1, 3840x2160@60, 1920x0, 1.5
|
monitor = HDMI-A-1, 3840x2160@60, 1920x0, 1.5
|
||||||
@@ -422,11 +396,17 @@ general {
|
|||||||
gaps_in = 10
|
gaps_in = 10
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
hyprPath := filepath.Join(tempDir, ".config", "hypr", "hyprland.conf")
|
hyprPath := filepath.Join(td, ".config", "hypr", "hyprland.conf")
|
||||||
err := os.MkdirAll(filepath.Dir(hyprPath), 0o755)
|
err = os.MkdirAll(filepath.Dir(hyprPath), 0o755)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
err = os.WriteFile(hyprPath, []byte(existingContent), 0o644)
|
err = os.WriteFile(hyprPath, []byte(existingContent), 0o644)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
dmsDir := filepath.Join(td, ".config", "hypr", "dms")
|
||||||
|
require.NoError(t, os.MkdirAll(dmsDir, 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dmsDir, "binds.conf"), []byte("bind = SUPER, T, exec, foot\n"), 0o644))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dmsDir, "cursor.conf"), []byte("env = XCURSOR_SIZE,24\n"), 0o644))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(filepath.Dir(hyprPath), "hyprland.conf.backup.old"), []byte("old backup\n"), 0o644))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dmsDir, "binds.conf.backup.old"), []byte("old dms backup\n"), 0o644))
|
||||||
|
|
||||||
result, err := cd.deployHyprlandConfig(deps.TerminalKitty, true)
|
result, err := cd.deployHyprlandConfig(deps.TerminalKitty, true)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -440,13 +420,76 @@ general {
|
|||||||
backupContent, err := os.ReadFile(result.BackupPath)
|
backupContent, err := os.ReadFile(result.BackupPath)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, existingContent, string(backupContent))
|
assert.Equal(t, existingContent, string(backupContent))
|
||||||
|
assert.Contains(t, result.BackupPath, hyprlandBackupDirName)
|
||||||
|
assert.NoFileExists(t, hyprPath)
|
||||||
|
assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "dms", "binds.conf"))
|
||||||
|
assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "dms", "cursor.conf"))
|
||||||
|
assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "hyprland.conf.backup.old"))
|
||||||
|
assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "dms", "binds.conf.backup.old"))
|
||||||
|
assert.NoFileExists(t, filepath.Join(dmsDir, "binds.conf"))
|
||||||
|
assert.NoFileExists(t, filepath.Join(dmsDir, "cursor.conf"))
|
||||||
|
assert.NoFileExists(t, filepath.Join(filepath.Dir(hyprPath), "hyprland.conf.backup.old"))
|
||||||
|
assert.NoFileExists(t, filepath.Join(dmsDir, "binds.conf.backup.old"))
|
||||||
|
|
||||||
newContent, err := os.ReadFile(result.Path)
|
newContent, err := os.ReadFile(result.Path)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Contains(t, string(newContent), "monitor = DP-1, 1920x1080@144")
|
assert.Contains(t, string(newContent), `require("dms.binds")`)
|
||||||
assert.Contains(t, string(newContent), "monitor = HDMI-A-1, 3840x2160@60")
|
|
||||||
assert.Contains(t, string(newContent), "source = ./dms/binds.conf")
|
outputsPath := filepath.Join(td, ".config", "hypr", "dms", "outputs.lua")
|
||||||
assert.NotContains(t, string(newContent), "monitor = eDP-2")
|
outBytes, err := os.ReadFile(outputsPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
outs := string(outBytes)
|
||||||
|
assert.Contains(t, outs, `hl.monitor`)
|
||||||
|
assert.Contains(t, outs, "DP-1")
|
||||||
|
assert.Contains(t, outs, "HDMI-A-1")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("deploy hyprland config removes root legacy symlink when lua exists", func(t *testing.T) {
|
||||||
|
td, err := os.MkdirTemp("", "dankinstall-hyprland-lua-conf-symlink")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer os.RemoveAll(td)
|
||||||
|
os.Setenv("HOME", td)
|
||||||
|
|
||||||
|
configDir := filepath.Join(td, ".config", "hypr")
|
||||||
|
require.NoError(t, os.MkdirAll(configDir, 0o755))
|
||||||
|
luaPath := filepath.Join(configDir, "hyprland.lua")
|
||||||
|
confPath := filepath.Join(configDir, "hyprland.conf")
|
||||||
|
require.NoError(t, os.WriteFile(luaPath, []byte(`require("dms.binds")`+"\n"), 0o644))
|
||||||
|
require.NoError(t, os.Symlink(filepath.Join(configDir, "missing-legacy.conf"), confPath))
|
||||||
|
|
||||||
|
result, err := cd.deployHyprlandConfig(deps.TerminalKitty, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, luaPath, result.Path)
|
||||||
|
_, err = os.Lstat(confPath)
|
||||||
|
assert.True(t, os.IsNotExist(err), "root hyprland.conf symlink should be moved out of the live config directory")
|
||||||
|
_, err = os.Lstat(filepath.Join(filepath.Dir(result.BackupPath), "hyprland.conf"))
|
||||||
|
assert.NoError(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("deploy hyprland config refreshes managed binds but preserves user binds", func(t *testing.T) {
|
||||||
|
td, err := os.MkdirTemp("", "dankinstall-hyprland-refresh-binds")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer os.RemoveAll(td)
|
||||||
|
os.Setenv("HOME", td)
|
||||||
|
|
||||||
|
dmsDir := filepath.Join(td, ".config", "hypr", "dms")
|
||||||
|
require.NoError(t, os.MkdirAll(dmsDir, 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dmsDir, "binds.lua"), []byte("-- stale managed binds\n"), 0o644))
|
||||||
|
userBinds := "-- custom user binds\n"
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dmsDir, "binds-user.lua"), []byte(userBinds), 0o644))
|
||||||
|
|
||||||
|
_, err = cd.deployHyprlandConfig(deps.TerminalKitty, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
managed, err := os.ReadFile(filepath.Join(dmsDir, "binds.lua"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(managed), `hl.bind("SUPER + F", hl.dsp.window.fullscreen({ mode = "maximized", action = "toggle" }))`)
|
||||||
|
assert.Contains(t, string(managed), `hl.bind("SUPER + minus", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive -10% 0]]), { repeating = true })`)
|
||||||
|
|
||||||
|
user, err := os.ReadFile(filepath.Join(dmsDir, "binds-user.lua"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, userBinds, string(user))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -459,10 +502,10 @@ func TestNiriConfigStructure(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHyprlandConfigStructure(t *testing.T) {
|
func TestHyprlandConfigStructure(t *testing.T) {
|
||||||
assert.Contains(t, HyprlandConfig, "# MONITOR CONFIG")
|
assert.Contains(t, HyprlandLuaConfig, `require("dms.binds")`)
|
||||||
assert.Contains(t, HyprlandConfig, "# STARTUP APPS")
|
assert.Contains(t, HyprlandLuaConfig, "DMS_STARTUP_BEGIN")
|
||||||
assert.Contains(t, HyprlandConfig, "# INPUT CONFIG")
|
assert.Contains(t, HyprlandLuaConfig, "hl.config(")
|
||||||
assert.Contains(t, HyprlandConfig, "source = ./dms/binds.conf")
|
assert.Contains(t, HyprlandLuaConfig, "input =")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGhosttyConfigStructure(t *testing.T) {
|
func TestGhosttyConfigStructure(t *testing.T) {
|
||||||
@@ -789,4 +832,37 @@ func TestShouldReplaceConfigDeployIfMissing(t *testing.T) {
|
|||||||
}
|
}
|
||||||
assert.True(t, foundGhostty, "expected Ghostty config to be deployed when replaceConfigs is true")
|
assert.True(t, foundGhostty, "expected Ghostty config to be deployed when replaceConfigs is true")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("hyprland legacy config exists skips when replace false", func(t *testing.T) {
|
||||||
|
tempDir, err := os.MkdirTemp("", "dankinstall-hyprland-legacy-skip-test")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
originalHome := os.Getenv("HOME")
|
||||||
|
os.Setenv("HOME", tempDir)
|
||||||
|
defer os.Setenv("HOME", originalHome)
|
||||||
|
|
||||||
|
hyprConf := filepath.Join(tempDir, ".config", "hypr", "hyprland.conf")
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(hyprConf), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(hyprConf, []byte("monitor = , preferred, auto, 1\n"), 0o644))
|
||||||
|
|
||||||
|
logChan := make(chan string, 100)
|
||||||
|
cd := NewConfigDeployer(logChan)
|
||||||
|
results, err := cd.deployConfigurationsInternal(
|
||||||
|
context.Background(),
|
||||||
|
deps.WindowManagerHyprland,
|
||||||
|
deps.TerminalGhostty,
|
||||||
|
nil,
|
||||||
|
allFalse,
|
||||||
|
nil,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
for _, r := range results {
|
||||||
|
if r.ConfigType == "Hyprland" && r.Deployed {
|
||||||
|
t.Fatalf("expected Hyprland deployment to be skipped when legacy config exists and replace=false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
-- Optional per-user keybind overrides (managed by DMS). Loaded after default binds.
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
# === Application Launchers ===
|
|
||||||
bind = SUPER, T, exec, {{TERMINAL_COMMAND}}
|
|
||||||
bind = SUPER, space, exec, dms ipc call spotlight toggle
|
|
||||||
bind = SUPER, V, exec, dms ipc call clipboard toggle
|
|
||||||
bind = SUPER, M, exec, dms ipc call processlist focusOrToggle
|
|
||||||
bind = SUPER, comma, exec, dms ipc call settings focusOrToggle
|
|
||||||
bind = SUPER, N, exec, dms ipc call notifications toggle
|
|
||||||
bind = SUPER SHIFT, N, exec, dms ipc call notepad toggle
|
|
||||||
bind = SUPER, Y, exec, dms ipc call dankdash wallpaper
|
|
||||||
bind = SUPER, TAB, exec, dms ipc call hypr toggleOverview
|
|
||||||
bind = SUPER, X, exec, dms ipc call powermenu toggle
|
|
||||||
|
|
||||||
# === Cheat sheet
|
|
||||||
bind = SUPER SHIFT, Slash, exec, dms ipc call keybinds toggle hyprland
|
|
||||||
|
|
||||||
# === Security ===
|
|
||||||
bind = SUPER ALT, L, exec, dms ipc call lock lock
|
|
||||||
bind = SUPER SHIFT, E, exit
|
|
||||||
bind = CTRL ALT, Delete, exec, dms ipc call processlist focusOrToggle
|
|
||||||
|
|
||||||
# === Audio Controls ===
|
|
||||||
bindel = , XF86AudioRaiseVolume, exec, dms ipc call audio increment 3
|
|
||||||
bindel = , XF86AudioLowerVolume, exec, dms ipc call audio decrement 3
|
|
||||||
bindl = , XF86AudioMute, exec, dms ipc call audio mute
|
|
||||||
bindl = , XF86AudioMicMute, exec, dms ipc call audio micmute
|
|
||||||
bindl = , XF86AudioPause, exec, dms ipc call mpris playPause
|
|
||||||
bindl = , XF86AudioPlay, exec, dms ipc call mpris playPause
|
|
||||||
bindl = , XF86AudioPrev, exec, dms ipc call mpris previous
|
|
||||||
bindl = , XF86AudioNext, exec, dms ipc call mpris next
|
|
||||||
bindel = CTRL, XF86AudioRaiseVolume, exec, dms ipc call mpris increment 3
|
|
||||||
bindel = CTRL, XF86AudioLowerVolume, exec, dms ipc call mpris decrement 3
|
|
||||||
|
|
||||||
# === Brightness Controls ===
|
|
||||||
bindel = , XF86MonBrightnessUp, exec, dms ipc call brightness increment 5 ""
|
|
||||||
bindel = , XF86MonBrightnessDown, exec, dms ipc call brightness decrement 5 ""
|
|
||||||
|
|
||||||
# === Window Management ===
|
|
||||||
bind = SUPER, Q, killactive
|
|
||||||
bind = SUPER, F, fullscreen, 1
|
|
||||||
bind = SUPER SHIFT, F, fullscreen, 0
|
|
||||||
bind = SUPER SHIFT, T, togglefloating
|
|
||||||
bind = SUPER, W, togglegroup
|
|
||||||
bind = SUPER SHIFT, W, exec, dms ipc call window-rules toggle
|
|
||||||
|
|
||||||
# === Focus Navigation ===
|
|
||||||
bind = SUPER, left, movefocus, l
|
|
||||||
bind = SUPER, down, movefocus, d
|
|
||||||
bind = SUPER, up, movefocus, u
|
|
||||||
bind = SUPER, right, movefocus, r
|
|
||||||
bind = SUPER, H, movefocus, l
|
|
||||||
bind = SUPER, J, movefocus, d
|
|
||||||
bind = SUPER, K, movefocus, u
|
|
||||||
bind = SUPER, L, movefocus, r
|
|
||||||
|
|
||||||
# === Window Movement ===
|
|
||||||
bind = SUPER SHIFT, left, movewindow, l
|
|
||||||
bind = SUPER SHIFT, down, movewindow, d
|
|
||||||
bind = SUPER SHIFT, up, movewindow, u
|
|
||||||
bind = SUPER SHIFT, right, movewindow, r
|
|
||||||
bind = SUPER SHIFT, H, movewindow, l
|
|
||||||
bind = SUPER SHIFT, J, movewindow, d
|
|
||||||
bind = SUPER SHIFT, K, movewindow, u
|
|
||||||
bind = SUPER SHIFT, L, movewindow, r
|
|
||||||
|
|
||||||
# === Column Navigation ===
|
|
||||||
bind = SUPER, Home, focuswindow, first
|
|
||||||
bind = SUPER, End, focuswindow, last
|
|
||||||
|
|
||||||
# === Monitor Navigation ===
|
|
||||||
bind = SUPER CTRL, left, focusmonitor, l
|
|
||||||
bind = SUPER CTRL, right, focusmonitor, r
|
|
||||||
bind = SUPER CTRL, H, focusmonitor, l
|
|
||||||
bind = SUPER CTRL, J, focusmonitor, d
|
|
||||||
bind = SUPER CTRL, K, focusmonitor, u
|
|
||||||
bind = SUPER CTRL, L, focusmonitor, r
|
|
||||||
|
|
||||||
# === Move to Monitor ===
|
|
||||||
bind = SUPER SHIFT CTRL, left, movewindow, mon:l
|
|
||||||
bind = SUPER SHIFT CTRL, down, movewindow, mon:d
|
|
||||||
bind = SUPER SHIFT CTRL, up, movewindow, mon:u
|
|
||||||
bind = SUPER SHIFT CTRL, right, movewindow, mon:r
|
|
||||||
bind = SUPER SHIFT CTRL, H, movewindow, mon:l
|
|
||||||
bind = SUPER SHIFT CTRL, J, movewindow, mon:d
|
|
||||||
bind = SUPER SHIFT CTRL, K, movewindow, mon:u
|
|
||||||
bind = SUPER SHIFT CTRL, L, movewindow, mon:r
|
|
||||||
|
|
||||||
# === Workspace Navigation ===
|
|
||||||
bind = SUPER, Page_Down, workspace, e+1
|
|
||||||
bind = SUPER, Page_Up, workspace, e-1
|
|
||||||
bind = SUPER, U, workspace, e+1
|
|
||||||
bind = SUPER, I, workspace, e-1
|
|
||||||
bind = SUPER CTRL, down, movetoworkspace, e+1
|
|
||||||
bind = SUPER CTRL, up, movetoworkspace, e-1
|
|
||||||
bind = SUPER CTRL, U, movetoworkspace, e+1
|
|
||||||
bind = SUPER CTRL, I, movetoworkspace, e-1
|
|
||||||
|
|
||||||
# === Workspace Management ===
|
|
||||||
bind = CTRL SHIFT, R, exec, dms ipc call workspace-rename open
|
|
||||||
|
|
||||||
# === Move Workspaces ===
|
|
||||||
bind = SUPER SHIFT, Page_Down, movetoworkspace, e+1
|
|
||||||
bind = SUPER SHIFT, Page_Up, movetoworkspace, e-1
|
|
||||||
bind = SUPER SHIFT, U, movetoworkspace, e+1
|
|
||||||
bind = SUPER SHIFT, I, movetoworkspace, e-1
|
|
||||||
|
|
||||||
# === Mouse Wheel Navigation ===
|
|
||||||
bind = SUPER, mouse_down, workspace, e+1
|
|
||||||
bind = SUPER, mouse_up, workspace, e-1
|
|
||||||
bind = SUPER CTRL, mouse_down, movetoworkspace, e+1
|
|
||||||
bind = SUPER CTRL, mouse_up, movetoworkspace, e-1
|
|
||||||
|
|
||||||
# === Numbered Workspaces ===
|
|
||||||
bind = SUPER, 1, workspace, 1
|
|
||||||
bind = SUPER, 2, workspace, 2
|
|
||||||
bind = SUPER, 3, workspace, 3
|
|
||||||
bind = SUPER, 4, workspace, 4
|
|
||||||
bind = SUPER, 5, workspace, 5
|
|
||||||
bind = SUPER, 6, workspace, 6
|
|
||||||
bind = SUPER, 7, workspace, 7
|
|
||||||
bind = SUPER, 8, workspace, 8
|
|
||||||
bind = SUPER, 9, workspace, 9
|
|
||||||
|
|
||||||
# === Move to Numbered Workspaces ===
|
|
||||||
bind = SUPER SHIFT, 1, movetoworkspace, 1
|
|
||||||
bind = SUPER SHIFT, 2, movetoworkspace, 2
|
|
||||||
bind = SUPER SHIFT, 3, movetoworkspace, 3
|
|
||||||
bind = SUPER SHIFT, 4, movetoworkspace, 4
|
|
||||||
bind = SUPER SHIFT, 5, movetoworkspace, 5
|
|
||||||
bind = SUPER SHIFT, 6, movetoworkspace, 6
|
|
||||||
bind = SUPER SHIFT, 7, movetoworkspace, 7
|
|
||||||
bind = SUPER SHIFT, 8, movetoworkspace, 8
|
|
||||||
bind = SUPER SHIFT, 9, movetoworkspace, 9
|
|
||||||
|
|
||||||
# === Column Management ===
|
|
||||||
bind = SUPER, bracketleft, layoutmsg, preselect l
|
|
||||||
bind = SUPER, bracketright, layoutmsg, preselect r
|
|
||||||
|
|
||||||
# === Sizing & Layout ===
|
|
||||||
bind = SUPER, R, layoutmsg, togglesplit
|
|
||||||
bind = SUPER CTRL, F, resizeactive, exact 100% 100%
|
|
||||||
|
|
||||||
# === Move/resize windows with mainMod + LMB/RMB and dragging ===
|
|
||||||
bindmd = SUPER, mouse:272, Move window, movewindow
|
|
||||||
bindmd = SUPER, mouse:273, Resize window, resizewindow
|
|
||||||
|
|
||||||
# === Move/resize windows with mainMod + LMB/RMB and dragging ===
|
|
||||||
bindd = SUPER, code:20, Expand window left, resizeactive, -100 0
|
|
||||||
bindd = SUPER, code:21, Shrink window left, resizeactive, 100 0
|
|
||||||
|
|
||||||
# === Manual Sizing ===
|
|
||||||
binde = SUPER, minus, resizeactive, -10% 0
|
|
||||||
binde = SUPER, equal, resizeactive, 10% 0
|
|
||||||
binde = SUPER SHIFT, minus, resizeactive, 0 -10%
|
|
||||||
binde = SUPER SHIFT, equal, resizeactive, 0 10%
|
|
||||||
|
|
||||||
# === Screenshots ===
|
|
||||||
bind = , Print, exec, dms screenshot
|
|
||||||
bind = CTRL, Print, exec, dms screenshot full
|
|
||||||
bind = ALT, Print, exec, dms screenshot window
|
|
||||||
|
|
||||||
# === System Controls ===
|
|
||||||
bind = SUPER SHIFT, P, dpms, toggle
|
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
-- DMS default keybinds (Hyprland 0.55+ Lua)
|
||||||
|
|
||||||
|
-- === Application Launchers ===
|
||||||
|
hl.bind("SUPER + T", hl.dsp.exec_cmd("{{TERMINAL_COMMAND}}"))
|
||||||
|
hl.bind("SUPER + space", hl.dsp.exec_cmd("dms ipc call spotlight toggle"))
|
||||||
|
hl.bind("ALT + space", hl.dsp.exec_cmd("dms ipc call spotlight-bar toggle"))
|
||||||
|
hl.bind("SUPER + V", hl.dsp.exec_cmd("dms ipc call clipboard toggle"))
|
||||||
|
hl.bind("SUPER + M", hl.dsp.exec_cmd("dms ipc call processlist focusOrToggle"))
|
||||||
|
hl.bind("SUPER + comma", hl.dsp.exec_cmd("dms ipc call settings focusOrToggle"))
|
||||||
|
hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notifications toggle"))
|
||||||
|
hl.bind("SUPER + SHIFT + N", hl.dsp.exec_cmd("dms ipc call notepad toggle"))
|
||||||
|
hl.bind("SUPER + Y", hl.dsp.exec_cmd("dms ipc call dankdash wallpaper"))
|
||||||
|
hl.bind("SUPER + TAB", hl.dsp.exec_cmd("dms ipc call hypr toggleOverview"))
|
||||||
|
hl.bind("SUPER + X", hl.dsp.exec_cmd("dms ipc call powermenu toggle"))
|
||||||
|
|
||||||
|
-- === Cheat sheet
|
||||||
|
hl.bind("SUPER + SHIFT + Slash", hl.dsp.exec_cmd("dms ipc call keybinds toggle hyprland"))
|
||||||
|
|
||||||
|
-- === Security ===
|
||||||
|
hl.bind("SUPER + ALT + L", hl.dsp.exec_cmd("dms ipc call lock lock"))
|
||||||
|
hl.bind("SUPER + SHIFT + E", hl.dsp.exit())
|
||||||
|
hl.bind("CTRL + ALT + Delete", hl.dsp.exec_cmd("dms ipc call processlist focusOrToggle"))
|
||||||
|
|
||||||
|
-- === Audio Controls ===
|
||||||
|
hl.bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("dms ipc call audio increment 3"), { locked = true, repeating = true })
|
||||||
|
hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("dms ipc call audio decrement 3"), { locked = true, repeating = true })
|
||||||
|
hl.bind("XF86AudioMute", hl.dsp.exec_cmd("dms ipc call audio mute"), { locked = true })
|
||||||
|
hl.bind("XF86AudioMicMute", hl.dsp.exec_cmd("dms ipc call audio micmute"), { locked = true })
|
||||||
|
hl.bind("XF86AudioPause", hl.dsp.exec_cmd("dms ipc call mpris playPause"), { locked = true })
|
||||||
|
hl.bind("XF86AudioPlay", hl.dsp.exec_cmd("dms ipc call mpris playPause"), { locked = true })
|
||||||
|
hl.bind("XF86AudioPrev", hl.dsp.exec_cmd("dms ipc call mpris previous"), { locked = true })
|
||||||
|
hl.bind("XF86AudioNext", hl.dsp.exec_cmd("dms ipc call mpris next"), { locked = true })
|
||||||
|
hl.bind("CTRL + XF86AudioRaiseVolume", hl.dsp.exec_cmd("dms ipc call mpris increment 3"), { locked = true, repeating = true })
|
||||||
|
hl.bind("CTRL + XF86AudioLowerVolume", hl.dsp.exec_cmd("dms ipc call mpris decrement 3"), { locked = true, repeating = true })
|
||||||
|
|
||||||
|
-- === Brightness Controls ===
|
||||||
|
hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd([[dms ipc call brightness increment 5 ""]]), { locked = true, repeating = true })
|
||||||
|
hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd([[dms ipc call brightness decrement 5 ""]]), { locked = true, repeating = true })
|
||||||
|
|
||||||
|
-- === Window Management ===
|
||||||
|
hl.bind("SUPER + Q", hl.dsp.window.kill())
|
||||||
|
hl.bind("SUPER + F", hl.dsp.window.fullscreen({ mode = "maximized", action = "toggle" }))
|
||||||
|
hl.bind("SUPER + SHIFT + F", hl.dsp.window.fullscreen({ mode = "fullscreen", action = "toggle" }))
|
||||||
|
hl.bind("SUPER + SHIFT + T", hl.dsp.window.float({ action = "toggle" }))
|
||||||
|
hl.bind("SUPER + W", hl.dsp.group.toggle())
|
||||||
|
hl.bind("SUPER + SHIFT + W", hl.dsp.exec_cmd("dms ipc call window-rules toggle"))
|
||||||
|
|
||||||
|
-- === Focus Navigation ===
|
||||||
|
hl.bind("SUPER + left", hl.dsp.focus({ direction = "l" }))
|
||||||
|
hl.bind("SUPER + down", hl.dsp.focus({ direction = "d" }))
|
||||||
|
hl.bind("SUPER + up", hl.dsp.focus({ direction = "u" }))
|
||||||
|
hl.bind("SUPER + right", hl.dsp.focus({ direction = "r" }))
|
||||||
|
hl.bind("SUPER + H", hl.dsp.focus({ direction = "l" }))
|
||||||
|
hl.bind("SUPER + J", hl.dsp.focus({ direction = "d" }))
|
||||||
|
hl.bind("SUPER + K", hl.dsp.focus({ direction = "u" }))
|
||||||
|
hl.bind("SUPER + L", hl.dsp.focus({ direction = "r" }))
|
||||||
|
|
||||||
|
-- === Window Movement ===
|
||||||
|
hl.bind("SUPER + SHIFT + left", hl.dsp.window.move({ direction = "l" }))
|
||||||
|
hl.bind("SUPER + SHIFT + down", hl.dsp.window.move({ direction = "d" }))
|
||||||
|
hl.bind("SUPER + SHIFT + up", hl.dsp.window.move({ direction = "u" }))
|
||||||
|
hl.bind("SUPER + SHIFT + right", hl.dsp.window.move({ direction = "r" }))
|
||||||
|
hl.bind("SUPER + SHIFT + H", hl.dsp.window.move({ direction = "l" }))
|
||||||
|
hl.bind("SUPER + SHIFT + J", hl.dsp.window.move({ direction = "d" }))
|
||||||
|
hl.bind("SUPER + SHIFT + K", hl.dsp.window.move({ direction = "u" }))
|
||||||
|
hl.bind("SUPER + SHIFT + L", hl.dsp.window.move({ direction = "r" }))
|
||||||
|
|
||||||
|
-- === Column Navigation ===
|
||||||
|
hl.bind("SUPER + Home", hl.dsp.focus({ window = "first" }))
|
||||||
|
hl.bind("SUPER + End", hl.dsp.focus({ window = "last" }))
|
||||||
|
|
||||||
|
-- === Monitor Navigation ===
|
||||||
|
hl.bind("SUPER + CTRL + left", hl.dsp.focus({ monitor = "l" }))
|
||||||
|
hl.bind("SUPER + CTRL + right", hl.dsp.focus({ monitor = "r" }))
|
||||||
|
hl.bind("SUPER + CTRL + H", hl.dsp.focus({ monitor = "l" }))
|
||||||
|
hl.bind("SUPER + CTRL + J", hl.dsp.focus({ monitor = "d" }))
|
||||||
|
hl.bind("SUPER + CTRL + K", hl.dsp.focus({ monitor = "u" }))
|
||||||
|
hl.bind("SUPER + CTRL + L", hl.dsp.focus({ monitor = "r" }))
|
||||||
|
|
||||||
|
-- === Move to Monitor ===
|
||||||
|
hl.bind("SUPER + SHIFT + CTRL + left", hl.dsp.window.move({ monitor = "l" }))
|
||||||
|
hl.bind("SUPER + SHIFT + CTRL + down", hl.dsp.window.move({ monitor = "d" }))
|
||||||
|
hl.bind("SUPER + SHIFT + CTRL + up", hl.dsp.window.move({ monitor = "u" }))
|
||||||
|
hl.bind("SUPER + SHIFT + CTRL + right", hl.dsp.window.move({ monitor = "r" }))
|
||||||
|
hl.bind("SUPER + SHIFT + CTRL + H", hl.dsp.window.move({ monitor = "l" }))
|
||||||
|
hl.bind("SUPER + SHIFT + CTRL + J", hl.dsp.window.move({ monitor = "d" }))
|
||||||
|
hl.bind("SUPER + SHIFT + CTRL + K", hl.dsp.window.move({ monitor = "u" }))
|
||||||
|
hl.bind("SUPER + SHIFT + CTRL + L", hl.dsp.window.move({ monitor = "r" }))
|
||||||
|
|
||||||
|
-- === Workspace Navigation ===
|
||||||
|
hl.bind("SUPER + Page_Down", hl.dsp.focus({ workspace = "e+1" }))
|
||||||
|
hl.bind("SUPER + Page_Up", hl.dsp.focus({ workspace = "e-1" }))
|
||||||
|
hl.bind("SUPER + U", hl.dsp.focus({ workspace = "e+1" }))
|
||||||
|
hl.bind("SUPER + I", hl.dsp.focus({ workspace = "e-1" }))
|
||||||
|
hl.bind("SUPER + CTRL + down", hl.dsp.window.move({ workspace = "e+1" }))
|
||||||
|
hl.bind("SUPER + CTRL + up", hl.dsp.window.move({ workspace = "e-1" }))
|
||||||
|
hl.bind("SUPER + CTRL + U", hl.dsp.window.move({ workspace = "e+1" }))
|
||||||
|
hl.bind("SUPER + CTRL + I", hl.dsp.window.move({ workspace = "e-1" }))
|
||||||
|
|
||||||
|
-- === Workspace Management ===
|
||||||
|
hl.bind("CTRL + SHIFT + R", hl.dsp.exec_cmd("dms ipc call workspace-rename open"))
|
||||||
|
|
||||||
|
-- === Move Workspaces ===
|
||||||
|
hl.bind("SUPER + SHIFT + Page_Down", hl.dsp.window.move({ workspace = "e+1" }))
|
||||||
|
hl.bind("SUPER + SHIFT + Page_Up", hl.dsp.window.move({ workspace = "e-1" }))
|
||||||
|
hl.bind("SUPER + SHIFT + U", hl.dsp.window.move({ workspace = "e+1" }))
|
||||||
|
hl.bind("SUPER + SHIFT + I", hl.dsp.window.move({ workspace = "e-1" }))
|
||||||
|
|
||||||
|
-- === Mouse Wheel Navigation ===
|
||||||
|
hl.bind("SUPER + mouse_down", hl.dsp.focus({ workspace = "e+1" }))
|
||||||
|
hl.bind("SUPER + mouse_up", hl.dsp.focus({ workspace = "e-1" }))
|
||||||
|
hl.bind("SUPER + CTRL + mouse_down", hl.dsp.window.move({ workspace = "e+1" }))
|
||||||
|
hl.bind("SUPER + CTRL + mouse_up", hl.dsp.window.move({ workspace = "e-1" }))
|
||||||
|
|
||||||
|
-- === Numbered Workspaces ===
|
||||||
|
hl.bind("SUPER + 1", hl.dsp.focus({ workspace = "1" }))
|
||||||
|
hl.bind("SUPER + 2", hl.dsp.focus({ workspace = "2" }))
|
||||||
|
hl.bind("SUPER + 3", hl.dsp.focus({ workspace = "3" }))
|
||||||
|
hl.bind("SUPER + 4", hl.dsp.focus({ workspace = "4" }))
|
||||||
|
hl.bind("SUPER + 5", hl.dsp.focus({ workspace = "5" }))
|
||||||
|
hl.bind("SUPER + 6", hl.dsp.focus({ workspace = "6" }))
|
||||||
|
hl.bind("SUPER + 7", hl.dsp.focus({ workspace = "7" }))
|
||||||
|
hl.bind("SUPER + 8", hl.dsp.focus({ workspace = "8" }))
|
||||||
|
hl.bind("SUPER + 9", hl.dsp.focus({ workspace = "9" }))
|
||||||
|
|
||||||
|
-- === Move to Numbered Workspaces ===
|
||||||
|
hl.bind("SUPER + SHIFT + 1", hl.dsp.window.move({ workspace = "1" }))
|
||||||
|
hl.bind("SUPER + SHIFT + 2", hl.dsp.window.move({ workspace = "2" }))
|
||||||
|
hl.bind("SUPER + SHIFT + 3", hl.dsp.window.move({ workspace = "3" }))
|
||||||
|
hl.bind("SUPER + SHIFT + 4", hl.dsp.window.move({ workspace = "4" }))
|
||||||
|
hl.bind("SUPER + SHIFT + 5", hl.dsp.window.move({ workspace = "5" }))
|
||||||
|
hl.bind("SUPER + SHIFT + 6", hl.dsp.window.move({ workspace = "6" }))
|
||||||
|
hl.bind("SUPER + SHIFT + 7", hl.dsp.window.move({ workspace = "7" }))
|
||||||
|
hl.bind("SUPER + SHIFT + 8", hl.dsp.window.move({ workspace = "8" }))
|
||||||
|
hl.bind("SUPER + SHIFT + 9", hl.dsp.window.move({ workspace = "9" }))
|
||||||
|
|
||||||
|
-- === Column Management ===
|
||||||
|
hl.bind("SUPER + bracketleft", hl.dsp.layout("preselect l"))
|
||||||
|
hl.bind("SUPER + bracketright", hl.dsp.layout("preselect r"))
|
||||||
|
|
||||||
|
-- === Sizing & Layout ===
|
||||||
|
hl.bind("SUPER + R", hl.dsp.layout("togglesplit"))
|
||||||
|
hl.bind("SUPER + CTRL + F", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive exact 100% 100%]]))
|
||||||
|
|
||||||
|
-- === Move/resize windows with mainMod + LMB/RMB and dragging ===
|
||||||
|
hl.bind("SUPER + mouse:272", hl.dsp.window.drag(), { mouse = true, description = "Move window" })
|
||||||
|
hl.bind("SUPER + mouse:273", hl.dsp.window.resize(), { mouse = true, description = "Resize window" })
|
||||||
|
|
||||||
|
hl.bind("SUPER + code:20", hl.dsp.window.resize({ x = -100, y = 0, relative = true }), { description = "Expand window left" })
|
||||||
|
hl.bind("SUPER + code:21", hl.dsp.window.resize({ x = 100, y = 0, relative = true }), { description = "Shrink window left" })
|
||||||
|
|
||||||
|
-- === Manual Sizing ===
|
||||||
|
hl.bind("SUPER + minus", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive -10% 0]]), { repeating = true })
|
||||||
|
hl.bind("SUPER + equal", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive 10% 0]]), { repeating = true })
|
||||||
|
hl.bind("SUPER + SHIFT + minus", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive 0 -10%]]), { repeating = true })
|
||||||
|
hl.bind("SUPER + SHIFT + equal", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive 0 10%]]), { repeating = true })
|
||||||
|
|
||||||
|
-- === Screenshots ===
|
||||||
|
hl.bind("Print", hl.dsp.exec_cmd("dms screenshot"))
|
||||||
|
hl.bind("CTRL + Print", hl.dsp.exec_cmd("dms screenshot full"))
|
||||||
|
hl.bind("ALT + Print", hl.dsp.exec_cmd("dms screenshot window"))
|
||||||
|
|
||||||
|
-- === Display Profiles ===
|
||||||
|
hl.bind("SUPER + P", hl.dsp.exec_cmd("dms ipc outputs cycleProfile"))
|
||||||
|
|
||||||
|
-- === System Controls ===
|
||||||
|
hl.bind("SUPER + SHIFT + P", hl.dsp.dpms({ action = "toggle" }))
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
# ! Auto-generated file. Do not edit directly.
|
|
||||||
# Remove source = ./dms/colors.conf from your config to override.
|
|
||||||
|
|
||||||
$primary = rgb(d0bcff)
|
|
||||||
$outline = rgb(948f99)
|
|
||||||
$error = rgb(f2b8b5)
|
|
||||||
|
|
||||||
general {
|
|
||||||
col.active_border = $primary
|
|
||||||
col.inactive_border = $outline
|
|
||||||
}
|
|
||||||
|
|
||||||
group {
|
|
||||||
col.border_active = $primary
|
|
||||||
col.border_inactive = $outline
|
|
||||||
col.border_locked_active = $error
|
|
||||||
col.border_locked_inactive = $outline
|
|
||||||
|
|
||||||
groupbar {
|
|
||||||
col.active = $primary
|
|
||||||
col.inactive = $outline
|
|
||||||
col.locked_active = $error
|
|
||||||
col.locked_inactive = $outline
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
-- ! Auto-generated file. Do not edit directly.
|
||||||
|
-- Regenerate via DMS theme tools or remove require("dms.colors") from hyprland.lua to override.
|
||||||
|
|
||||||
|
hl.config({
|
||||||
|
general = {
|
||||||
|
col = {
|
||||||
|
active_border = "rgb(d0bcff)",
|
||||||
|
inactive_border = "rgb(948f99)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
group = {
|
||||||
|
col = {
|
||||||
|
border_active = "rgb(d0bcff)",
|
||||||
|
border_inactive = "rgb(948f99)",
|
||||||
|
border_locked_active = "rgb(f2b8b5)",
|
||||||
|
border_locked_inactive = "rgb(948f99)",
|
||||||
|
},
|
||||||
|
groupbar = {
|
||||||
|
col = {
|
||||||
|
active = "rgb(d0bcff)",
|
||||||
|
inactive = "rgb(948f99)",
|
||||||
|
locked_active = "rgb(f2b8b5)",
|
||||||
|
locked_inactive = "rgb(948f99)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
-- Cursor theme overrides. Deploy writes ~/.config/hypr/dms/cursor.lua
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# Auto-generated by DMS - do not edit manually
|
|
||||||
|
|
||||||
general {
|
|
||||||
gaps_in = 4
|
|
||||||
gaps_out = 4
|
|
||||||
border_size = 2
|
|
||||||
}
|
|
||||||
|
|
||||||
decoration {
|
|
||||||
rounding = 12
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- Auto-generated by DMS — do not edit manually
|
||||||
|
|
||||||
|
hl.config({
|
||||||
|
general = {
|
||||||
|
gaps_in = 4,
|
||||||
|
gaps_out = 4,
|
||||||
|
border_size = 2,
|
||||||
|
},
|
||||||
|
decoration = {
|
||||||
|
rounding = 12,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Per-output monitor rules — embedded sibling of the legacy outputs.conf fragment. Deploy writes ~/.config/hypr/dms/outputs.lua
|
||||||
|
|
||||||
|
hl.monitor({ output = "", mode = "preferred", position = "auto", scale = "auto" })
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
-- Window rules. Deploy writes ~/.config/hypr/dms/windowrules.lua
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
# Hyprland Configuration
|
|
||||||
# https://wiki.hypr.land/Configuring/
|
|
||||||
|
|
||||||
# ==================
|
|
||||||
# MONITOR CONFIG
|
|
||||||
# ==================
|
|
||||||
# monitor = eDP-2, 2560x1600@239.998993, 2560x0, 1, vrr, 1
|
|
||||||
monitor = , preferred,auto,auto
|
|
||||||
|
|
||||||
# ==================
|
|
||||||
# STARTUP APPS
|
|
||||||
# ==================
|
|
||||||
exec-once = dbus-update-activation-environment --systemd --all
|
|
||||||
exec-once = systemctl --user start hyprland-session.target
|
|
||||||
|
|
||||||
# ==================
|
|
||||||
# INPUT CONFIG
|
|
||||||
# ==================
|
|
||||||
input {
|
|
||||||
kb_layout = us
|
|
||||||
numlock_by_default = true
|
|
||||||
}
|
|
||||||
|
|
||||||
# ==================
|
|
||||||
# GENERAL LAYOUT
|
|
||||||
# ==================
|
|
||||||
general {
|
|
||||||
gaps_in = 5
|
|
||||||
gaps_out = 5
|
|
||||||
border_size = 2
|
|
||||||
|
|
||||||
layout = dwindle
|
|
||||||
}
|
|
||||||
|
|
||||||
# ==================
|
|
||||||
# DECORATION
|
|
||||||
# ==================
|
|
||||||
decoration {
|
|
||||||
rounding = 12
|
|
||||||
|
|
||||||
active_opacity = 1.0
|
|
||||||
inactive_opacity = 1.0
|
|
||||||
|
|
||||||
shadow {
|
|
||||||
enabled = true
|
|
||||||
range = 30
|
|
||||||
render_power = 5
|
|
||||||
offset = 0 5
|
|
||||||
color = rgba(00000070)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# ==================
|
|
||||||
# ANIMATIONS
|
|
||||||
# ==================
|
|
||||||
animations {
|
|
||||||
enabled = true
|
|
||||||
|
|
||||||
animation = windowsIn, 1, 3, default
|
|
||||||
animation = windowsOut, 1, 3, default
|
|
||||||
animation = workspaces, 1, 5, default
|
|
||||||
animation = windowsMove, 1, 4, default
|
|
||||||
animation = fade, 1, 3, default
|
|
||||||
animation = border, 1, 3, default
|
|
||||||
}
|
|
||||||
|
|
||||||
# ==================
|
|
||||||
# LAYOUTS
|
|
||||||
# ==================
|
|
||||||
dwindle {
|
|
||||||
preserve_split = true
|
|
||||||
}
|
|
||||||
|
|
||||||
master {
|
|
||||||
mfact = 0.5
|
|
||||||
}
|
|
||||||
|
|
||||||
# ==================
|
|
||||||
# MISC
|
|
||||||
# ==================
|
|
||||||
misc {
|
|
||||||
disable_hyprland_logo = true
|
|
||||||
disable_splash_rendering = true
|
|
||||||
}
|
|
||||||
|
|
||||||
# ==================
|
|
||||||
# WINDOW RULES
|
|
||||||
# ==================
|
|
||||||
windowrule = tile on, match:class ^(org\.wezfurlong\.wezterm)$
|
|
||||||
|
|
||||||
windowrule = rounding 12, match:class ^(org\.gnome\.)
|
|
||||||
|
|
||||||
windowrule = tile on, match:class ^(gnome-control-center)$
|
|
||||||
windowrule = tile on, match:class ^(pavucontrol)$
|
|
||||||
windowrule = tile on, match:class ^(nm-connection-editor)$
|
|
||||||
|
|
||||||
windowrule = float on, match:class ^(org\.gnome\.Calculator)$
|
|
||||||
windowrule = float on, match:class ^(gnome-calculator)$
|
|
||||||
windowrule = float on, match:class ^(galculator)$
|
|
||||||
windowrule = float on, match:class ^(blueman-manager)$
|
|
||||||
windowrule = float on, match:class ^(org\.gnome\.Nautilus)$
|
|
||||||
windowrule = float on, match:class ^(xdg-desktop-portal)$
|
|
||||||
|
|
||||||
windowrule = no_initial_focus on, match:class ^(steam)$, match:title ^(notificationtoasts)
|
|
||||||
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:.*
|
|
||||||
|
|
||||||
source = ./dms/colors.conf
|
|
||||||
source = ./dms/outputs.conf
|
|
||||||
source = ./dms/layout.conf
|
|
||||||
source = ./dms/cursor.conf
|
|
||||||
source = ./dms/binds.conf
|
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
-- Hyprland configuration (Lua) — https://wiki.hypr.land/Configuring/Start/
|
||||||
|
|
||||||
|
hl.config({ autogenerated = false })
|
||||||
|
|
||||||
|
-- DMS_STARTUP_BEGIN
|
||||||
|
hl.on("hyprland.start", function()
|
||||||
|
hl.exec_cmd("dbus-update-activation-environment --systemd --all")
|
||||||
|
hl.exec_cmd("systemctl --user start hyprland-session.target")
|
||||||
|
end)
|
||||||
|
-- DMS_STARTUP_END
|
||||||
|
|
||||||
|
hl.config({
|
||||||
|
input = {
|
||||||
|
kb_layout = "us",
|
||||||
|
numlock_by_default = true,
|
||||||
|
},
|
||||||
|
general = {
|
||||||
|
gaps_in = 5,
|
||||||
|
gaps_out = 5,
|
||||||
|
border_size = 2,
|
||||||
|
layout = "dwindle",
|
||||||
|
},
|
||||||
|
decoration = {
|
||||||
|
rounding = 12,
|
||||||
|
active_opacity = 1.0,
|
||||||
|
inactive_opacity = 1.0,
|
||||||
|
shadow = {
|
||||||
|
enabled = true,
|
||||||
|
range = 30,
|
||||||
|
render_power = 5,
|
||||||
|
offset = "0 5",
|
||||||
|
color = "rgba(00000070)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
misc = {
|
||||||
|
disable_hyprland_logo = true,
|
||||||
|
disable_splash_rendering = true,
|
||||||
|
},
|
||||||
|
dwindle = {
|
||||||
|
preserve_split = true,
|
||||||
|
},
|
||||||
|
master = {
|
||||||
|
mfact = 0.5,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
hl.animation({ leaf = "windowsIn", enabled = true, speed = 3, bezier = "default" })
|
||||||
|
hl.animation({ leaf = "windowsOut", enabled = true, speed = 3, bezier = "default" })
|
||||||
|
hl.animation({ leaf = "workspaces", enabled = true, speed = 5, bezier = "default" })
|
||||||
|
hl.animation({ leaf = "windowsMove", enabled = true, speed = 4, bezier = "default" })
|
||||||
|
hl.animation({ leaf = "fade", enabled = true, speed = 3, bezier = "default" })
|
||||||
|
hl.animation({ leaf = "border", enabled = true, speed = 3, bezier = "default" })
|
||||||
|
|
||||||
|
hl.window_rule({ match = { class = "^(org\\.wezfurlong\\.wezterm)$" }, tile = true })
|
||||||
|
hl.window_rule({ match = { class = "^(org\\.gnome\\.)" }, rounding = 12 })
|
||||||
|
hl.window_rule({ match = { class = "^(gnome-control-center)$" }, tile = true })
|
||||||
|
hl.window_rule({ match = { class = "^(pavucontrol)$" }, tile = true })
|
||||||
|
hl.window_rule({ match = { class = "^(nm-connection-editor)$" }, tile = true })
|
||||||
|
hl.window_rule({ match = { class = "^(org\\.gnome\\.Calculator)$" }, float = true })
|
||||||
|
hl.window_rule({ match = { class = "^(gnome-calculator)$" }, float = true })
|
||||||
|
hl.window_rule({ match = { class = "^(galculator)$" }, float = true })
|
||||||
|
hl.window_rule({ match = { class = "^(blueman-manager)$" }, float = true })
|
||||||
|
hl.window_rule({ match = { class = "^(org\\.gnome\\.Nautilus)$" }, float = true })
|
||||||
|
hl.window_rule({ match = { class = "^(xdg-desktop-portal)$" }, float = true })
|
||||||
|
hl.window_rule({
|
||||||
|
match = { class = "^(steam)$", title = "^(notificationtoasts)" },
|
||||||
|
no_initial_focus = true,
|
||||||
|
pin = true,
|
||||||
|
})
|
||||||
|
hl.window_rule({
|
||||||
|
match = { class = "^(firefox)$", title = "^(Picture-in-Picture)$" },
|
||||||
|
float = true,
|
||||||
|
})
|
||||||
|
hl.window_rule({ match = { class = "^(zoom)$" }, float = true })
|
||||||
|
hl.layer_rule({ match = { namespace = "^(quickshell)$" }, no_anim = true })
|
||||||
|
hl.layer_rule({ match = { namespace = "^dms:.*" }, no_anim = true })
|
||||||
|
|
||||||
|
require("dms.colors")
|
||||||
|
require("dms.outputs")
|
||||||
|
require("dms.layout")
|
||||||
|
require("dms.cursor")
|
||||||
|
require("dms.binds")
|
||||||
|
require("dms.binds-user")
|
||||||
|
require("dms.windowrules")
|
||||||
@@ -9,6 +9,9 @@ binds {
|
|||||||
Mod+Space hotkey-overlay-title="Application Launcher" {
|
Mod+Space hotkey-overlay-title="Application Launcher" {
|
||||||
spawn "dms" "ipc" "call" "spotlight" "toggle";
|
spawn "dms" "ipc" "call" "spotlight" "toggle";
|
||||||
}
|
}
|
||||||
|
Alt+Space hotkey-overlay-title="Spotlight Bar" {
|
||||||
|
spawn "dms" "ipc" "call" "spotlight-bar" "toggle";
|
||||||
|
}
|
||||||
Mod+V hotkey-overlay-title="Clipboard Manager" {
|
Mod+V hotkey-overlay-title="Clipboard Manager" {
|
||||||
spawn "dms" "ipc" "call" "clipboard" "toggle";
|
spawn "dms" "ipc" "call" "clipboard" "toggle";
|
||||||
}
|
}
|
||||||
@@ -215,6 +218,11 @@ binds {
|
|||||||
Print { screenshot; }
|
Print { screenshot; }
|
||||||
Ctrl+Print { screenshot-screen; }
|
Ctrl+Print { screenshot-screen; }
|
||||||
Alt+Print { screenshot-window; }
|
Alt+Print { screenshot-window; }
|
||||||
|
// === Display Profiles ===
|
||||||
|
Mod+P hotkey-overlay-title="Cycle Display Profile" {
|
||||||
|
spawn "dms" "ipc" "outputs" "cycleProfile";
|
||||||
|
}
|
||||||
|
|
||||||
// === System Controls ===
|
// === System Controls ===
|
||||||
Mod+Escape allow-inhibiting=false { toggle-keyboard-shortcuts-inhibit; }
|
Mod+Escape allow-inhibiting=false { toggle-keyboard-shortcuts-inhibit; }
|
||||||
Mod+Shift+P { power-off-monitors; }
|
Mod+Shift+P { power-off-monitors; }
|
||||||
|
|||||||
@@ -250,12 +250,6 @@ window-rule {
|
|||||||
match app-id="zoom"
|
match app-id="zoom"
|
||||||
open-floating true
|
open-floating true
|
||||||
}
|
}
|
||||||
// Open dms windows as floating by default
|
|
||||||
window-rule {
|
|
||||||
match app-id=r#"org.quickshell$"#
|
|
||||||
match app-id=r#"com.danklinux.dms$"#
|
|
||||||
open-floating true
|
|
||||||
}
|
|
||||||
debug {
|
debug {
|
||||||
honor-xdg-activation-with-invalid-serial
|
honor-xdg-activation-with-invalid-serial
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,26 @@ package config
|
|||||||
|
|
||||||
import _ "embed"
|
import _ "embed"
|
||||||
|
|
||||||
//go:embed embedded/hyprland.conf
|
//go:embed embedded/hyprland.lua
|
||||||
var HyprlandConfig string
|
var HyprlandLuaConfig string
|
||||||
|
|
||||||
//go:embed embedded/hypr-colors.conf
|
//go:embed embedded/hypr-colors.lua
|
||||||
var HyprColorsConfig string
|
var DMSColorsLuaConfig string
|
||||||
|
|
||||||
//go:embed embedded/hypr-layout.conf
|
//go:embed embedded/hypr-layout.lua
|
||||||
var HyprLayoutConfig string
|
var DMSLayoutLuaConfig string
|
||||||
|
|
||||||
//go:embed embedded/hypr-binds.conf
|
//go:embed embedded/hypr-binds.lua
|
||||||
var HyprBindsConfig string
|
var DMSBindsLuaConfig string
|
||||||
|
|
||||||
|
//go:embed embedded/hypr-outputs.lua
|
||||||
|
var DMSOutputsLuaConfig string
|
||||||
|
|
||||||
|
//go:embed embedded/hypr-cursor.lua
|
||||||
|
var DMSCursorLuaConfig string
|
||||||
|
|
||||||
|
//go:embed embedded/hypr-windowrules.lua
|
||||||
|
var DMSWindowRulesLuaConfig string
|
||||||
|
|
||||||
|
//go:embed embedded/hypr-binds-user.lua
|
||||||
|
var DMSBindsUserLuaConfig string
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
hyprlandStartupBegin = "-- DMS_STARTUP_BEGIN"
|
||||||
|
hyprlandStartupEnd = "-- DMS_STARTUP_END"
|
||||||
|
)
|
||||||
|
|
||||||
|
func extractHyprlangMonitorLines(hyprlang string) []string {
|
||||||
|
re := regexp.MustCompile(`(?m)^\s*#?\s*monitor\s*=.*$`)
|
||||||
|
return re.FindAllString(hyprlang, -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hyprlangMonitorLineToLua(line string) (string, error) {
|
||||||
|
re := regexp.MustCompile(`(?i)^\s*#?\s*monitor\s*=\s*(.*)\s*$`)
|
||||||
|
m := re.FindStringSubmatch(line)
|
||||||
|
if m == nil {
|
||||||
|
return "", fmt.Errorf("not a monitor line")
|
||||||
|
}
|
||||||
|
rest := strings.TrimSpace(m[1])
|
||||||
|
parts := strings.Split(rest, ",")
|
||||||
|
for i := range parts {
|
||||||
|
parts[i] = strings.TrimSpace(parts[i])
|
||||||
|
}
|
||||||
|
if len(parts) < 4 {
|
||||||
|
if len(parts) == 2 && strings.EqualFold(parts[1], "disable") {
|
||||||
|
return fmt.Sprintf(`hl.monitor({ output = %s, disabled = true })`, strconv.Quote(parts[0])), nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("expected at least 4 comma-separated fields")
|
||||||
|
}
|
||||||
|
out := parts[0]
|
||||||
|
mode := parts[1]
|
||||||
|
pos := parts[2]
|
||||||
|
scaleStr := parts[3]
|
||||||
|
|
||||||
|
scaleField := formatMonitorScaleLua(scaleStr)
|
||||||
|
fields := []string{
|
||||||
|
fmt.Sprintf("output = %s", strconv.Quote(out)),
|
||||||
|
fmt.Sprintf("mode = %s", strconv.Quote(mode)),
|
||||||
|
fmt.Sprintf("position = %s", strconv.Quote(pos)),
|
||||||
|
scaleField,
|
||||||
|
}
|
||||||
|
for i := 4; i < len(parts); i += 2 {
|
||||||
|
key := strings.ToLower(strings.TrimSpace(parts[i]))
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if i+1 >= len(parts) {
|
||||||
|
fields = append(fields, fmt.Sprintf("%s = true", hyprlangMonitorOptionToLuaKey(key)))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val := strings.TrimSpace(parts[i+1])
|
||||||
|
if converted, ok := formatMonitorOptionLua(key, val); ok {
|
||||||
|
fields = append(fields, converted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(`hl.monitor({ %s })`, strings.Join(fields, ", ")), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatMonitorScaleLua(scaleStr string) string {
|
||||||
|
if scaleStr == "auto" {
|
||||||
|
return `scale = "auto"`
|
||||||
|
}
|
||||||
|
if f, err := strconv.ParseFloat(scaleStr, 64); err == nil {
|
||||||
|
return fmt.Sprintf(`scale = %g`, f)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(`scale = %s`, strconv.Quote(scaleStr))
|
||||||
|
}
|
||||||
|
|
||||||
|
func hyprlangMonitorOptionToLuaKey(key string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(key)) {
|
||||||
|
case "10bit":
|
||||||
|
return "bitdepth"
|
||||||
|
default:
|
||||||
|
return strings.ReplaceAll(strings.ToLower(strings.TrimSpace(key)), "-", "_")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatMonitorOptionLua(key, val string) (string, bool) {
|
||||||
|
luaKey := hyprlangMonitorOptionToLuaKey(key)
|
||||||
|
switch luaKey {
|
||||||
|
case "transform", "vrr", "bitdepth", "supports_wide_color", "supports_hdr", "sdr_max_luminance", "max_luminance", "max_avg_luminance":
|
||||||
|
if _, err := strconv.Atoi(val); err == nil {
|
||||||
|
return fmt.Sprintf("%s = %s", luaKey, val), true
|
||||||
|
}
|
||||||
|
case "sdrbrightness", "sdrsaturation", "sdr_min_luminance", "min_luminance":
|
||||||
|
if _, err := strconv.ParseFloat(val, 64); err == nil {
|
||||||
|
return fmt.Sprintf("%s = %s", luaKey, val), true
|
||||||
|
}
|
||||||
|
case "cm", "sdr_eotf", "icc", "mirror":
|
||||||
|
return fmt.Sprintf("%s = %s", luaKey, strconv.Quote(val)), true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func transformHyprlandLuaForNonSystemd(config, terminalCommand string) string {
|
||||||
|
start := strings.Index(config, hyprlandStartupBegin)
|
||||||
|
end := strings.Index(config, hyprlandStartupEnd)
|
||||||
|
if start == -1 || end == -1 || end <= start {
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
endClose := end + len(hyprlandStartupEnd)
|
||||||
|
replacement := hyprlandStartupBegin + "\n" +
|
||||||
|
`hl.env("QT_QPA_PLATFORM", "wayland;xcb")` + "\n" +
|
||||||
|
`hl.env("ELECTRON_OZONE_PLATFORM_HINT", "auto")` + "\n" +
|
||||||
|
`hl.env("QT_QPA_PLATFORMTHEME", "gtk3")` + "\n" +
|
||||||
|
`hl.env("QT_QPA_PLATFORMTHEME_QT6", "gtk3")` + "\n" +
|
||||||
|
fmt.Sprintf(`hl.env("TERMINAL", %s)`, strconv.Quote(terminalCommand)) + "\n\n" +
|
||||||
|
`hl.on("hyprland.start", function()` + "\n" +
|
||||||
|
` hl.exec_cmd("dms run")` + "\n" +
|
||||||
|
`end)` + "\n" +
|
||||||
|
hyprlandStartupEnd
|
||||||
|
return config[:start] + replacement + config[endClose:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func readExistingHyprlandConfig(configDir string) (data string, sourcePath string, err error) {
|
||||||
|
luaPath := filepath.Join(configDir, "hyprland.lua")
|
||||||
|
if b, e := os.ReadFile(luaPath); e == nil {
|
||||||
|
return string(b), luaPath, nil
|
||||||
|
} else if !os.IsNotExist(e) {
|
||||||
|
return "", "", e
|
||||||
|
}
|
||||||
|
confPath := filepath.Join(configDir, "hyprland.conf")
|
||||||
|
if b, e := os.ReadFile(confPath); e == nil {
|
||||||
|
return string(b), confPath, nil
|
||||||
|
} else if !os.IsNotExist(e) {
|
||||||
|
return "", "", e
|
||||||
|
}
|
||||||
|
return "", "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanupStrayHyprlandConfFile moves a stray ~/.config/hypr/hyprland.conf
|
||||||
|
// into .dms-backups/<timestamp>/ only when hyprland.lua also exists, which
|
||||||
|
// proves Lua is the live config and the .conf is an autogen Hyprland 0.55
|
||||||
|
// produced when launched without -c. If only hyprland.conf exists, the user
|
||||||
|
// has not migrated and we must leave their config alone.
|
||||||
|
func CleanupStrayHyprlandConfFile(logFn func(format string, v ...any)) {
|
||||||
|
if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
home := os.Getenv("HOME")
|
||||||
|
if home == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
configDir := filepath.Join(home, ".config", "hypr")
|
||||||
|
luaPath := filepath.Join(configDir, "hyprland.lua")
|
||||||
|
if _, err := os.Stat(luaPath); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
confPath := filepath.Join(configDir, "hyprland.conf")
|
||||||
|
if _, err := os.Stat(confPath); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ts := time.Now().Format("2006-01-02_15-04-05")
|
||||||
|
dst := filepath.Join(configDir, hyprlandBackupDirName, ts, "hyprland.conf")
|
||||||
|
if err := moveHyprlandConfigFile(confPath, dst); err != nil {
|
||||||
|
if logFn != nil {
|
||||||
|
logFn("Could not move stray hyprland.conf: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if logFn != nil {
|
||||||
|
logFn("Moved stray hyprland.conf to %s", dst)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package desktop
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Entry struct {
|
||||||
|
ID string
|
||||||
|
Path string
|
||||||
|
Name string
|
||||||
|
Exec string
|
||||||
|
Icon string
|
||||||
|
Categories []string
|
||||||
|
MimeTypes []string
|
||||||
|
NoDisplay bool
|
||||||
|
Hidden bool
|
||||||
|
Terminal bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type cachedEntry struct {
|
||||||
|
entry *Entry
|
||||||
|
modTime time.Time
|
||||||
|
size int64
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
entryCache = make(map[string]cachedEntry)
|
||||||
|
entryCacheMu sync.Mutex
|
||||||
|
|
||||||
|
listingCache []*Entry
|
||||||
|
listingExpires time.Time
|
||||||
|
listingCacheMu sync.Mutex
|
||||||
|
)
|
||||||
|
|
||||||
|
const listingTTL = 5 * time.Second
|
||||||
|
|
||||||
|
func applicationDirs() []string {
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
var dirs []string
|
||||||
|
|
||||||
|
add := func(path string) {
|
||||||
|
if path == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
abs, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
abs = path
|
||||||
|
}
|
||||||
|
if seen[abs] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[abs] = true
|
||||||
|
dirs = append(dirs, abs)
|
||||||
|
}
|
||||||
|
|
||||||
|
add(filepath.Join(utils.XDGDataHome(), "applications"))
|
||||||
|
|
||||||
|
if env := os.Getenv("XDG_DATA_DIRS"); env != "" {
|
||||||
|
for d := range strings.SplitSeq(env, ":") {
|
||||||
|
add(filepath.Join(strings.TrimSpace(d), "applications"))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
add("/usr/local/share/applications")
|
||||||
|
add("/usr/share/applications")
|
||||||
|
}
|
||||||
|
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
if home != "" {
|
||||||
|
add(filepath.Join(home, ".local", "share", "flatpak", "exports", "share", "applications"))
|
||||||
|
}
|
||||||
|
add("/var/lib/flatpak/exports/share/applications")
|
||||||
|
add("/var/lib/snapd/desktop/applications")
|
||||||
|
|
||||||
|
return dirs
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseEntry(path string, id string) (*Entry, error) {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
entryCacheMu.Lock()
|
||||||
|
if c, ok := entryCache[path]; ok && c.modTime.Equal(info.ModTime()) && c.size == info.Size() {
|
||||||
|
entryCacheMu.Unlock()
|
||||||
|
return c.entry, nil
|
||||||
|
}
|
||||||
|
entryCacheMu.Unlock()
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
groups := parseGroups(data)
|
||||||
|
g, ok := groups["Desktop Entry"]
|
||||||
|
if !ok {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := &Entry{
|
||||||
|
ID: id,
|
||||||
|
Path: path,
|
||||||
|
Name: g.keys["Name"],
|
||||||
|
Exec: g.keys["Exec"],
|
||||||
|
Icon: g.keys["Icon"],
|
||||||
|
Categories: splitList(g.keys["Categories"]),
|
||||||
|
MimeTypes: splitList(g.keys["MimeType"]),
|
||||||
|
NoDisplay: parseBool(g.keys["NoDisplay"]),
|
||||||
|
Hidden: parseBool(g.keys["Hidden"]),
|
||||||
|
Terminal: parseBool(g.keys["Terminal"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
if t := g.keys["Type"]; t != "" && t != "Application" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
entryCacheMu.Lock()
|
||||||
|
entryCache[path] = cachedEntry{entry: entry, modTime: info.ModTime(), size: info.Size()}
|
||||||
|
entryCacheMu.Unlock()
|
||||||
|
|
||||||
|
return entry, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func relativeID(root, path string) string {
|
||||||
|
rel, err := filepath.Rel(root, path)
|
||||||
|
if err != nil {
|
||||||
|
return filepath.Base(path)
|
||||||
|
}
|
||||||
|
return strings.ReplaceAll(rel, string(filepath.Separator), "-")
|
||||||
|
}
|
||||||
|
|
||||||
|
func AllEntries() []*Entry {
|
||||||
|
listingCacheMu.Lock()
|
||||||
|
if time.Now().Before(listingExpires) && listingCache != nil {
|
||||||
|
out := listingCache
|
||||||
|
listingCacheMu.Unlock()
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
listingCacheMu.Unlock()
|
||||||
|
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
var entries []*Entry
|
||||||
|
|
||||||
|
for _, dir := range applicationDirs() {
|
||||||
|
_ = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil || d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(path, ".desktop") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
id := relativeID(dir, path)
|
||||||
|
if seen[id] {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seen[id] = true
|
||||||
|
|
||||||
|
entry, err := parseEntry(path, id)
|
||||||
|
if err != nil || entry == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
entries = append(entries, entry)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
listingCacheMu.Lock()
|
||||||
|
listingCache = entries
|
||||||
|
listingExpires = time.Now().Add(listingTTL)
|
||||||
|
listingCacheMu.Unlock()
|
||||||
|
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
func EntryByID(id string) *Entry {
|
||||||
|
if !strings.HasSuffix(id, ".desktop") {
|
||||||
|
id += ".desktop"
|
||||||
|
}
|
||||||
|
for _, entry := range AllEntries() {
|
||||||
|
if entry.ID == id {
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func InvalidateCache() {
|
||||||
|
entryCacheMu.Lock()
|
||||||
|
entryCache = make(map[string]cachedEntry)
|
||||||
|
entryCacheMu.Unlock()
|
||||||
|
|
||||||
|
listingCacheMu.Lock()
|
||||||
|
listingCache = nil
|
||||||
|
listingExpires = time.Time{}
|
||||||
|
listingCacheMu.Unlock()
|
||||||
|
}
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
package desktop
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
aliasMap map[string]string
|
||||||
|
subclassMap map[string][]string
|
||||||
|
aliasLoaded time.Time
|
||||||
|
aliasReloadMu sync.Mutex
|
||||||
|
|
||||||
|
mimeCacheMap map[string][]string
|
||||||
|
mimeCacheLoaded time.Time
|
||||||
|
mimeCacheReloadMu sync.Mutex
|
||||||
|
)
|
||||||
|
|
||||||
|
const aliasTTL = 60 * time.Second
|
||||||
|
const mimeCacheTTL = 10 * time.Second
|
||||||
|
|
||||||
|
func mimeDataDirs() []string {
|
||||||
|
var dirs []string
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
|
||||||
|
add := func(p string) {
|
||||||
|
if p == "" || seen[p] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[p] = true
|
||||||
|
dirs = append(dirs, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
add(filepath.Join(utils.XDGDataHome(), "mime"))
|
||||||
|
|
||||||
|
if env := os.Getenv("XDG_DATA_DIRS"); env != "" {
|
||||||
|
for d := range strings.SplitSeq(env, ":") {
|
||||||
|
add(filepath.Join(strings.TrimSpace(d), "mime"))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
add("/usr/local/share/mime")
|
||||||
|
add("/usr/share/mime")
|
||||||
|
}
|
||||||
|
|
||||||
|
return dirs
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadAliasTables() {
|
||||||
|
aliases := make(map[string]string)
|
||||||
|
subclasses := make(map[string][]string)
|
||||||
|
|
||||||
|
for _, dir := range mimeDataDirs() {
|
||||||
|
readKV(filepath.Join(dir, "aliases"), func(k, v string) {
|
||||||
|
if _, ok := aliases[k]; !ok {
|
||||||
|
aliases[k] = v
|
||||||
|
}
|
||||||
|
})
|
||||||
|
readKV(filepath.Join(dir, "subclasses"), func(k, v string) {
|
||||||
|
subclasses[k] = append(subclasses[k], v)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
aliasMap = aliases
|
||||||
|
subclassMap = subclasses
|
||||||
|
aliasLoaded = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
func readKV(path string, fn func(k, v string)) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scanner := bufio.NewScanner(bytes.NewReader(data))
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" || line[0] == '#' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sp := strings.IndexByte(line, ' ')
|
||||||
|
if sp <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fn(strings.TrimSpace(line[:sp]), strings.TrimSpace(line[sp+1:]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureAliasTables() {
|
||||||
|
aliasReloadMu.Lock()
|
||||||
|
defer aliasReloadMu.Unlock()
|
||||||
|
|
||||||
|
if aliasMap == nil || time.Since(aliasLoaded) > aliasTTL {
|
||||||
|
loadAliasTables()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadMimeCache() {
|
||||||
|
merged := make(map[string][]string)
|
||||||
|
seen := make(map[string]map[string]bool)
|
||||||
|
|
||||||
|
for _, dir := range applicationDirs() {
|
||||||
|
data, err := os.ReadFile(filepath.Join(dir, "mimeinfo.cache"))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
groups := parseGroups(data)
|
||||||
|
g := groups["MIME Cache"]
|
||||||
|
if g == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for mime, val := range g.keys {
|
||||||
|
ids := splitList(val)
|
||||||
|
if len(ids) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if seen[mime] == nil {
|
||||||
|
seen[mime] = make(map[string]bool)
|
||||||
|
}
|
||||||
|
for _, id := range ids {
|
||||||
|
if seen[mime][id] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[mime][id] = true
|
||||||
|
merged[mime] = append(merged[mime], id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mimeCacheMap = merged
|
||||||
|
mimeCacheLoaded = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureMimeCache() {
|
||||||
|
mimeCacheReloadMu.Lock()
|
||||||
|
defer mimeCacheReloadMu.Unlock()
|
||||||
|
|
||||||
|
if mimeCacheMap == nil || time.Since(mimeCacheLoaded) > mimeCacheTTL {
|
||||||
|
loadMimeCache()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cacheAppsForMime(mimeType string) []string {
|
||||||
|
ensureMimeCache()
|
||||||
|
return mimeCacheMap[mimeType]
|
||||||
|
}
|
||||||
|
|
||||||
|
func StripMimeParams(mimeType string) string {
|
||||||
|
if semi := strings.IndexByte(mimeType, ';'); semi >= 0 {
|
||||||
|
mimeType = mimeType[:semi]
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(mimeType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalMime(mimeType string) string {
|
||||||
|
ensureAliasTables()
|
||||||
|
mimeType = StripMimeParams(mimeType)
|
||||||
|
if target, ok := aliasMap[mimeType]; ok {
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
return mimeType
|
||||||
|
}
|
||||||
|
|
||||||
|
func mimeChain(mimeType string) []string {
|
||||||
|
ensureAliasTables()
|
||||||
|
|
||||||
|
root := canonicalMime(mimeType)
|
||||||
|
visited := map[string]bool{root: true}
|
||||||
|
chain := []string{root}
|
||||||
|
|
||||||
|
queue := []string{root}
|
||||||
|
for len(queue) > 0 {
|
||||||
|
cur := queue[0]
|
||||||
|
queue = queue[1:]
|
||||||
|
for _, parent := range subclassMap[cur] {
|
||||||
|
if visited[parent] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
visited[parent] = true
|
||||||
|
chain = append(chain, parent)
|
||||||
|
queue = append(queue, parent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return chain
|
||||||
|
}
|
||||||
|
|
||||||
|
func entrySupportsMime(entry *Entry, chain []string) bool {
|
||||||
|
for _, m := range entry.MimeTypes {
|
||||||
|
canonical := canonicalMime(m)
|
||||||
|
if slices.Contains(chain, canonical) || slices.Contains(chain, m) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetDefault(mimeType string) string {
|
||||||
|
merged := mergedAssociations()
|
||||||
|
chain := mimeChain(mimeType)
|
||||||
|
|
||||||
|
for _, m := range chain {
|
||||||
|
if id, ok := merged.Defaults[m]; ok {
|
||||||
|
if !slices.Contains(merged.Removed[m], id) {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range chain {
|
||||||
|
for _, id := range merged.Added[m] {
|
||||||
|
if !slices.Contains(merged.Removed[m], id) {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range chain {
|
||||||
|
for _, id := range cacheAppsForMime(m) {
|
||||||
|
if !slices.Contains(merged.Removed[m], id) {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range chain {
|
||||||
|
for _, entry := range AllEntries() {
|
||||||
|
if entry.Hidden || entry.NoDisplay {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if slices.Contains(merged.Removed[m], entry.ID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if entrySupportsMime(entry, []string{m}) {
|
||||||
|
return entry.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetDefault(mimeType, desktopID string) error {
|
||||||
|
return setDefaultAssociation(mimeType, desktopID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetDefaults(mimeTypes []string, desktopID string) error {
|
||||||
|
return setDefaultAssociations(mimeTypes, desktopID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func AppsForMime(mimeType string) []string {
|
||||||
|
merged := mergedAssociations()
|
||||||
|
chain := mimeChain(mimeType)
|
||||||
|
removed := make(map[string]bool)
|
||||||
|
for _, m := range chain {
|
||||||
|
for _, id := range merged.Removed[m] {
|
||||||
|
removed[id] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
var out []string
|
||||||
|
|
||||||
|
add := func(id string) {
|
||||||
|
if id == "" || removed[id] || seen[id] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[id] = true
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range chain {
|
||||||
|
if id := merged.Defaults[m]; id != "" {
|
||||||
|
add(id)
|
||||||
|
}
|
||||||
|
for _, id := range merged.Added[m] {
|
||||||
|
add(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range chain {
|
||||||
|
for _, id := range cacheAppsForMime(m) {
|
||||||
|
add(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range AllEntries() {
|
||||||
|
if entry.Hidden {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if entrySupportsMime(entry, chain) {
|
||||||
|
add(entry.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func QueryDefaults(mimeTypes []string) map[string]string {
|
||||||
|
out := make(map[string]string, len(mimeTypes))
|
||||||
|
for _, m := range mimeTypes {
|
||||||
|
out[m] = GetDefault(m)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
package desktop
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupFakeXDG(t *testing.T) (configHome, dataHome string) {
|
||||||
|
t.Helper()
|
||||||
|
tmp := t.TempDir()
|
||||||
|
configHome = filepath.Join(tmp, "config")
|
||||||
|
dataHome = filepath.Join(tmp, "data")
|
||||||
|
if err := os.MkdirAll(filepath.Join(dataHome, "applications"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(configHome, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", configHome)
|
||||||
|
t.Setenv("XDG_DATA_HOME", dataHome)
|
||||||
|
t.Setenv("XDG_DATA_DIRS", dataHome)
|
||||||
|
t.Setenv("XDG_CONFIG_DIRS", configHome)
|
||||||
|
InvalidateCache()
|
||||||
|
|
||||||
|
mimeCacheReloadMu.Lock()
|
||||||
|
mimeCacheMap = nil
|
||||||
|
mimeCacheReloadMu.Unlock()
|
||||||
|
aliasReloadMu.Lock()
|
||||||
|
aliasMap = nil
|
||||||
|
subclassMap = nil
|
||||||
|
aliasReloadMu.Unlock()
|
||||||
|
|
||||||
|
return configHome, dataHome
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFile(t *testing.T, path, content string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseDesktopEntry(t *testing.T) {
|
||||||
|
_, dataHome := setupFakeXDG(t)
|
||||||
|
writeFile(t, filepath.Join(dataHome, "applications", "test.desktop"), `[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=Test
|
||||||
|
Exec=test %f
|
||||||
|
Icon=test
|
||||||
|
MimeType=application/pdf;image/png;
|
||||||
|
Categories=Office;Viewer;
|
||||||
|
NoDisplay=false
|
||||||
|
`)
|
||||||
|
|
||||||
|
entry := EntryByID("test.desktop")
|
||||||
|
if entry == nil {
|
||||||
|
t.Fatal("entry not found")
|
||||||
|
}
|
||||||
|
if entry.Name != "Test" {
|
||||||
|
t.Errorf("Name = %q", entry.Name)
|
||||||
|
}
|
||||||
|
if len(entry.MimeTypes) != 2 || entry.MimeTypes[0] != "application/pdf" {
|
||||||
|
t.Errorf("MimeTypes = %v", entry.MimeTypes)
|
||||||
|
}
|
||||||
|
if len(entry.Categories) != 2 || entry.Categories[1] != "Viewer" {
|
||||||
|
t.Errorf("Categories = %v", entry.Categories)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetGetDefault(t *testing.T) {
|
||||||
|
configHome, dataHome := setupFakeXDG(t)
|
||||||
|
writeFile(t, filepath.Join(dataHome, "applications", "foo.desktop"), `[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=Foo
|
||||||
|
MimeType=application/pdf;
|
||||||
|
`)
|
||||||
|
writeFile(t, filepath.Join(dataHome, "applications", "bar.desktop"), `[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=Bar
|
||||||
|
MimeType=application/pdf;
|
||||||
|
`)
|
||||||
|
|
||||||
|
if err := SetDefault("application/pdf", "bar.desktop"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := GetDefault("application/pdf"); got != "bar.desktop" {
|
||||||
|
t.Errorf("GetDefault = %q want bar.desktop", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(filepath.Join(configHome, "mimeapps.list"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !contains(string(data), "application/pdf=bar.desktop") {
|
||||||
|
t.Errorf("mimeapps.list missing default:\n%s", string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetDefaultBypassesMimeSupportCheck(t *testing.T) {
|
||||||
|
configHome, dataHome := setupFakeXDG(t)
|
||||||
|
writeFile(t, filepath.Join(dataHome, "applications", "dms-open.desktop"), `[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=DMS
|
||||||
|
MimeType=x-scheme-handler/http;
|
||||||
|
`)
|
||||||
|
|
||||||
|
if err := SetDefault("application/pdf", "dms-open.desktop"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := GetDefault("application/pdf"); got != "dms-open.desktop" {
|
||||||
|
t.Errorf("GetDefault = %q, want dms-open.desktop (native impl must not enforce MimeType= check)", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := os.ReadFile(filepath.Join(configHome, "mimeapps.list"))
|
||||||
|
if !contains(string(data), "application/pdf=dms-open.desktop") {
|
||||||
|
t.Errorf("mimeapps.list missing override:\n%s", string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAliasResolution(t *testing.T) {
|
||||||
|
_, dataHome := setupFakeXDG(t)
|
||||||
|
writeFile(t, filepath.Join(dataHome, "mime", "aliases"), "text/javascript application/javascript\n")
|
||||||
|
writeFile(t, filepath.Join(dataHome, "applications", "editor.desktop"), `[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=Editor
|
||||||
|
MimeType=application/javascript;
|
||||||
|
`)
|
||||||
|
writeFile(t, filepath.Join(dataHome, "applications", "mimeinfo.cache"), `[MIME Cache]
|
||||||
|
application/javascript=editor.desktop;
|
||||||
|
`)
|
||||||
|
|
||||||
|
if got := GetDefault("text/javascript"); got != "editor.desktop" {
|
||||||
|
t.Errorf("GetDefault(text/javascript) = %q want editor.desktop (alias resolution)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetDefaultsBatch(t *testing.T) {
|
||||||
|
configHome, dataHome := setupFakeXDG(t)
|
||||||
|
writeFile(t, filepath.Join(dataHome, "applications", "dms-open.desktop"), `[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=DMS
|
||||||
|
MimeType=x-scheme-handler/http;
|
||||||
|
`)
|
||||||
|
|
||||||
|
mimes := []string{
|
||||||
|
"text/plain", "text/x-csrc", "text/x-python",
|
||||||
|
"text/x-shellscript", "application/json",
|
||||||
|
}
|
||||||
|
if err := SetDefaults(mimes, "dms-open.desktop"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(filepath.Join(configHome, "mimeapps.list"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, m := range mimes {
|
||||||
|
if !contains(string(data), m+"=dms-open.desktop") {
|
||||||
|
t.Errorf("missing %s default in mimeapps.list:\n%s", m, string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConcurrentSetDefaultNoCorruption(t *testing.T) {
|
||||||
|
configHome, dataHome := setupFakeXDG(t)
|
||||||
|
writeFile(t, filepath.Join(dataHome, "applications", "app.desktop"), `[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=App
|
||||||
|
`)
|
||||||
|
|
||||||
|
mimes := []string{
|
||||||
|
"a/1", "a/2", "a/3", "a/4", "a/5", "a/6", "a/7",
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for _, m := range mimes {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(m string) {
|
||||||
|
defer wg.Done()
|
||||||
|
if err := SetDefault(m, "app.desktop"); err != nil {
|
||||||
|
t.Errorf("SetDefault(%s) failed: %v", m, err)
|
||||||
|
}
|
||||||
|
}(m)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
data, err := os.ReadFile(filepath.Join(configHome, "mimeapps.list"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, m := range mimes {
|
||||||
|
if !contains(string(data), m+"=app.desktop") {
|
||||||
|
t.Errorf("lost write for %s — concurrent writes corrupted file:\n%s", m, string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMimeCacheOrdering(t *testing.T) {
|
||||||
|
_, dataHome := setupFakeXDG(t)
|
||||||
|
writeFile(t, filepath.Join(dataHome, "applications", "a.desktop"), `[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=A
|
||||||
|
MimeType=image/png;
|
||||||
|
`)
|
||||||
|
writeFile(t, filepath.Join(dataHome, "applications", "b.desktop"), `[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=B
|
||||||
|
MimeType=image/png;
|
||||||
|
`)
|
||||||
|
writeFile(t, filepath.Join(dataHome, "applications", "mimeinfo.cache"), `[MIME Cache]
|
||||||
|
image/png=b.desktop;a.desktop;
|
||||||
|
`)
|
||||||
|
|
||||||
|
if got := GetDefault("image/png"); got != "b.desktop" {
|
||||||
|
t.Errorf("GetDefault should follow mimeinfo.cache order: got %q want b.desktop", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(haystack, needle string) bool {
|
||||||
|
for i := 0; i+len(needle) <= len(haystack); i++ {
|
||||||
|
if haystack[i:i+len(needle)] == needle {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
package desktop
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
var mimeappsWriteMu sync.Mutex
|
||||||
|
|
||||||
|
const (
|
||||||
|
groupDefaults = "Default Applications"
|
||||||
|
groupAdded = "Added Associations"
|
||||||
|
groupRemoved = "Removed Associations"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MimeAssociations struct {
|
||||||
|
Defaults map[string]string
|
||||||
|
Added map[string][]string
|
||||||
|
Removed map[string][]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAssociations() *MimeAssociations {
|
||||||
|
return &MimeAssociations{
|
||||||
|
Defaults: make(map[string]string),
|
||||||
|
Added: make(map[string][]string),
|
||||||
|
Removed: make(map[string][]string),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mimeappsSearchPaths() []string {
|
||||||
|
var paths []string
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
|
||||||
|
add := func(p string) {
|
||||||
|
if p == "" || seen[p] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[p] = true
|
||||||
|
paths = append(paths, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
add(filepath.Join(utils.XDGConfigHome(), "mimeapps.list"))
|
||||||
|
|
||||||
|
if env := os.Getenv("XDG_CONFIG_DIRS"); env != "" {
|
||||||
|
for d := range strings.SplitSeq(env, ":") {
|
||||||
|
add(filepath.Join(strings.TrimSpace(d), "mimeapps.list"))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
add("/etc/xdg/mimeapps.list")
|
||||||
|
}
|
||||||
|
|
||||||
|
add(filepath.Join(utils.XDGDataHome(), "applications", "mimeapps.list"))
|
||||||
|
|
||||||
|
if env := os.Getenv("XDG_DATA_DIRS"); env != "" {
|
||||||
|
for d := range strings.SplitSeq(env, ":") {
|
||||||
|
add(filepath.Join(strings.TrimSpace(d), "applications", "mimeapps.list"))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
add("/usr/local/share/applications/mimeapps.list")
|
||||||
|
add("/usr/share/applications/mimeapps.list")
|
||||||
|
}
|
||||||
|
|
||||||
|
return paths
|
||||||
|
}
|
||||||
|
|
||||||
|
func mimeappsWritePath() string {
|
||||||
|
return filepath.Join(utils.XDGConfigHome(), "mimeapps.list")
|
||||||
|
}
|
||||||
|
|
||||||
|
func readAssociations(path string) (*MimeAssociations, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
groups := parseGroups(data)
|
||||||
|
assoc := newAssociations()
|
||||||
|
|
||||||
|
if g := groups[groupDefaults]; g != nil {
|
||||||
|
for mime, val := range g.keys {
|
||||||
|
parts := splitList(val)
|
||||||
|
if len(parts) > 0 {
|
||||||
|
assoc.Defaults[mime] = parts[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if g := groups[groupAdded]; g != nil {
|
||||||
|
for mime, val := range g.keys {
|
||||||
|
assoc.Added[mime] = splitList(val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if g := groups[groupRemoved]; g != nil {
|
||||||
|
for mime, val := range g.keys {
|
||||||
|
assoc.Removed[mime] = splitList(val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return assoc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergedAssociations() *MimeAssociations {
|
||||||
|
merged := newAssociations()
|
||||||
|
|
||||||
|
for _, path := range mimeappsSearchPaths() {
|
||||||
|
assoc, err := readAssociations(path)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for mime, app := range assoc.Defaults {
|
||||||
|
if _, ok := merged.Defaults[mime]; !ok {
|
||||||
|
merged.Defaults[mime] = app
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for mime, apps := range assoc.Added {
|
||||||
|
merged.Added[mime] = append(merged.Added[mime], apps...)
|
||||||
|
}
|
||||||
|
for mime, apps := range assoc.Removed {
|
||||||
|
merged.Removed[mime] = append(merged.Removed[mime], apps...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeUserMimeapps(update func(*MimeAssociations)) error {
|
||||||
|
mimeappsWriteMu.Lock()
|
||||||
|
defer mimeappsWriteMu.Unlock()
|
||||||
|
|
||||||
|
path := mimeappsWritePath()
|
||||||
|
|
||||||
|
assoc, err := readAssociations(path)
|
||||||
|
if err != nil {
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
assoc = newAssociations()
|
||||||
|
}
|
||||||
|
|
||||||
|
update(assoc)
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
w := bufio.NewWriter(&buf)
|
||||||
|
|
||||||
|
writeSection := func(name string, entries map[string]string) {
|
||||||
|
fmt.Fprintf(w, "[%s]\n", name)
|
||||||
|
keys := make([]string, 0, len(entries))
|
||||||
|
for k := range entries {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
for _, k := range keys {
|
||||||
|
fmt.Fprintf(w, "%s=%s\n", k, entries[k])
|
||||||
|
}
|
||||||
|
fmt.Fprintln(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
flatten := func(m map[string][]string) map[string]string {
|
||||||
|
out := make(map[string]string, len(m))
|
||||||
|
for k, list := range m {
|
||||||
|
out[k] = strings.Join(list, ";") + ";"
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
writeSection(groupDefaults, assoc.Defaults)
|
||||||
|
writeSection(groupAdded, flatten(assoc.Added))
|
||||||
|
writeSection(groupRemoved, flatten(assoc.Removed))
|
||||||
|
|
||||||
|
if err := w.Flush(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, buf.Bytes(), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setDefaultAssociation(mimeType, desktopID string) error {
|
||||||
|
return setDefaultAssociations([]string{mimeType}, desktopID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setDefaultAssociations(mimeTypes []string, desktopID string) error {
|
||||||
|
if !strings.HasSuffix(desktopID, ".desktop") {
|
||||||
|
desktopID += ".desktop"
|
||||||
|
}
|
||||||
|
return writeUserMimeapps(func(assoc *MimeAssociations) {
|
||||||
|
for _, mimeType := range mimeTypes {
|
||||||
|
if mimeType == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
assoc.Defaults[mimeType] = desktopID
|
||||||
|
existing := assoc.Added[mimeType]
|
||||||
|
if !slices.Contains(existing, desktopID) {
|
||||||
|
assoc.Added[mimeType] = append(existing, desktopID)
|
||||||
|
}
|
||||||
|
removed, ok := assoc.Removed[mimeType]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered := removed[:0]
|
||||||
|
for _, id := range removed {
|
||||||
|
if id != desktopID {
|
||||||
|
filtered = append(filtered, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case len(filtered) == 0:
|
||||||
|
delete(assoc.Removed, mimeType)
|
||||||
|
default:
|
||||||
|
assoc.Removed[mimeType] = filtered
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package desktop
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type group struct {
|
||||||
|
keys map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseGroups(data []byte) map[string]*group {
|
||||||
|
groups := make(map[string]*group)
|
||||||
|
var current *group
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(bytes.NewReader(data))
|
||||||
|
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" || line[0] == '#' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if line[0] == '[' && strings.HasSuffix(line, "]") {
|
||||||
|
name := line[1 : len(line)-1]
|
||||||
|
g, ok := groups[name]
|
||||||
|
if !ok {
|
||||||
|
g = &group{keys: make(map[string]string)}
|
||||||
|
groups[name] = g
|
||||||
|
}
|
||||||
|
current = g
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if current == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
eq := strings.IndexByte(line, '=')
|
||||||
|
if eq <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
key := strings.TrimSpace(line[:eq])
|
||||||
|
if bracket := strings.IndexByte(key, '['); bracket > 0 {
|
||||||
|
key = key[:bracket]
|
||||||
|
}
|
||||||
|
if _, ok := current.keys[key]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
current.keys[key] = strings.TrimSpace(line[eq+1:])
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitList(value string) []string {
|
||||||
|
if value == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(value, ";")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseBool(value string) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||||
|
case "true", "1", "yes":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -207,8 +208,7 @@ func (a *ArchDistribution) getQuickshellMapping(variant deps.PackageVariant) Pac
|
|||||||
if forceQuickshellGit || variant == deps.VariantGit {
|
if forceQuickshellGit || variant == deps.VariantGit {
|
||||||
return PackageMapping{Name: "quickshell-git", Repository: RepoTypeAUR}
|
return PackageMapping{Name: "quickshell-git", Repository: RepoTypeAUR}
|
||||||
}
|
}
|
||||||
// ! TODO - for now we're only forcing quickshell-git on ARCH, as other distros use DL repos which pin a newer quickshell
|
return PackageMapping{Name: "quickshell", Repository: RepoTypeSystem}
|
||||||
return PackageMapping{Name: "quickshell-git", Repository: RepoTypeAUR}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *ArchDistribution) getHyprlandMapping(_ deps.PackageVariant) PackageMapping {
|
func (a *ArchDistribution) getHyprlandMapping(_ deps.PackageVariant) PackageMapping {
|
||||||
@@ -292,7 +292,7 @@ func (a *ArchDistribution) InstallPrerequisites(ctx context.Context, sudoPasswor
|
|||||||
LogOutput: "Installing base-devel development tools",
|
LogOutput: "Installing base-devel development tools",
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := ExecSudoCommand(ctx, sudoPassword, "pacman -S --needed --noconfirm base-devel")
|
cmd := privesc.ExecCommand(ctx, sudoPassword, "pacman -S --needed --noconfirm base-devel")
|
||||||
if err := a.runWithProgress(cmd, progressChan, PhasePrerequisites, 0.08, 0.10); err != nil {
|
if err := a.runWithProgress(cmd, progressChan, PhasePrerequisites, 0.08, 0.10); err != nil {
|
||||||
return fmt.Errorf("failed to install base-devel: %w", err)
|
return fmt.Errorf("failed to install base-devel: %w", err)
|
||||||
}
|
}
|
||||||
@@ -331,6 +331,12 @@ func (a *ArchDistribution) InstallPackages(ctx context.Context, dependencies []d
|
|||||||
aurPkgs = slices.DeleteFunc(aurPkgs, func(p string) bool { return p == "quickshell-git" })
|
aurPkgs = slices.DeleteFunc(aurPkgs, func(p string) bool { return p == "quickshell-git" })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if slices.Contains(systemPkgs, "quickshell") && a.packageInstalled("quickshell-git") {
|
||||||
|
if err := a.removeQuickshellGit(ctx, sudoPassword, progressChan); err != nil {
|
||||||
|
return fmt.Errorf("failed to remove quickshell-git: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Phase 3: System Packages
|
// Phase 3: System Packages
|
||||||
if len(systemPkgs) > 0 {
|
if len(systemPkgs) > 0 {
|
||||||
progressChan <- InstallProgressMsg{
|
progressChan <- InstallProgressMsg{
|
||||||
@@ -448,6 +454,20 @@ func (a *ArchDistribution) categorizePackages(dependencies []deps.Dependency, wm
|
|||||||
return systemPkgs, aurPkgs, manualPkgs, variantMap
|
return systemPkgs, aurPkgs, manualPkgs, variantMap
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *ArchDistribution) removeQuickshellGit(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||||
|
progressChan <- InstallProgressMsg{
|
||||||
|
Phase: PhaseSystemPackages,
|
||||||
|
Progress: 0.33,
|
||||||
|
Step: "Removing quickshell-git...",
|
||||||
|
IsComplete: false,
|
||||||
|
NeedsSudo: true,
|
||||||
|
CommandInfo: "sudo pacman -Rdd --noconfirm quickshell-git",
|
||||||
|
LogOutput: "Removing quickshell-git so stable quickshell can be installed",
|
||||||
|
}
|
||||||
|
cmd := privesc.ExecCommand(ctx, sudoPassword, "pacman -Rdd --noconfirm quickshell-git")
|
||||||
|
return a.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.33, 0.35)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *ArchDistribution) preinstallQuickshellGit(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
func (a *ArchDistribution) preinstallQuickshellGit(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||||
if a.packageInstalled("quickshell-git") {
|
if a.packageInstalled("quickshell-git") {
|
||||||
return nil
|
return nil
|
||||||
@@ -463,7 +483,7 @@ func (a *ArchDistribution) preinstallQuickshellGit(ctx context.Context, sudoPass
|
|||||||
CommandInfo: "sudo pacman -Rdd --noconfirm quickshell",
|
CommandInfo: "sudo pacman -Rdd --noconfirm quickshell",
|
||||||
LogOutput: "Removing stable quickshell so quickshell-git can be installed",
|
LogOutput: "Removing stable quickshell so quickshell-git can be installed",
|
||||||
}
|
}
|
||||||
cmd := ExecSudoCommand(ctx, sudoPassword, "pacman -Rdd --noconfirm quickshell")
|
cmd := privesc.ExecCommand(ctx, sudoPassword, "pacman -Rdd --noconfirm quickshell")
|
||||||
if err := a.runWithProgress(cmd, progressChan, PhaseAURPackages, 0.15, 0.18); err != nil {
|
if err := a.runWithProgress(cmd, progressChan, PhaseAURPackages, 0.15, 0.18); err != nil {
|
||||||
return fmt.Errorf("failed to remove stable quickshell: %w", err)
|
return fmt.Errorf("failed to remove stable quickshell: %w", err)
|
||||||
}
|
}
|
||||||
@@ -501,7 +521,7 @@ func (a *ArchDistribution) installSystemPackages(ctx context.Context, packages [
|
|||||||
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
|
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
|
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
|
||||||
return a.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.40, 0.60)
|
return a.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.40, 0.60)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -779,7 +799,7 @@ func (a *ArchDistribution) installSingleAURPackageInternal(ctx context.Context,
|
|||||||
installArgs := []string{"pacman", "-U", "--noconfirm"}
|
installArgs := []string{"pacman", "-U", "--noconfirm"}
|
||||||
installArgs = append(installArgs, files...)
|
installArgs = append(installArgs, files...)
|
||||||
|
|
||||||
installCmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(installArgs, " "))
|
installCmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(installArgs, " "))
|
||||||
|
|
||||||
fileNames := make([]string, len(files))
|
fileNames := make([]string, len(files))
|
||||||
for i, f := range files {
|
for i, f := range files {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/version"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -55,27 +56,6 @@ func (b *BaseDistribution) logError(message string, err error) {
|
|||||||
b.log(errorMsg)
|
b.log(errorMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// escapeSingleQuotes escapes single quotes in a string for safe use in bash single-quoted strings.
|
|
||||||
// It replaces each ' with '\” which closes the quote, adds an escaped quote, and reopens the quote.
|
|
||||||
// This prevents shell injection and syntax errors when passwords contain single quotes or apostrophes.
|
|
||||||
func escapeSingleQuotes(s string) string {
|
|
||||||
return strings.ReplaceAll(s, "'", "'\\''")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MakeSudoCommand creates a command string that safely passes password to sudo.
|
|
||||||
// This helper escapes special characters in the password to prevent shell injection
|
|
||||||
// and syntax errors when passwords contain single quotes, apostrophes, or other special chars.
|
|
||||||
func MakeSudoCommand(sudoPassword string, command string) string {
|
|
||||||
return fmt.Sprintf("echo '%s' | sudo -S %s", escapeSingleQuotes(sudoPassword), command)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExecSudoCommand creates an exec.Cmd that runs a command with sudo using the provided password.
|
|
||||||
// The password is properly escaped to prevent shell injection and syntax errors.
|
|
||||||
func ExecSudoCommand(ctx context.Context, sudoPassword string, command string) *exec.Cmd {
|
|
||||||
cmdStr := MakeSudoCommand(sudoPassword, command)
|
|
||||||
return exec.CommandContext(ctx, "bash", "-c", cmdStr)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *BaseDistribution) detectCommand(name, description string) deps.Dependency {
|
func (b *BaseDistribution) detectCommand(name, description string) deps.Dependency {
|
||||||
status := deps.StatusMissing
|
status := deps.StatusMissing
|
||||||
if b.commandExists(name) {
|
if b.commandExists(name) {
|
||||||
@@ -252,7 +232,7 @@ func (b *BaseDistribution) detectQuickshell() deps.Dependency {
|
|||||||
}
|
}
|
||||||
|
|
||||||
versionStr := string(output)
|
versionStr := string(output)
|
||||||
versionRegex := regexp.MustCompile(`quickshell (\d+\.\d+\.\d+)`)
|
versionRegex := regexp.MustCompile(`(?i)quickshell (\d+\.\d+\.\d+)`)
|
||||||
matches := versionRegex.FindStringSubmatch(versionStr)
|
matches := versionRegex.FindStringSubmatch(versionStr)
|
||||||
|
|
||||||
if len(matches) < 2 {
|
if len(matches) < 2 {
|
||||||
@@ -710,7 +690,7 @@ func (b *BaseDistribution) installDMSBinary(ctx context.Context, sudoPassword st
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Install to /usr/local/bin
|
// Install to /usr/local/bin
|
||||||
installCmd := ExecSudoCommand(ctx, sudoPassword,
|
installCmd := privesc.ExecCommand(ctx, sudoPassword,
|
||||||
fmt.Sprintf("cp %s /usr/local/bin/dms", binaryPath))
|
fmt.Sprintf("cp %s /usr/local/bin/dms", binaryPath))
|
||||||
if err := installCmd.Run(); err != nil {
|
if err := installCmd.Run(); err != nil {
|
||||||
return fmt.Errorf("failed to install DMS binary: %w", err)
|
return fmt.Errorf("failed to install DMS binary: %w", err)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -182,7 +183,7 @@ func (d *DebianDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
|
|||||||
LogOutput: "Updating APT package lists",
|
LogOutput: "Updating APT package lists",
|
||||||
}
|
}
|
||||||
|
|
||||||
updateCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get update")
|
updateCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get update")
|
||||||
if err := d.runWithProgress(updateCmd, progressChan, PhasePrerequisites, 0.06, 0.07); err != nil {
|
if err := d.runWithProgress(updateCmd, progressChan, PhasePrerequisites, 0.06, 0.07); err != nil {
|
||||||
return fmt.Errorf("failed to update package lists: %w", err)
|
return fmt.Errorf("failed to update package lists: %w", err)
|
||||||
}
|
}
|
||||||
@@ -199,7 +200,7 @@ func (d *DebianDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
|
|||||||
|
|
||||||
checkCmd := exec.CommandContext(ctx, "dpkg", "-l", "build-essential")
|
checkCmd := exec.CommandContext(ctx, "dpkg", "-l", "build-essential")
|
||||||
if err := checkCmd.Run(); err != nil {
|
if err := checkCmd.Run(); err != nil {
|
||||||
cmd := ExecSudoCommand(ctx, sudoPassword, "DEBIAN_FRONTEND=noninteractive apt-get install -y build-essential")
|
cmd := privesc.ExecCommand(ctx, sudoPassword, "DEBIAN_FRONTEND=noninteractive apt-get install -y build-essential")
|
||||||
if err := d.runWithProgress(cmd, progressChan, PhasePrerequisites, 0.08, 0.09); err != nil {
|
if err := d.runWithProgress(cmd, progressChan, PhasePrerequisites, 0.08, 0.09); err != nil {
|
||||||
return fmt.Errorf("failed to install build-essential: %w", err)
|
return fmt.Errorf("failed to install build-essential: %w", err)
|
||||||
}
|
}
|
||||||
@@ -215,7 +216,7 @@ func (d *DebianDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
|
|||||||
LogOutput: "Installing additional development tools",
|
LogOutput: "Installing additional development tools",
|
||||||
}
|
}
|
||||||
|
|
||||||
devToolsCmd := ExecSudoCommand(ctx, sudoPassword,
|
devToolsCmd := privesc.ExecCommand(ctx, sudoPassword,
|
||||||
"DEBIAN_FRONTEND=noninteractive apt-get install -y curl wget git cmake ninja-build pkg-config gnupg libxcb-cursor-dev libglib2.0-dev libpolkit-agent-1-dev libjpeg-dev libpugixml-dev")
|
"DEBIAN_FRONTEND=noninteractive apt-get install -y curl wget git cmake ninja-build pkg-config gnupg libxcb-cursor-dev libglib2.0-dev libpolkit-agent-1-dev libjpeg-dev libpugixml-dev")
|
||||||
if err := d.runWithProgress(devToolsCmd, progressChan, PhasePrerequisites, 0.10, 0.12); err != nil {
|
if err := d.runWithProgress(devToolsCmd, progressChan, PhasePrerequisites, 0.10, 0.12); err != nil {
|
||||||
return fmt.Errorf("failed to install development tools: %w", err)
|
return fmt.Errorf("failed to install development tools: %w", err)
|
||||||
@@ -441,7 +442,7 @@ func (d *DebianDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Packa
|
|||||||
keyringPath := fmt.Sprintf("/etc/apt/keyrings/%s.gpg", repoName)
|
keyringPath := fmt.Sprintf("/etc/apt/keyrings/%s.gpg", repoName)
|
||||||
|
|
||||||
// Create keyrings directory if it doesn't exist
|
// Create keyrings directory if it doesn't exist
|
||||||
mkdirCmd := ExecSudoCommand(ctx, sudoPassword, "mkdir -p /etc/apt/keyrings")
|
mkdirCmd := privesc.ExecCommand(ctx, sudoPassword, "mkdir -p /etc/apt/keyrings")
|
||||||
if err := mkdirCmd.Run(); err != nil {
|
if err := mkdirCmd.Run(); err != nil {
|
||||||
d.log(fmt.Sprintf("Warning: failed to create keyrings directory: %v", err))
|
d.log(fmt.Sprintf("Warning: failed to create keyrings directory: %v", err))
|
||||||
}
|
}
|
||||||
@@ -455,7 +456,7 @@ func (d *DebianDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Packa
|
|||||||
}
|
}
|
||||||
|
|
||||||
keyCmd := fmt.Sprintf("bash -c 'rm -f %s && curl -fsSL %s/Release.key | gpg --batch --dearmor -o %s'", keyringPath, baseURL, keyringPath)
|
keyCmd := fmt.Sprintf("bash -c 'rm -f %s && curl -fsSL %s/Release.key | gpg --batch --dearmor -o %s'", keyringPath, baseURL, keyringPath)
|
||||||
cmd := ExecSudoCommand(ctx, sudoPassword, keyCmd)
|
cmd := privesc.ExecCommand(ctx, sudoPassword, keyCmd)
|
||||||
if err := d.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.18, 0.20); err != nil {
|
if err := d.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.18, 0.20); err != nil {
|
||||||
return fmt.Errorf("failed to add OBS GPG key for %s: %w", pkg.RepoURL, err)
|
return fmt.Errorf("failed to add OBS GPG key for %s: %w", pkg.RepoURL, err)
|
||||||
}
|
}
|
||||||
@@ -471,7 +472,7 @@ func (d *DebianDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Packa
|
|||||||
CommandInfo: fmt.Sprintf("echo '%s' | sudo tee %s", repoLine, listFile),
|
CommandInfo: fmt.Sprintf("echo '%s' | sudo tee %s", repoLine, listFile),
|
||||||
}
|
}
|
||||||
|
|
||||||
addRepoCmd := ExecSudoCommand(ctx, sudoPassword,
|
addRepoCmd := privesc.ExecCommand(ctx, sudoPassword,
|
||||||
fmt.Sprintf("bash -c \"echo '%s' | tee %s\"", repoLine, listFile))
|
fmt.Sprintf("bash -c \"echo '%s' | tee %s\"", repoLine, listFile))
|
||||||
if err := d.runWithProgress(addRepoCmd, progressChan, PhaseSystemPackages, 0.20, 0.22); err != nil {
|
if err := d.runWithProgress(addRepoCmd, progressChan, PhaseSystemPackages, 0.20, 0.22); err != nil {
|
||||||
return fmt.Errorf("failed to add OBS repo %s: %w", pkg.RepoURL, err)
|
return fmt.Errorf("failed to add OBS repo %s: %w", pkg.RepoURL, err)
|
||||||
@@ -491,7 +492,7 @@ func (d *DebianDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Packa
|
|||||||
CommandInfo: "sudo apt-get update",
|
CommandInfo: "sudo apt-get update",
|
||||||
}
|
}
|
||||||
|
|
||||||
updateCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get update")
|
updateCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get update")
|
||||||
if err := d.runWithProgress(updateCmd, progressChan, PhaseSystemPackages, 0.25, 0.27); err != nil {
|
if err := d.runWithProgress(updateCmd, progressChan, PhaseSystemPackages, 0.25, 0.27); err != nil {
|
||||||
return fmt.Errorf("failed to update package lists after adding OBS repos: %w", err)
|
return fmt.Errorf("failed to update package lists after adding OBS repos: %w", err)
|
||||||
}
|
}
|
||||||
@@ -537,7 +538,7 @@ func (d *DebianDistribution) installAPTPackages(ctx context.Context, packages []
|
|||||||
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
|
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
|
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
|
||||||
return d.runWithProgress(cmd, progressChan, PhaseSystemPackages, startProgress, endProgress)
|
return d.runWithProgress(cmd, progressChan, PhaseSystemPackages, startProgress, endProgress)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -625,7 +626,7 @@ func (d *DebianDistribution) installBuildDependencies(ctx context.Context, manua
|
|||||||
args := []string{"apt-get", "install", "-y"}
|
args := []string{"apt-get", "install", "-y"}
|
||||||
args = append(args, depList...)
|
args = append(args, depList...)
|
||||||
|
|
||||||
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
|
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
|
||||||
return d.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.80, 0.82)
|
return d.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.80, 0.82)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -643,7 +644,7 @@ func (d *DebianDistribution) installRust(ctx context.Context, sudoPassword strin
|
|||||||
CommandInfo: "sudo apt-get install rustup",
|
CommandInfo: "sudo apt-get install rustup",
|
||||||
}
|
}
|
||||||
|
|
||||||
rustupInstallCmd := ExecSudoCommand(ctx, sudoPassword, "DEBIAN_FRONTEND=noninteractive apt-get install -y rustup")
|
rustupInstallCmd := privesc.ExecCommand(ctx, sudoPassword, "DEBIAN_FRONTEND=noninteractive apt-get install -y rustup")
|
||||||
if err := d.runWithProgress(rustupInstallCmd, progressChan, PhaseSystemPackages, 0.82, 0.83); err != nil {
|
if err := d.runWithProgress(rustupInstallCmd, progressChan, PhaseSystemPackages, 0.82, 0.83); err != nil {
|
||||||
return fmt.Errorf("failed to install rustup: %w", err)
|
return fmt.Errorf("failed to install rustup: %w", err)
|
||||||
}
|
}
|
||||||
@@ -682,7 +683,7 @@ func (d *DebianDistribution) installGo(ctx context.Context, sudoPassword string,
|
|||||||
CommandInfo: "sudo apt-get install golang-go",
|
CommandInfo: "sudo apt-get install golang-go",
|
||||||
}
|
}
|
||||||
|
|
||||||
installCmd := ExecSudoCommand(ctx, sudoPassword, "DEBIAN_FRONTEND=noninteractive apt-get install -y golang-go")
|
installCmd := privesc.ExecCommand(ctx, sudoPassword, "DEBIAN_FRONTEND=noninteractive apt-get install -y golang-go")
|
||||||
return d.runWithProgress(installCmd, progressChan, PhaseSystemPackages, 0.87, 0.90)
|
return d.runWithProgress(installCmd, progressChan, PhaseSystemPackages, 0.87, 0.90)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -254,7 +255,7 @@ func (f *FedoraDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
|
|||||||
|
|
||||||
args := []string{"dnf", "install", "-y"}
|
args := []string{"dnf", "install", "-y"}
|
||||||
args = append(args, missingPkgs...)
|
args = append(args, missingPkgs...)
|
||||||
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
|
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
f.logError("failed to install prerequisites", err)
|
f.logError("failed to install prerequisites", err)
|
||||||
@@ -437,7 +438,7 @@ func (f *FedoraDistribution) enableCOPRRepos(ctx context.Context, coprPkgs []Pac
|
|||||||
CommandInfo: fmt.Sprintf("sudo dnf copr enable -y %s", pkg.RepoURL),
|
CommandInfo: fmt.Sprintf("sudo dnf copr enable -y %s", pkg.RepoURL),
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := ExecSudoCommand(ctx, sudoPassword,
|
cmd := privesc.ExecCommand(ctx, sudoPassword,
|
||||||
fmt.Sprintf("dnf copr enable -y %s 2>&1", pkg.RepoURL))
|
fmt.Sprintf("dnf copr enable -y %s 2>&1", pkg.RepoURL))
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -461,7 +462,7 @@ func (f *FedoraDistribution) enableCOPRRepos(ctx context.Context, coprPkgs []Pac
|
|||||||
CommandInfo: fmt.Sprintf("echo \"priority=1\" | sudo tee -a %s", repoFile),
|
CommandInfo: fmt.Sprintf("echo \"priority=1\" | sudo tee -a %s", repoFile),
|
||||||
}
|
}
|
||||||
|
|
||||||
priorityCmd := ExecSudoCommand(ctx, sudoPassword,
|
priorityCmd := privesc.ExecCommand(ctx, sudoPassword,
|
||||||
fmt.Sprintf("bash -c 'echo \"priority=1\" | tee -a %s'", repoFile))
|
fmt.Sprintf("bash -c 'echo \"priority=1\" | tee -a %s'", repoFile))
|
||||||
priorityOutput, err := priorityCmd.CombinedOutput()
|
priorityOutput, err := priorityCmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -537,7 +538,7 @@ func (f *FedoraDistribution) installDNFGroups(ctx context.Context, packages []st
|
|||||||
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
|
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
|
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
|
||||||
return f.runWithProgress(cmd, progressChan, phase, groupStart, groupEnd)
|
return f.runWithProgress(cmd, progressChan, phase, groupStart, groupEnd)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||||
)
|
)
|
||||||
|
|
||||||
var GentooGlobalUseFlags = []string{
|
var GentooGlobalUseFlags = []string{
|
||||||
@@ -115,6 +116,20 @@ func (g *GentooDistribution) detectXDGPortal() deps.Dependency {
|
|||||||
return g.detectPackage("xdg-desktop-portal-gtk", "Desktop integration portal for GTK", g.packageInstalled("sys-apps/xdg-desktop-portal-gtk"))
|
return g.detectPackage("xdg-desktop-portal-gtk", "Desktop integration portal for GTK", g.packageInstalled("sys-apps/xdg-desktop-portal-gtk"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (g *GentooDistribution) detectDMS() deps.Dependency {
|
||||||
|
dep := deps.Dependency{
|
||||||
|
Name: "dms (DankMaterialShell)",
|
||||||
|
Status: deps.StatusMissing,
|
||||||
|
Description: "Desktop Management System configuration",
|
||||||
|
Required: true,
|
||||||
|
CanToggle: false,
|
||||||
|
}
|
||||||
|
if g.packageInstalled("gui-apps/dankmaterialshell") {
|
||||||
|
dep.Status = deps.StatusInstalled
|
||||||
|
}
|
||||||
|
return dep
|
||||||
|
}
|
||||||
|
|
||||||
func (g *GentooDistribution) detectXwaylandSatellite() deps.Dependency {
|
func (g *GentooDistribution) detectXwaylandSatellite() deps.Dependency {
|
||||||
return g.detectPackage("xwayland-satellite", "Xwayland support", g.packageInstalled("gui-apps/xwayland-satellite"))
|
return g.detectPackage("xwayland-satellite", "Xwayland support", g.packageInstalled("gui-apps/xwayland-satellite"))
|
||||||
}
|
}
|
||||||
@@ -149,8 +164,8 @@ func (g *GentooDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
|
|||||||
|
|
||||||
"quickshell": g.getQuickshellMapping(variants["quickshell"]),
|
"quickshell": g.getQuickshellMapping(variants["quickshell"]),
|
||||||
"matugen": {Name: "x11-misc/matugen", Repository: RepoTypeGURU, AcceptKeywords: archKeyword},
|
"matugen": {Name: "x11-misc/matugen", Repository: RepoTypeGURU, AcceptKeywords: archKeyword},
|
||||||
"dms (DankMaterialShell)": g.getDmsMapping(variants["dms (DankMaterialShell)"]),
|
"dms (DankMaterialShell)": g.getDmsMapping(),
|
||||||
"dgop": {Name: "dgop", Repository: RepoTypeManual, BuildFunc: "installDgop"},
|
"dgop": {Name: "gui-apps/dgop", Repository: RepoTypeGURU, AcceptKeywords: archKeyword},
|
||||||
}
|
}
|
||||||
|
|
||||||
switch wm {
|
switch wm {
|
||||||
@@ -170,8 +185,8 @@ func (g *GentooDistribution) getQuickshellMapping(_ deps.PackageVariant) Package
|
|||||||
return PackageMapping{Name: "gui-apps/quickshell", Repository: RepoTypeGURU, UseFlags: "breakpad jemalloc sockets wayland layer-shell session-lock toplevel-management screencopy X pipewire tray mpris pam hyprland hyprland-global-shortcuts hyprland-focus-grab i3 i3-ipc bluetooth", AcceptKeywords: "**"}
|
return PackageMapping{Name: "gui-apps/quickshell", Repository: RepoTypeGURU, UseFlags: "breakpad jemalloc sockets wayland layer-shell session-lock toplevel-management screencopy X pipewire tray mpris pam hyprland hyprland-global-shortcuts hyprland-focus-grab i3 i3-ipc bluetooth", AcceptKeywords: "**"}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *GentooDistribution) getDmsMapping(_ deps.PackageVariant) PackageMapping {
|
func (g *GentooDistribution) getDmsMapping() PackageMapping {
|
||||||
return PackageMapping{Name: "dms", Repository: RepoTypeManual, BuildFunc: "installDankMaterialShell"}
|
return PackageMapping{Name: "gui-apps/dankmaterialshell", Repository: RepoTypeGURU, AcceptKeywords: g.getArchKeyword()}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *GentooDistribution) getHyprlandMapping(_ deps.PackageVariant) PackageMapping {
|
func (g *GentooDistribution) getHyprlandMapping(_ deps.PackageVariant) PackageMapping {
|
||||||
@@ -201,9 +216,9 @@ func (g *GentooDistribution) setGlobalUseFlags(ctx context.Context, sudoPassword
|
|||||||
|
|
||||||
var cmd *exec.Cmd
|
var cmd *exec.Cmd
|
||||||
if hasUse {
|
if hasUse {
|
||||||
cmd = ExecSudoCommand(ctx, sudoPassword, fmt.Sprintf("sed -i 's/^USE=\"\\(.*\\)\"/USE=\"\\1 %s\"/' /etc/portage/make.conf", useFlags))
|
cmd = privesc.ExecCommand(ctx, sudoPassword, fmt.Sprintf("sed -i 's/^USE=\"\\(.*\\)\"/USE=\"\\1 %s\"/' /etc/portage/make.conf", useFlags))
|
||||||
} else {
|
} else {
|
||||||
cmd = ExecSudoCommand(ctx, sudoPassword, fmt.Sprintf("bash -c \"echo 'USE=\\\"%s\\\"' >> /etc/portage/make.conf\"", useFlags))
|
cmd = privesc.ExecCommand(ctx, sudoPassword, fmt.Sprintf("bash -c \"echo 'USE=\\\"%s\\\"' >> /etc/portage/make.conf\"", useFlags))
|
||||||
}
|
}
|
||||||
|
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
@@ -281,7 +296,7 @@ func (g *GentooDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
|
|||||||
LogOutput: "Syncing Portage tree with emerge --sync",
|
LogOutput: "Syncing Portage tree with emerge --sync",
|
||||||
}
|
}
|
||||||
|
|
||||||
syncCmd := ExecSudoCommand(ctx, sudoPassword, "emerge --sync --quiet")
|
syncCmd := privesc.ExecCommand(ctx, sudoPassword, "emerge --sync --quiet")
|
||||||
syncOutput, syncErr := syncCmd.CombinedOutput()
|
syncOutput, syncErr := syncCmd.CombinedOutput()
|
||||||
if syncErr != nil {
|
if syncErr != nil {
|
||||||
g.log(fmt.Sprintf("emerge --sync output: %s", string(syncOutput)))
|
g.log(fmt.Sprintf("emerge --sync output: %s", string(syncOutput)))
|
||||||
@@ -302,7 +317,7 @@ func (g *GentooDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
|
|||||||
|
|
||||||
args := []string{"emerge", "--ask=n", "--quiet"}
|
args := []string{"emerge", "--ask=n", "--quiet"}
|
||||||
args = append(args, missingPkgs...)
|
args = append(args, missingPkgs...)
|
||||||
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
|
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
g.logError("failed to install prerequisites", err)
|
g.logError("failed to install prerequisites", err)
|
||||||
@@ -503,14 +518,14 @@ func (g *GentooDistribution) installPortagePackages(ctx context.Context, package
|
|||||||
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
|
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
|
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
|
||||||
return g.runWithProgressTimeout(cmd, progressChan, PhaseSystemPackages, 0.40, 0.60, 0)
|
return g.runWithProgressTimeout(cmd, progressChan, PhaseSystemPackages, 0.40, 0.60, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *GentooDistribution) setPackageUseFlags(ctx context.Context, packageName, useFlags, sudoPassword string) error {
|
func (g *GentooDistribution) setPackageUseFlags(ctx context.Context, packageName, useFlags, sudoPassword string) error {
|
||||||
packageUseDir := "/etc/portage/package.use"
|
packageUseDir := "/etc/portage/package.use"
|
||||||
|
|
||||||
mkdirCmd := ExecSudoCommand(ctx, sudoPassword,
|
mkdirCmd := privesc.ExecCommand(ctx, sudoPassword,
|
||||||
fmt.Sprintf("mkdir -p %s", packageUseDir))
|
fmt.Sprintf("mkdir -p %s", packageUseDir))
|
||||||
if output, err := mkdirCmd.CombinedOutput(); err != nil {
|
if output, err := mkdirCmd.CombinedOutput(); err != nil {
|
||||||
g.log(fmt.Sprintf("mkdir output: %s", string(output)))
|
g.log(fmt.Sprintf("mkdir output: %s", string(output)))
|
||||||
@@ -524,7 +539,7 @@ func (g *GentooDistribution) setPackageUseFlags(ctx context.Context, packageName
|
|||||||
if checkExistingCmd.Run() == nil {
|
if checkExistingCmd.Run() == nil {
|
||||||
g.log(fmt.Sprintf("Updating USE flags for %s from existing entry", packageName))
|
g.log(fmt.Sprintf("Updating USE flags for %s from existing entry", packageName))
|
||||||
escapedPkg := strings.ReplaceAll(packageName, "/", "\\/")
|
escapedPkg := strings.ReplaceAll(packageName, "/", "\\/")
|
||||||
replaceCmd := ExecSudoCommand(ctx, sudoPassword,
|
replaceCmd := privesc.ExecCommand(ctx, sudoPassword,
|
||||||
fmt.Sprintf("sed -i '/^%s /d' %s/danklinux; exit_code=$?; exit $exit_code", escapedPkg, packageUseDir))
|
fmt.Sprintf("sed -i '/^%s /d' %s/danklinux; exit_code=$?; exit $exit_code", escapedPkg, packageUseDir))
|
||||||
if output, err := replaceCmd.CombinedOutput(); err != nil {
|
if output, err := replaceCmd.CombinedOutput(); err != nil {
|
||||||
g.log(fmt.Sprintf("sed delete output: %s", string(output)))
|
g.log(fmt.Sprintf("sed delete output: %s", string(output)))
|
||||||
@@ -532,7 +547,7 @@ func (g *GentooDistribution) setPackageUseFlags(ctx context.Context, packageName
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
appendCmd := ExecSudoCommand(ctx, sudoPassword,
|
appendCmd := privesc.ExecCommand(ctx, sudoPassword,
|
||||||
fmt.Sprintf("bash -c \"echo '%s' >> %s/danklinux\"", useFlagLine, packageUseDir))
|
fmt.Sprintf("bash -c \"echo '%s' >> %s/danklinux\"", useFlagLine, packageUseDir))
|
||||||
|
|
||||||
output, err := appendCmd.CombinedOutput()
|
output, err := appendCmd.CombinedOutput()
|
||||||
@@ -557,7 +572,7 @@ func (g *GentooDistribution) syncGURURepo(ctx context.Context, sudoPassword stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Enable GURU repository
|
// Enable GURU repository
|
||||||
enableCmd := ExecSudoCommand(ctx, sudoPassword,
|
enableCmd := privesc.ExecCommand(ctx, sudoPassword,
|
||||||
"eselect repository enable guru 2>&1; exit_code=$?; exit $exit_code")
|
"eselect repository enable guru 2>&1; exit_code=$?; exit $exit_code")
|
||||||
output, err := enableCmd.CombinedOutput()
|
output, err := enableCmd.CombinedOutput()
|
||||||
|
|
||||||
@@ -589,7 +604,7 @@ func (g *GentooDistribution) syncGURURepo(ctx context.Context, sudoPassword stri
|
|||||||
LogOutput: "Syncing GURU repository",
|
LogOutput: "Syncing GURU repository",
|
||||||
}
|
}
|
||||||
|
|
||||||
syncCmd := ExecSudoCommand(ctx, sudoPassword,
|
syncCmd := privesc.ExecCommand(ctx, sudoPassword,
|
||||||
"emaint sync --repo guru 2>&1; exit_code=$?; exit $exit_code")
|
"emaint sync --repo guru 2>&1; exit_code=$?; exit $exit_code")
|
||||||
syncOutput, syncErr := syncCmd.CombinedOutput()
|
syncOutput, syncErr := syncCmd.CombinedOutput()
|
||||||
|
|
||||||
@@ -622,7 +637,7 @@ func (g *GentooDistribution) setPackageAcceptKeywords(ctx context.Context, packa
|
|||||||
|
|
||||||
acceptKeywordsDir := "/etc/portage/package.accept_keywords"
|
acceptKeywordsDir := "/etc/portage/package.accept_keywords"
|
||||||
|
|
||||||
mkdirCmd := ExecSudoCommand(ctx, sudoPassword,
|
mkdirCmd := privesc.ExecCommand(ctx, sudoPassword,
|
||||||
fmt.Sprintf("mkdir -p %s", acceptKeywordsDir))
|
fmt.Sprintf("mkdir -p %s", acceptKeywordsDir))
|
||||||
if output, err := mkdirCmd.CombinedOutput(); err != nil {
|
if output, err := mkdirCmd.CombinedOutput(); err != nil {
|
||||||
g.log(fmt.Sprintf("mkdir output: %s", string(output)))
|
g.log(fmt.Sprintf("mkdir output: %s", string(output)))
|
||||||
@@ -636,7 +651,7 @@ func (g *GentooDistribution) setPackageAcceptKeywords(ctx context.Context, packa
|
|||||||
if checkExistingCmd.Run() == nil {
|
if checkExistingCmd.Run() == nil {
|
||||||
g.log(fmt.Sprintf("Updating accept keywords for %s from existing entry", packageName))
|
g.log(fmt.Sprintf("Updating accept keywords for %s from existing entry", packageName))
|
||||||
escapedPkg := strings.ReplaceAll(packageName, "/", "\\/")
|
escapedPkg := strings.ReplaceAll(packageName, "/", "\\/")
|
||||||
replaceCmd := ExecSudoCommand(ctx, sudoPassword,
|
replaceCmd := privesc.ExecCommand(ctx, sudoPassword,
|
||||||
fmt.Sprintf("sed -i '/^%s /d' %s/danklinux; exit_code=$?; exit $exit_code", escapedPkg, acceptKeywordsDir))
|
fmt.Sprintf("sed -i '/^%s /d' %s/danklinux; exit_code=$?; exit $exit_code", escapedPkg, acceptKeywordsDir))
|
||||||
if output, err := replaceCmd.CombinedOutput(); err != nil {
|
if output, err := replaceCmd.CombinedOutput(); err != nil {
|
||||||
g.log(fmt.Sprintf("sed delete output: %s", string(output)))
|
g.log(fmt.Sprintf("sed delete output: %s", string(output)))
|
||||||
@@ -644,7 +659,7 @@ func (g *GentooDistribution) setPackageAcceptKeywords(ctx context.Context, packa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
appendCmd := ExecSudoCommand(ctx, sudoPassword,
|
appendCmd := privesc.ExecCommand(ctx, sudoPassword,
|
||||||
fmt.Sprintf("bash -c \"echo '%s' >> %s/danklinux\"", keywordLine, acceptKeywordsDir))
|
fmt.Sprintf("bash -c \"echo '%s' >> %s/danklinux\"", keywordLine, acceptKeywordsDir))
|
||||||
|
|
||||||
output, err := appendCmd.CombinedOutput()
|
output, err := appendCmd.CombinedOutput()
|
||||||
@@ -695,6 +710,6 @@ func (g *GentooDistribution) installGURUPackages(ctx context.Context, packages [
|
|||||||
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
|
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
|
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
|
||||||
return g.runWithProgressTimeout(cmd, progressChan, PhaseAURPackages, 0.70, 0.85, 0)
|
return g.runWithProgressTimeout(cmd, progressChan, PhaseAURPackages, 0.70, 0.85, 0)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ManualPackageInstaller provides methods for installing packages from source
|
// ManualPackageInstaller provides methods for installing packages from source
|
||||||
@@ -143,7 +144,7 @@ func (m *ManualPackageInstaller) installDgop(ctx context.Context, sudoPassword s
|
|||||||
CommandInfo: "sudo make install",
|
CommandInfo: "sudo make install",
|
||||||
}
|
}
|
||||||
|
|
||||||
installCmd := ExecSudoCommand(ctx, sudoPassword, "make install")
|
installCmd := privesc.ExecCommand(ctx, sudoPassword, "make install")
|
||||||
installCmd.Dir = tmpDir
|
installCmd.Dir = tmpDir
|
||||||
if err := installCmd.Run(); err != nil {
|
if err := installCmd.Run(); err != nil {
|
||||||
m.logError("failed to install dgop", err)
|
m.logError("failed to install dgop", err)
|
||||||
@@ -213,7 +214,7 @@ func (m *ManualPackageInstaller) installNiri(ctx context.Context, sudoPassword s
|
|||||||
CommandInfo: "dpkg -i niri.deb",
|
CommandInfo: "dpkg -i niri.deb",
|
||||||
}
|
}
|
||||||
|
|
||||||
installDebCmd := ExecSudoCommand(ctx, sudoPassword,
|
installDebCmd := privesc.ExecCommand(ctx, sudoPassword,
|
||||||
fmt.Sprintf("dpkg -i %s/target/debian/niri_*.deb", buildDir))
|
fmt.Sprintf("dpkg -i %s/target/debian/niri_*.deb", buildDir))
|
||||||
|
|
||||||
output, err := installDebCmd.CombinedOutput()
|
output, err := installDebCmd.CombinedOutput()
|
||||||
@@ -324,7 +325,7 @@ func (m *ManualPackageInstaller) installQuickshell(ctx context.Context, variant
|
|||||||
CommandInfo: "sudo cmake --install build",
|
CommandInfo: "sudo cmake --install build",
|
||||||
}
|
}
|
||||||
|
|
||||||
installCmd := ExecSudoCommand(ctx, sudoPassword, "cmake --install build")
|
installCmd := privesc.ExecCommand(ctx, sudoPassword, "cmake --install build")
|
||||||
installCmd.Dir = tmpDir
|
installCmd.Dir = tmpDir
|
||||||
if err := installCmd.Run(); err != nil {
|
if err := installCmd.Run(); err != nil {
|
||||||
return fmt.Errorf("failed to install quickshell: %w", err)
|
return fmt.Errorf("failed to install quickshell: %w", err)
|
||||||
@@ -387,7 +388,7 @@ func (m *ManualPackageInstaller) installHyprland(ctx context.Context, sudoPasswo
|
|||||||
CommandInfo: "sudo make install",
|
CommandInfo: "sudo make install",
|
||||||
}
|
}
|
||||||
|
|
||||||
installCmd := ExecSudoCommand(ctx, sudoPassword, "make install")
|
installCmd := privesc.ExecCommand(ctx, sudoPassword, "make install")
|
||||||
installCmd.Dir = tmpDir
|
installCmd.Dir = tmpDir
|
||||||
if err := installCmd.Run(); err != nil {
|
if err := installCmd.Run(); err != nil {
|
||||||
return fmt.Errorf("failed to install Hyprland: %w", err)
|
return fmt.Errorf("failed to install Hyprland: %w", err)
|
||||||
@@ -453,7 +454,7 @@ func (m *ManualPackageInstaller) installGhostty(ctx context.Context, sudoPasswor
|
|||||||
CommandInfo: "sudo cp zig-out/bin/ghostty /usr/local/bin/",
|
CommandInfo: "sudo cp zig-out/bin/ghostty /usr/local/bin/",
|
||||||
}
|
}
|
||||||
|
|
||||||
installCmd := ExecSudoCommand(ctx, sudoPassword,
|
installCmd := privesc.ExecCommand(ctx, sudoPassword,
|
||||||
fmt.Sprintf("cp %s/zig-out/bin/ghostty /usr/local/bin/", tmpDir))
|
fmt.Sprintf("cp %s/zig-out/bin/ghostty /usr/local/bin/", tmpDir))
|
||||||
if err := installCmd.Run(); err != nil {
|
if err := installCmd.Run(); err != nil {
|
||||||
return fmt.Errorf("failed to install Ghostty: %w", err)
|
return fmt.Errorf("failed to install Ghostty: %w", err)
|
||||||
@@ -492,16 +493,11 @@ func (m *ManualPackageInstaller) installMatugen(ctx context.Context, sudoPasswor
|
|||||||
CommandInfo: fmt.Sprintf("sudo cp %s %s", sourcePath, targetPath),
|
CommandInfo: fmt.Sprintf("sudo cp %s %s", sourcePath, targetPath),
|
||||||
}
|
}
|
||||||
|
|
||||||
copyCmd := exec.CommandContext(ctx, "sudo", "-S", "cp", sourcePath, targetPath)
|
if err := privesc.Run(ctx, sudoPassword, "cp", sourcePath, targetPath); err != nil {
|
||||||
copyCmd.Stdin = strings.NewReader(sudoPassword + "\n")
|
|
||||||
if err := copyCmd.Run(); err != nil {
|
|
||||||
return fmt.Errorf("failed to copy matugen to /usr/local/bin: %w", err)
|
return fmt.Errorf("failed to copy matugen to /usr/local/bin: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make it executable
|
if err := privesc.Run(ctx, sudoPassword, "chmod", "+x", targetPath); err != nil {
|
||||||
chmodCmd := exec.CommandContext(ctx, "sudo", "-S", "chmod", "+x", targetPath)
|
|
||||||
chmodCmd.Stdin = strings.NewReader(sudoPassword + "\n")
|
|
||||||
if err := chmodCmd.Run(); err != nil {
|
|
||||||
return fmt.Errorf("failed to make matugen executable: %w", err)
|
return fmt.Errorf("failed to make matugen executable: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -646,15 +642,11 @@ func (m *ManualPackageInstaller) installXwaylandSatellite(ctx context.Context, s
|
|||||||
CommandInfo: fmt.Sprintf("sudo cp %s %s", sourcePath, targetPath),
|
CommandInfo: fmt.Sprintf("sudo cp %s %s", sourcePath, targetPath),
|
||||||
}
|
}
|
||||||
|
|
||||||
copyCmd := exec.CommandContext(ctx, "sudo", "-S", "cp", sourcePath, targetPath)
|
if err := privesc.Run(ctx, sudoPassword, "cp", sourcePath, targetPath); err != nil {
|
||||||
copyCmd.Stdin = strings.NewReader(sudoPassword + "\n")
|
|
||||||
if err := copyCmd.Run(); err != nil {
|
|
||||||
return fmt.Errorf("failed to copy xwayland-satellite to /usr/local/bin: %w", err)
|
return fmt.Errorf("failed to copy xwayland-satellite to /usr/local/bin: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
chmodCmd := exec.CommandContext(ctx, "sudo", "-S", "chmod", "+x", targetPath)
|
if err := privesc.Run(ctx, sudoPassword, "chmod", "+x", targetPath); err != nil {
|
||||||
chmodCmd.Stdin = strings.NewReader(sudoPassword + "\n")
|
|
||||||
if err := chmodCmd.Run(); err != nil {
|
|
||||||
return fmt.Errorf("failed to make xwayland-satellite executable: %w", err)
|
return fmt.Errorf("failed to make xwayland-satellite executable: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -250,7 +251,7 @@ func (o *OpenSUSEDistribution) InstallPrerequisites(ctx context.Context, sudoPas
|
|||||||
|
|
||||||
args := []string{"zypper", "install", "-y"}
|
args := []string{"zypper", "install", "-y"}
|
||||||
args = append(args, missingPkgs...)
|
args = append(args, missingPkgs...)
|
||||||
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
|
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
o.logError("failed to install prerequisites", err)
|
o.logError("failed to install prerequisites", err)
|
||||||
@@ -486,7 +487,7 @@ func (o *OpenSUSEDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Pac
|
|||||||
CommandInfo: fmt.Sprintf("sudo zypper addrepo %s", repoURL),
|
CommandInfo: fmt.Sprintf("sudo zypper addrepo %s", repoURL),
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := ExecSudoCommand(ctx, sudoPassword,
|
cmd := privesc.ExecCommand(ctx, sudoPassword,
|
||||||
fmt.Sprintf("zypper addrepo -f %s", repoURL))
|
fmt.Sprintf("zypper addrepo -f %s", repoURL))
|
||||||
if err := o.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.20, 0.22); err != nil {
|
if err := o.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.20, 0.22); err != nil {
|
||||||
o.log(fmt.Sprintf("OBS repo %s add failed (may already exist): %v", pkg.RepoURL, err))
|
o.log(fmt.Sprintf("OBS repo %s add failed (may already exist): %v", pkg.RepoURL, err))
|
||||||
@@ -507,7 +508,7 @@ func (o *OpenSUSEDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Pac
|
|||||||
CommandInfo: "sudo zypper --gpg-auto-import-keys refresh",
|
CommandInfo: "sudo zypper --gpg-auto-import-keys refresh",
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshCmd := ExecSudoCommand(ctx, sudoPassword, "zypper --gpg-auto-import-keys refresh")
|
refreshCmd := privesc.ExecCommand(ctx, sudoPassword, "zypper --gpg-auto-import-keys refresh")
|
||||||
if err := o.runWithProgress(refreshCmd, progressChan, PhaseSystemPackages, 0.25, 0.27); err != nil {
|
if err := o.runWithProgress(refreshCmd, progressChan, PhaseSystemPackages, 0.25, 0.27); err != nil {
|
||||||
return fmt.Errorf("failed to refresh repositories: %w", err)
|
return fmt.Errorf("failed to refresh repositories: %w", err)
|
||||||
}
|
}
|
||||||
@@ -588,7 +589,7 @@ func (o *OpenSUSEDistribution) disableInstallMediaRepos(ctx context.Context, sud
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, alias := range aliases {
|
for _, alias := range aliases {
|
||||||
cmd := ExecSudoCommand(ctx, sudoPassword, fmt.Sprintf("zypper modifyrepo -d '%s'", escapeSingleQuotes(alias)))
|
cmd := privesc.ExecCommand(ctx, sudoPassword, fmt.Sprintf("zypper modifyrepo -d '%s'", privesc.EscapeSingleQuotes(alias)))
|
||||||
repoOutput, err := cmd.CombinedOutput()
|
repoOutput, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
o.log(fmt.Sprintf("Failed to disable install media repo %s: %s", alias, strings.TrimSpace(string(repoOutput))))
|
o.log(fmt.Sprintf("Failed to disable install media repo %s: %s", alias, strings.TrimSpace(string(repoOutput))))
|
||||||
@@ -646,7 +647,7 @@ func (o *OpenSUSEDistribution) installZypperPackages(ctx context.Context, packag
|
|||||||
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
|
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
|
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
|
||||||
return o.runWithProgress(cmd, progressChan, phase, groupStart, groupEnd)
|
return o.runWithProgress(cmd, progressChan, phase, groupStart, groupEnd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -774,7 +775,7 @@ func (o *OpenSUSEDistribution) installQuickshell(ctx context.Context, variant de
|
|||||||
CommandInfo: "sudo cmake --install build",
|
CommandInfo: "sudo cmake --install build",
|
||||||
}
|
}
|
||||||
|
|
||||||
installCmd := ExecSudoCommand(ctx, sudoPassword, "cmake --install build")
|
installCmd := privesc.ExecCommand(ctx, sudoPassword, "cmake --install build")
|
||||||
installCmd.Dir = tmpDir
|
installCmd.Dir = tmpDir
|
||||||
if err := installCmd.Run(); err != nil {
|
if err := installCmd.Run(); err != nil {
|
||||||
return fmt.Errorf("failed to install quickshell: %w", err)
|
return fmt.Errorf("failed to install quickshell: %w", err)
|
||||||
@@ -798,7 +799,7 @@ func (o *OpenSUSEDistribution) installRust(ctx context.Context, sudoPassword str
|
|||||||
CommandInfo: "sudo zypper install rustup",
|
CommandInfo: "sudo zypper install rustup",
|
||||||
}
|
}
|
||||||
|
|
||||||
rustupInstallCmd := ExecSudoCommand(ctx, sudoPassword, "zypper install -y rustup")
|
rustupInstallCmd := privesc.ExecCommand(ctx, sudoPassword, "zypper install -y rustup")
|
||||||
if err := o.runWithProgress(rustupInstallCmd, progressChan, PhaseSystemPackages, 0.82, 0.83); err != nil {
|
if err := o.runWithProgress(rustupInstallCmd, progressChan, PhaseSystemPackages, 0.82, 0.83); err != nil {
|
||||||
return fmt.Errorf("failed to install rustup: %w", err)
|
return fmt.Errorf("failed to install rustup: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ func GetOSInfo() (*OSInfo, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
key := parts[0]
|
key := parts[0]
|
||||||
value := strings.Trim(parts[1], "\"")
|
value := strings.Trim(parts[1], "\"'")
|
||||||
|
|
||||||
switch key {
|
switch key {
|
||||||
case "ID":
|
case "ID":
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -177,7 +178,7 @@ func (u *UbuntuDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
|
|||||||
LogOutput: "Updating APT package lists",
|
LogOutput: "Updating APT package lists",
|
||||||
}
|
}
|
||||||
|
|
||||||
updateCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get update")
|
updateCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get update")
|
||||||
if err := u.runWithProgress(updateCmd, progressChan, PhasePrerequisites, 0.06, 0.07); err != nil {
|
if err := u.runWithProgress(updateCmd, progressChan, PhasePrerequisites, 0.06, 0.07); err != nil {
|
||||||
return fmt.Errorf("failed to update package lists: %w", err)
|
return fmt.Errorf("failed to update package lists: %w", err)
|
||||||
}
|
}
|
||||||
@@ -195,7 +196,7 @@ func (u *UbuntuDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
|
|||||||
checkCmd := exec.CommandContext(ctx, "dpkg", "-l", "build-essential")
|
checkCmd := exec.CommandContext(ctx, "dpkg", "-l", "build-essential")
|
||||||
if err := checkCmd.Run(); err != nil {
|
if err := checkCmd.Run(); err != nil {
|
||||||
// Not installed, install it
|
// Not installed, install it
|
||||||
cmd := ExecSudoCommand(ctx, sudoPassword, "apt-get install -y build-essential")
|
cmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get install -y build-essential")
|
||||||
if err := u.runWithProgress(cmd, progressChan, PhasePrerequisites, 0.08, 0.09); err != nil {
|
if err := u.runWithProgress(cmd, progressChan, PhasePrerequisites, 0.08, 0.09); err != nil {
|
||||||
return fmt.Errorf("failed to install build-essential: %w", err)
|
return fmt.Errorf("failed to install build-essential: %w", err)
|
||||||
}
|
}
|
||||||
@@ -211,7 +212,7 @@ func (u *UbuntuDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
|
|||||||
LogOutput: "Installing additional development tools",
|
LogOutput: "Installing additional development tools",
|
||||||
}
|
}
|
||||||
|
|
||||||
devToolsCmd := ExecSudoCommand(ctx, sudoPassword,
|
devToolsCmd := privesc.ExecCommand(ctx, sudoPassword,
|
||||||
"apt-get install -y curl wget git cmake ninja-build pkg-config libglib2.0-dev libpolkit-agent-1-dev")
|
"apt-get install -y curl wget git cmake ninja-build pkg-config libglib2.0-dev libpolkit-agent-1-dev")
|
||||||
if err := u.runWithProgress(devToolsCmd, progressChan, PhasePrerequisites, 0.10, 0.12); err != nil {
|
if err := u.runWithProgress(devToolsCmd, progressChan, PhasePrerequisites, 0.10, 0.12); err != nil {
|
||||||
return fmt.Errorf("failed to install development tools: %w", err)
|
return fmt.Errorf("failed to install development tools: %w", err)
|
||||||
@@ -398,7 +399,7 @@ func (u *UbuntuDistribution) extractPackageNames(packages []PackageMapping) []st
|
|||||||
func (u *UbuntuDistribution) enablePPARepos(ctx context.Context, ppaPkgs []PackageMapping, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
func (u *UbuntuDistribution) enablePPARepos(ctx context.Context, ppaPkgs []PackageMapping, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||||
enabledRepos := make(map[string]bool)
|
enabledRepos := make(map[string]bool)
|
||||||
|
|
||||||
installPPACmd := ExecSudoCommand(ctx, sudoPassword,
|
installPPACmd := privesc.ExecCommand(ctx, sudoPassword,
|
||||||
"apt-get install -y software-properties-common")
|
"apt-get install -y software-properties-common")
|
||||||
if err := u.runWithProgress(installPPACmd, progressChan, PhaseSystemPackages, 0.15, 0.17); err != nil {
|
if err := u.runWithProgress(installPPACmd, progressChan, PhaseSystemPackages, 0.15, 0.17); err != nil {
|
||||||
return fmt.Errorf("failed to install software-properties-common: %w", err)
|
return fmt.Errorf("failed to install software-properties-common: %w", err)
|
||||||
@@ -416,7 +417,7 @@ func (u *UbuntuDistribution) enablePPARepos(ctx context.Context, ppaPkgs []Packa
|
|||||||
CommandInfo: fmt.Sprintf("sudo add-apt-repository -y %s", pkg.RepoURL),
|
CommandInfo: fmt.Sprintf("sudo add-apt-repository -y %s", pkg.RepoURL),
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := ExecSudoCommand(ctx, sudoPassword,
|
cmd := privesc.ExecCommand(ctx, sudoPassword,
|
||||||
fmt.Sprintf("add-apt-repository -y %s", pkg.RepoURL))
|
fmt.Sprintf("add-apt-repository -y %s", pkg.RepoURL))
|
||||||
if err := u.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.20, 0.22); err != nil {
|
if err := u.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.20, 0.22); err != nil {
|
||||||
u.logError(fmt.Sprintf("failed to enable PPA repo %s", pkg.RepoURL), err)
|
u.logError(fmt.Sprintf("failed to enable PPA repo %s", pkg.RepoURL), err)
|
||||||
@@ -437,7 +438,7 @@ func (u *UbuntuDistribution) enablePPARepos(ctx context.Context, ppaPkgs []Packa
|
|||||||
CommandInfo: "sudo apt-get update",
|
CommandInfo: "sudo apt-get update",
|
||||||
}
|
}
|
||||||
|
|
||||||
updateCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get update")
|
updateCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get update")
|
||||||
if err := u.runWithProgress(updateCmd, progressChan, PhaseSystemPackages, 0.25, 0.27); err != nil {
|
if err := u.runWithProgress(updateCmd, progressChan, PhaseSystemPackages, 0.25, 0.27); err != nil {
|
||||||
return fmt.Errorf("failed to update package lists after adding PPAs: %w", err)
|
return fmt.Errorf("failed to update package lists after adding PPAs: %w", err)
|
||||||
}
|
}
|
||||||
@@ -504,7 +505,7 @@ func (u *UbuntuDistribution) installAPTGroups(ctx context.Context, packages []st
|
|||||||
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
|
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
|
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
|
||||||
return u.runWithProgress(cmd, progressChan, phase, groupStart, groupEnd)
|
return u.runWithProgress(cmd, progressChan, phase, groupStart, groupEnd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -591,7 +592,7 @@ func (u *UbuntuDistribution) installBuildDependencies(ctx context.Context, manua
|
|||||||
args := []string{"apt-get", "install", "-y"}
|
args := []string{"apt-get", "install", "-y"}
|
||||||
args = append(args, depList...)
|
args = append(args, depList...)
|
||||||
|
|
||||||
cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " "))
|
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
|
||||||
return u.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.80, 0.82)
|
return u.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.80, 0.82)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -609,7 +610,7 @@ func (u *UbuntuDistribution) installRust(ctx context.Context, sudoPassword strin
|
|||||||
CommandInfo: "sudo apt-get install rustup",
|
CommandInfo: "sudo apt-get install rustup",
|
||||||
}
|
}
|
||||||
|
|
||||||
rustupInstallCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get install -y rustup")
|
rustupInstallCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get install -y rustup")
|
||||||
if err := u.runWithProgress(rustupInstallCmd, progressChan, PhaseSystemPackages, 0.82, 0.83); err != nil {
|
if err := u.runWithProgress(rustupInstallCmd, progressChan, PhaseSystemPackages, 0.82, 0.83); err != nil {
|
||||||
return fmt.Errorf("failed to install rustup: %w", err)
|
return fmt.Errorf("failed to install rustup: %w", err)
|
||||||
}
|
}
|
||||||
@@ -649,7 +650,7 @@ func (u *UbuntuDistribution) installGo(ctx context.Context, sudoPassword string,
|
|||||||
CommandInfo: "sudo add-apt-repository ppa:longsleep/golang-backports",
|
CommandInfo: "sudo add-apt-repository ppa:longsleep/golang-backports",
|
||||||
}
|
}
|
||||||
|
|
||||||
addPPACmd := ExecSudoCommand(ctx, sudoPassword,
|
addPPACmd := privesc.ExecCommand(ctx, sudoPassword,
|
||||||
"add-apt-repository -y ppa:longsleep/golang-backports")
|
"add-apt-repository -y ppa:longsleep/golang-backports")
|
||||||
if err := u.runWithProgress(addPPACmd, progressChan, PhaseSystemPackages, 0.87, 0.88); err != nil {
|
if err := u.runWithProgress(addPPACmd, progressChan, PhaseSystemPackages, 0.87, 0.88); err != nil {
|
||||||
return fmt.Errorf("failed to add Go PPA: %w", err)
|
return fmt.Errorf("failed to add Go PPA: %w", err)
|
||||||
@@ -664,7 +665,7 @@ func (u *UbuntuDistribution) installGo(ctx context.Context, sudoPassword string,
|
|||||||
CommandInfo: "sudo apt-get update",
|
CommandInfo: "sudo apt-get update",
|
||||||
}
|
}
|
||||||
|
|
||||||
updateCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get update")
|
updateCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get update")
|
||||||
if err := u.runWithProgress(updateCmd, progressChan, PhaseSystemPackages, 0.88, 0.89); err != nil {
|
if err := u.runWithProgress(updateCmd, progressChan, PhaseSystemPackages, 0.88, 0.89); err != nil {
|
||||||
return fmt.Errorf("failed to update package lists after adding Go PPA: %w", err)
|
return fmt.Errorf("failed to update package lists after adding Go PPA: %w", err)
|
||||||
}
|
}
|
||||||
@@ -678,7 +679,7 @@ func (u *UbuntuDistribution) installGo(ctx context.Context, sudoPassword string,
|
|||||||
CommandInfo: "sudo apt-get install golang-go",
|
CommandInfo: "sudo apt-get install golang-go",
|
||||||
}
|
}
|
||||||
|
|
||||||
installCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get install -y golang-go")
|
installCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get install -y golang-go")
|
||||||
return u.runWithProgress(installCmd, progressChan, PhaseSystemPackages, 0.89, 0.90)
|
return u.runWithProgress(installCmd, progressChan, PhaseSystemPackages, 0.89, 0.90)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/matugen"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/matugen"
|
||||||
sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam"
|
sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||||
"github.com/sblinch/kdl-go"
|
"github.com/sblinch/kdl-go"
|
||||||
"github.com/sblinch/kdl-go/document"
|
"github.com/sblinch/kdl-go/document"
|
||||||
@@ -327,56 +328,17 @@ func EnsureGreetdInstalled(logFunc func(string), sudoPassword string) error {
|
|||||||
|
|
||||||
switch config.Family {
|
switch config.Family {
|
||||||
case distros.FamilyArch:
|
case distros.FamilyArch:
|
||||||
if sudoPassword != "" {
|
installCmd = privesc.ExecCommand(ctx, sudoPassword, "pacman -S --needed --noconfirm greetd")
|
||||||
installCmd = distros.ExecSudoCommand(ctx, sudoPassword,
|
|
||||||
"pacman -S --needed --noconfirm greetd")
|
|
||||||
} else {
|
|
||||||
installCmd = exec.CommandContext(ctx, "sudo", "pacman", "-S", "--needed", "--noconfirm", "greetd")
|
|
||||||
}
|
|
||||||
|
|
||||||
case distros.FamilyFedora:
|
case distros.FamilyFedora:
|
||||||
if sudoPassword != "" {
|
installCmd = privesc.ExecCommand(ctx, sudoPassword, "dnf install -y greetd")
|
||||||
installCmd = distros.ExecSudoCommand(ctx, sudoPassword,
|
|
||||||
"dnf install -y greetd")
|
|
||||||
} else {
|
|
||||||
installCmd = exec.CommandContext(ctx, "sudo", "dnf", "install", "-y", "greetd")
|
|
||||||
}
|
|
||||||
|
|
||||||
case distros.FamilySUSE:
|
case distros.FamilySUSE:
|
||||||
if sudoPassword != "" {
|
installCmd = privesc.ExecCommand(ctx, sudoPassword, "zypper install -y greetd")
|
||||||
installCmd = distros.ExecSudoCommand(ctx, sudoPassword,
|
case distros.FamilyUbuntu, distros.FamilyDebian:
|
||||||
"zypper install -y greetd")
|
installCmd = privesc.ExecCommand(ctx, sudoPassword, "apt-get install -y greetd")
|
||||||
} else {
|
|
||||||
installCmd = exec.CommandContext(ctx, "sudo", "zypper", "install", "-y", "greetd")
|
|
||||||
}
|
|
||||||
|
|
||||||
case distros.FamilyUbuntu:
|
|
||||||
if sudoPassword != "" {
|
|
||||||
installCmd = distros.ExecSudoCommand(ctx, sudoPassword,
|
|
||||||
"apt-get install -y greetd")
|
|
||||||
} else {
|
|
||||||
installCmd = exec.CommandContext(ctx, "sudo", "apt-get", "install", "-y", "greetd")
|
|
||||||
}
|
|
||||||
|
|
||||||
case distros.FamilyDebian:
|
|
||||||
if sudoPassword != "" {
|
|
||||||
installCmd = distros.ExecSudoCommand(ctx, sudoPassword,
|
|
||||||
"apt-get install -y greetd")
|
|
||||||
} else {
|
|
||||||
installCmd = exec.CommandContext(ctx, "sudo", "apt-get", "install", "-y", "greetd")
|
|
||||||
}
|
|
||||||
|
|
||||||
case distros.FamilyGentoo:
|
case distros.FamilyGentoo:
|
||||||
if sudoPassword != "" {
|
installCmd = privesc.ExecCommand(ctx, sudoPassword, "emerge --ask n sys-apps/greetd")
|
||||||
installCmd = distros.ExecSudoCommand(ctx, sudoPassword,
|
|
||||||
"emerge --ask n sys-apps/greetd")
|
|
||||||
} else {
|
|
||||||
installCmd = exec.CommandContext(ctx, "sudo", "emerge", "--ask", "n", "sys-apps/greetd")
|
|
||||||
}
|
|
||||||
|
|
||||||
case distros.FamilyNix:
|
case distros.FamilyNix:
|
||||||
return fmt.Errorf("on NixOS, please add greetd to your configuration.nix")
|
return fmt.Errorf("on NixOS, please add greetd to your configuration.nix")
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported distribution family for automatic greetd installation: %s", config.Family)
|
return fmt.Errorf("unsupported distribution family for automatic greetd installation: %s", config.Family)
|
||||||
}
|
}
|
||||||
@@ -437,56 +399,56 @@ func TryInstallGreeterPackage(logFunc func(string), sudoPassword string) bool {
|
|||||||
logFunc(fmt.Sprintf("Adding DankLinux OBS repository (%s)...", obsSlug))
|
logFunc(fmt.Sprintf("Adding DankLinux OBS repository (%s)...", obsSlug))
|
||||||
if _, err := exec.LookPath("gpg"); err != nil {
|
if _, err := exec.LookPath("gpg"); err != nil {
|
||||||
logFunc("Installing gnupg for OBS repository key import...")
|
logFunc("Installing gnupg for OBS repository key import...")
|
||||||
installGPGCmd := exec.CommandContext(ctx, "sudo", "apt-get", "install", "-y", "gnupg")
|
installGPGCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get install -y gnupg")
|
||||||
installGPGCmd.Stdout = os.Stdout
|
installGPGCmd.Stdout = os.Stdout
|
||||||
installGPGCmd.Stderr = os.Stderr
|
installGPGCmd.Stderr = os.Stderr
|
||||||
if err := installGPGCmd.Run(); err != nil {
|
if err := installGPGCmd.Run(); err != nil {
|
||||||
logFunc(fmt.Sprintf("⚠ Failed to install gnupg: %v", err))
|
logFunc(fmt.Sprintf("⚠ Failed to install gnupg: %v", err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
mkdirCmd := exec.CommandContext(ctx, "sudo", "mkdir", "-p", "/etc/apt/keyrings")
|
mkdirCmd := privesc.ExecCommand(ctx, sudoPassword, "mkdir -p /etc/apt/keyrings")
|
||||||
mkdirCmd.Stdout = os.Stdout
|
mkdirCmd.Stdout = os.Stdout
|
||||||
mkdirCmd.Stderr = os.Stderr
|
mkdirCmd.Stderr = os.Stderr
|
||||||
mkdirCmd.Run()
|
mkdirCmd.Run()
|
||||||
addKeyCmd := exec.CommandContext(ctx, "bash", "-c",
|
addKeyCmd := privesc.ExecCommand(ctx, sudoPassword,
|
||||||
fmt.Sprintf(`curl -fsSL %s | sudo gpg --dearmor -o /etc/apt/keyrings/danklinux.gpg`, keyURL))
|
fmt.Sprintf(`bash -c "curl -fsSL %s | gpg --dearmor -o /etc/apt/keyrings/danklinux.gpg"`, keyURL))
|
||||||
addKeyCmd.Stdout = os.Stdout
|
addKeyCmd.Stdout = os.Stdout
|
||||||
addKeyCmd.Stderr = os.Stderr
|
addKeyCmd.Stderr = os.Stderr
|
||||||
addKeyCmd.Run()
|
addKeyCmd.Run()
|
||||||
addRepoCmd := exec.CommandContext(ctx, "bash", "-c",
|
addRepoCmd := privesc.ExecCommand(ctx, sudoPassword,
|
||||||
fmt.Sprintf(`echo '%s' | sudo tee /etc/apt/sources.list.d/danklinux.list`, repoLine))
|
fmt.Sprintf(`bash -c "echo '%s' > /etc/apt/sources.list.d/danklinux.list"`, repoLine))
|
||||||
addRepoCmd.Stdout = os.Stdout
|
addRepoCmd.Stdout = os.Stdout
|
||||||
addRepoCmd.Stderr = os.Stderr
|
addRepoCmd.Stderr = os.Stderr
|
||||||
addRepoCmd.Run()
|
addRepoCmd.Run()
|
||||||
exec.CommandContext(ctx, "sudo", "apt-get", "update").Run()
|
privesc.ExecCommand(ctx, sudoPassword, "apt-get update").Run()
|
||||||
installCmd = exec.CommandContext(ctx, "sudo", "apt-get", "install", "-y", "dms-greeter")
|
installCmd = privesc.ExecCommand(ctx, sudoPassword, "apt-get install -y dms-greeter")
|
||||||
case distros.FamilySUSE:
|
case distros.FamilySUSE:
|
||||||
repoURL := getOpenSUSEOBSRepoURL(osInfo)
|
repoURL := getOpenSUSEOBSRepoURL(osInfo)
|
||||||
failHint = fmt.Sprintf("⚠ dms-greeter install failed. Add OBS repo manually:\nsudo zypper addrepo %s\nsudo zypper refresh && sudo zypper install dms-greeter", repoURL)
|
failHint = fmt.Sprintf("⚠ dms-greeter install failed. Add OBS repo manually:\nsudo zypper addrepo %s\nsudo zypper refresh && sudo zypper install dms-greeter", repoURL)
|
||||||
logFunc("Adding DankLinux OBS repository...")
|
logFunc("Adding DankLinux OBS repository...")
|
||||||
addRepoCmd := exec.CommandContext(ctx, "sudo", "zypper", "addrepo", repoURL)
|
addRepoCmd := privesc.ExecCommand(ctx, sudoPassword, fmt.Sprintf("zypper addrepo %s", repoURL))
|
||||||
addRepoCmd.Stdout = os.Stdout
|
addRepoCmd.Stdout = os.Stdout
|
||||||
addRepoCmd.Stderr = os.Stderr
|
addRepoCmd.Stderr = os.Stderr
|
||||||
addRepoCmd.Run()
|
addRepoCmd.Run()
|
||||||
exec.CommandContext(ctx, "sudo", "zypper", "refresh").Run()
|
privesc.ExecCommand(ctx, sudoPassword, "zypper refresh").Run()
|
||||||
installCmd = exec.CommandContext(ctx, "sudo", "zypper", "install", "-y", "dms-greeter")
|
installCmd = privesc.ExecCommand(ctx, sudoPassword, "zypper install -y dms-greeter")
|
||||||
case distros.FamilyUbuntu:
|
case distros.FamilyUbuntu:
|
||||||
failHint = "⚠ dms-greeter install failed. Add PPA manually: sudo add-apt-repository ppa:avengemedia/danklinux && sudo apt-get update && sudo apt-get install -y dms-greeter"
|
failHint = "⚠ dms-greeter install failed. Add PPA manually: sudo add-apt-repository ppa:avengemedia/danklinux && sudo apt-get update && sudo apt-get install -y dms-greeter"
|
||||||
logFunc("Enabling PPA ppa:avengemedia/danklinux...")
|
logFunc("Enabling PPA ppa:avengemedia/danklinux...")
|
||||||
ppacmd := exec.CommandContext(ctx, "sudo", "add-apt-repository", "-y", "ppa:avengemedia/danklinux")
|
ppacmd := privesc.ExecCommand(ctx, sudoPassword, "add-apt-repository -y ppa:avengemedia/danklinux")
|
||||||
ppacmd.Stdout = os.Stdout
|
ppacmd.Stdout = os.Stdout
|
||||||
ppacmd.Stderr = os.Stderr
|
ppacmd.Stderr = os.Stderr
|
||||||
ppacmd.Run()
|
ppacmd.Run()
|
||||||
exec.CommandContext(ctx, "sudo", "apt-get", "update").Run()
|
privesc.ExecCommand(ctx, sudoPassword, "apt-get update").Run()
|
||||||
installCmd = exec.CommandContext(ctx, "sudo", "apt-get", "install", "-y", "dms-greeter")
|
installCmd = privesc.ExecCommand(ctx, sudoPassword, "apt-get install -y dms-greeter")
|
||||||
case distros.FamilyFedora:
|
case distros.FamilyFedora:
|
||||||
failHint = "⚠ dms-greeter install failed. Enable COPR manually: sudo dnf copr enable avengemedia/danklinux && sudo dnf install dms-greeter"
|
failHint = "⚠ dms-greeter install failed. Enable COPR manually: sudo dnf copr enable avengemedia/danklinux && sudo dnf install dms-greeter"
|
||||||
logFunc("Enabling COPR avengemedia/danklinux...")
|
logFunc("Enabling COPR avengemedia/danklinux...")
|
||||||
coprcmd := exec.CommandContext(ctx, "sudo", "dnf", "copr", "enable", "-y", "avengemedia/danklinux")
|
coprcmd := privesc.ExecCommand(ctx, sudoPassword, "dnf copr enable -y avengemedia/danklinux")
|
||||||
coprcmd.Stdout = os.Stdout
|
coprcmd.Stdout = os.Stdout
|
||||||
coprcmd.Stderr = os.Stderr
|
coprcmd.Stderr = os.Stderr
|
||||||
coprcmd.Run()
|
coprcmd.Run()
|
||||||
installCmd = exec.CommandContext(ctx, "sudo", "dnf", "install", "-y", "dms-greeter")
|
installCmd = privesc.ExecCommand(ctx, sudoPassword, "dnf install -y dms-greeter")
|
||||||
case distros.FamilyArch:
|
case distros.FamilyArch:
|
||||||
aurHelper := ""
|
aurHelper := ""
|
||||||
for _, helper := range []string{"paru", "yay"} {
|
for _, helper := range []string{"paru", "yay"} {
|
||||||
@@ -539,25 +501,25 @@ func CopyGreeterFiles(dmsPath, compositor string, logFunc func(string), sudoPass
|
|||||||
if info, err := os.Stat(wrapperDst); err == nil && !info.IsDir() {
|
if info, err := os.Stat(wrapperDst); err == nil && !info.IsDir() {
|
||||||
action = "Updated"
|
action = "Updated"
|
||||||
}
|
}
|
||||||
if err := runSudoCmd(sudoPassword, "cp", wrapperSrc, wrapperDst); err != nil {
|
if err := privesc.Run(context.Background(), sudoPassword, "cp", wrapperSrc, wrapperDst); err != nil {
|
||||||
return fmt.Errorf("failed to copy dms-greeter wrapper: %w", err)
|
return fmt.Errorf("failed to copy dms-greeter wrapper: %w", err)
|
||||||
}
|
}
|
||||||
logFunc(fmt.Sprintf("✓ %s dms-greeter wrapper at %s", action, wrapperDst))
|
logFunc(fmt.Sprintf("✓ %s dms-greeter wrapper at %s", action, wrapperDst))
|
||||||
|
|
||||||
if err := runSudoCmd(sudoPassword, "chmod", "+x", wrapperDst); err != nil {
|
if err := privesc.Run(context.Background(), sudoPassword, "chmod", "+x", wrapperDst); err != nil {
|
||||||
return fmt.Errorf("failed to make wrapper executable: %w", err)
|
return fmt.Errorf("failed to make wrapper executable: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
osInfo, err := distros.GetOSInfo()
|
osInfo, err := distros.GetOSInfo()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
if config, exists := distros.Registry[osInfo.Distribution.ID]; exists && (config.Family == distros.FamilyFedora || config.Family == distros.FamilySUSE) {
|
if config, exists := distros.Registry[osInfo.Distribution.ID]; exists && (config.Family == distros.FamilyFedora || config.Family == distros.FamilySUSE) {
|
||||||
if err := runSudoCmd(sudoPassword, "semanage", "fcontext", "-a", "-t", "bin_t", wrapperDst); err != nil {
|
if err := privesc.Run(context.Background(), sudoPassword, "semanage", "fcontext", "-a", "-t", "bin_t", wrapperDst); err != nil {
|
||||||
logFunc(fmt.Sprintf("⚠ Warning: Failed to set SELinux fcontext: %v", err))
|
logFunc(fmt.Sprintf("⚠ Warning: Failed to set SELinux fcontext: %v", err))
|
||||||
} else {
|
} else {
|
||||||
logFunc("✓ Set SELinux fcontext for dms-greeter")
|
logFunc("✓ Set SELinux fcontext for dms-greeter")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := runSudoCmd(sudoPassword, "restorecon", "-v", wrapperDst); err != nil {
|
if err := privesc.Run(context.Background(), sudoPassword, "restorecon", "-v", wrapperDst); err != nil {
|
||||||
logFunc(fmt.Sprintf("⚠ Warning: Failed to restore SELinux context: %v", err))
|
logFunc(fmt.Sprintf("⚠ Warning: Failed to restore SELinux context: %v", err))
|
||||||
} else {
|
} else {
|
||||||
logFunc("✓ Restored SELinux context for dms-greeter")
|
logFunc("✓ Restored SELinux context for dms-greeter")
|
||||||
@@ -583,7 +545,7 @@ func EnsureGreeterCacheDir(logFunc func(string), sudoPassword string) error {
|
|||||||
if !os.IsNotExist(err) {
|
if !os.IsNotExist(err) {
|
||||||
return fmt.Errorf("failed to stat cache directory: %w", err)
|
return fmt.Errorf("failed to stat cache directory: %w", err)
|
||||||
}
|
}
|
||||||
if err := runSudoCmd(sudoPassword, "mkdir", "-p", cacheDir); err != nil {
|
if err := privesc.Run(context.Background(), sudoPassword, "mkdir", "-p", cacheDir); err != nil {
|
||||||
return fmt.Errorf("failed to create cache directory: %w", err)
|
return fmt.Errorf("failed to create cache directory: %w", err)
|
||||||
}
|
}
|
||||||
created = true
|
created = true
|
||||||
@@ -595,17 +557,17 @@ func EnsureGreeterCacheDir(logFunc func(string), sudoPassword string) error {
|
|||||||
daemonUser := DetectGreeterUser()
|
daemonUser := DetectGreeterUser()
|
||||||
preferredOwner := fmt.Sprintf("%s:%s", daemonUser, group)
|
preferredOwner := fmt.Sprintf("%s:%s", daemonUser, group)
|
||||||
owner := preferredOwner
|
owner := preferredOwner
|
||||||
if err := runSudoCmd(sudoPassword, "chown", owner, cacheDir); err != nil {
|
if err := privesc.Run(context.Background(), sudoPassword, "chown", owner, cacheDir); err != nil {
|
||||||
// Some setups may not have a matching daemon user at this moment; fall back
|
// Some setups may not have a matching daemon user at this moment; fall back
|
||||||
// to root:<group> while still allowing group-writable greeter runtime access.
|
// to root:<group> while still allowing group-writable greeter runtime access.
|
||||||
fallbackOwner := fmt.Sprintf("root:%s", group)
|
fallbackOwner := fmt.Sprintf("root:%s", group)
|
||||||
if fallbackErr := runSudoCmd(sudoPassword, "chown", fallbackOwner, cacheDir); fallbackErr != nil {
|
if fallbackErr := privesc.Run(context.Background(), sudoPassword, "chown", fallbackOwner, cacheDir); fallbackErr != nil {
|
||||||
return fmt.Errorf("failed to set cache directory owner (preferred %s: %v; fallback %s: %w)", preferredOwner, err, fallbackOwner, fallbackErr)
|
return fmt.Errorf("failed to set cache directory owner (preferred %s: %v; fallback %s: %w)", preferredOwner, err, fallbackOwner, fallbackErr)
|
||||||
}
|
}
|
||||||
owner = fallbackOwner
|
owner = fallbackOwner
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := runSudoCmd(sudoPassword, "chmod", "2770", cacheDir); err != nil {
|
if err := privesc.Run(context.Background(), sudoPassword, "chmod", "2770", cacheDir); err != nil {
|
||||||
return fmt.Errorf("failed to set cache directory permissions: %w", err)
|
return fmt.Errorf("failed to set cache directory permissions: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -616,13 +578,13 @@ func EnsureGreeterCacheDir(logFunc func(string), sudoPassword string) error {
|
|||||||
filepath.Join(cacheDir, ".cache"),
|
filepath.Join(cacheDir, ".cache"),
|
||||||
}
|
}
|
||||||
for _, dir := range runtimeDirs {
|
for _, dir := range runtimeDirs {
|
||||||
if err := runSudoCmd(sudoPassword, "mkdir", "-p", dir); err != nil {
|
if err := privesc.Run(context.Background(), sudoPassword, "mkdir", "-p", dir); err != nil {
|
||||||
return fmt.Errorf("failed to create cache runtime directory %s: %w", dir, err)
|
return fmt.Errorf("failed to create cache runtime directory %s: %w", dir, err)
|
||||||
}
|
}
|
||||||
if err := runSudoCmd(sudoPassword, "chown", owner, dir); err != nil {
|
if err := privesc.Run(context.Background(), sudoPassword, "chown", owner, dir); err != nil {
|
||||||
return fmt.Errorf("failed to set owner for cache runtime directory %s: %w", dir, err)
|
return fmt.Errorf("failed to set owner for cache runtime directory %s: %w", dir, err)
|
||||||
}
|
}
|
||||||
if err := runSudoCmd(sudoPassword, "chmod", "2770", dir); err != nil {
|
if err := privesc.Run(context.Background(), sudoPassword, "chmod", "2770", dir); err != nil {
|
||||||
return fmt.Errorf("failed to set permissions for cache runtime directory %s: %w", dir, err)
|
return fmt.Errorf("failed to set permissions for cache runtime directory %s: %w", dir, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -634,7 +596,7 @@ func EnsureGreeterCacheDir(logFunc func(string), sudoPassword string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if isSELinuxEnforcing() && utils.CommandExists("restorecon") {
|
if isSELinuxEnforcing() && utils.CommandExists("restorecon") {
|
||||||
if err := runSudoCmd(sudoPassword, "restorecon", "-Rv", cacheDir); err != nil {
|
if err := privesc.Run(context.Background(), sudoPassword, "restorecon", "-Rv", cacheDir); err != nil {
|
||||||
logFunc(fmt.Sprintf("⚠ Warning: Failed to restore SELinux context for %s: %v", cacheDir, err))
|
logFunc(fmt.Sprintf("⚠ Warning: Failed to restore SELinux context for %s: %v", cacheDir, err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -659,13 +621,13 @@ func ensureGreeterMemoryCompatLink(logFunc func(string), sudoPassword, legacyPat
|
|||||||
info, err := os.Lstat(legacyPath)
|
info, err := os.Lstat(legacyPath)
|
||||||
if err == nil && info.Mode().IsRegular() {
|
if err == nil && info.Mode().IsRegular() {
|
||||||
if _, stateErr := os.Stat(statePath); os.IsNotExist(stateErr) {
|
if _, stateErr := os.Stat(statePath); os.IsNotExist(stateErr) {
|
||||||
if copyErr := runSudoCmd(sudoPassword, "cp", "-f", legacyPath, statePath); copyErr != nil {
|
if copyErr := privesc.Run(context.Background(), sudoPassword, "cp", "-f", legacyPath, statePath); copyErr != nil {
|
||||||
logFunc(fmt.Sprintf("⚠ Warning: Failed to migrate legacy greeter memory file to %s: %v", statePath, copyErr))
|
logFunc(fmt.Sprintf("⚠ Warning: Failed to migrate legacy greeter memory file to %s: %v", statePath, copyErr))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := runSudoCmd(sudoPassword, "ln", "-sfn", statePath, legacyPath); err != nil {
|
if err := privesc.Run(context.Background(), sudoPassword, "ln", "-sfn", statePath, legacyPath); err != nil {
|
||||||
return fmt.Errorf("failed to create greeter memory compatibility symlink %s -> %s: %w", legacyPath, statePath, err)
|
return fmt.Errorf("failed to create greeter memory compatibility symlink %s -> %s: %w", legacyPath, statePath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -692,7 +654,7 @@ func InstallAppArmorProfile(logFunc func(string), sudoPassword string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := runSudoCmd(sudoPassword, "mkdir", "-p", "/etc/apparmor.d"); err != nil {
|
if err := privesc.Run(context.Background(), sudoPassword, "mkdir", "-p", "/etc/apparmor.d"); err != nil {
|
||||||
return fmt.Errorf("failed to create /etc/apparmor.d: %w", err)
|
return fmt.Errorf("failed to create /etc/apparmor.d: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -709,15 +671,15 @@ func InstallAppArmorProfile(logFunc func(string), sudoPassword string) error {
|
|||||||
}
|
}
|
||||||
tmp.Close()
|
tmp.Close()
|
||||||
|
|
||||||
if err := runSudoCmd(sudoPassword, "cp", tmpPath, appArmorProfileDest); err != nil {
|
if err := privesc.Run(context.Background(), sudoPassword, "cp", tmpPath, appArmorProfileDest); err != nil {
|
||||||
return fmt.Errorf("failed to install AppArmor profile to %s: %w", appArmorProfileDest, err)
|
return fmt.Errorf("failed to install AppArmor profile to %s: %w", appArmorProfileDest, err)
|
||||||
}
|
}
|
||||||
if err := runSudoCmd(sudoPassword, "chmod", "644", appArmorProfileDest); err != nil {
|
if err := privesc.Run(context.Background(), sudoPassword, "chmod", "644", appArmorProfileDest); err != nil {
|
||||||
return fmt.Errorf("failed to set AppArmor profile permissions: %w", err)
|
return fmt.Errorf("failed to set AppArmor profile permissions: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if utils.CommandExists("apparmor_parser") {
|
if utils.CommandExists("apparmor_parser") {
|
||||||
if err := runSudoCmd(sudoPassword, "apparmor_parser", "-r", appArmorProfileDest); err != nil {
|
if err := privesc.Run(context.Background(), sudoPassword, "apparmor_parser", "-r", appArmorProfileDest); err != nil {
|
||||||
logFunc(fmt.Sprintf(" ⚠ AppArmor profile installed but reload failed: %v", err))
|
logFunc(fmt.Sprintf(" ⚠ AppArmor profile installed but reload failed: %v", err))
|
||||||
logFunc(" Run: sudo apparmor_parser -r " + appArmorProfileDest)
|
logFunc(" Run: sudo apparmor_parser -r " + appArmorProfileDest)
|
||||||
} else {
|
} else {
|
||||||
@@ -745,9 +707,9 @@ func UninstallAppArmorProfile(logFunc func(string), sudoPassword string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if utils.CommandExists("apparmor_parser") {
|
if utils.CommandExists("apparmor_parser") {
|
||||||
_ = runSudoCmd(sudoPassword, "apparmor_parser", "--remove", appArmorProfileDest)
|
_ = privesc.Run(context.Background(), sudoPassword, "apparmor_parser", "--remove", appArmorProfileDest)
|
||||||
}
|
}
|
||||||
if err := runSudoCmd(sudoPassword, "rm", "-f", appArmorProfileDest); err != nil {
|
if err := privesc.Run(context.Background(), sudoPassword, "rm", "-f", appArmorProfileDest); err != nil {
|
||||||
return fmt.Errorf("failed to remove AppArmor profile: %w", err)
|
return fmt.Errorf("failed to remove AppArmor profile: %w", err)
|
||||||
}
|
}
|
||||||
logFunc(" ✓ Removed DMS AppArmor profile")
|
logFunc(" ✓ Removed DMS AppArmor profile")
|
||||||
@@ -777,50 +739,17 @@ func EnsureACLInstalled(logFunc func(string), sudoPassword string) error {
|
|||||||
|
|
||||||
switch config.Family {
|
switch config.Family {
|
||||||
case distros.FamilyArch:
|
case distros.FamilyArch:
|
||||||
if sudoPassword != "" {
|
installCmd = privesc.ExecCommand(ctx, sudoPassword, "pacman -S --needed --noconfirm acl")
|
||||||
installCmd = distros.ExecSudoCommand(ctx, sudoPassword, "pacman -S --needed --noconfirm acl")
|
|
||||||
} else {
|
|
||||||
installCmd = exec.CommandContext(ctx, "sudo", "pacman", "-S", "--needed", "--noconfirm", "acl")
|
|
||||||
}
|
|
||||||
|
|
||||||
case distros.FamilyFedora:
|
case distros.FamilyFedora:
|
||||||
if sudoPassword != "" {
|
installCmd = privesc.ExecCommand(ctx, sudoPassword, "dnf install -y acl")
|
||||||
installCmd = distros.ExecSudoCommand(ctx, sudoPassword, "dnf install -y acl")
|
|
||||||
} else {
|
|
||||||
installCmd = exec.CommandContext(ctx, "sudo", "dnf", "install", "-y", "acl")
|
|
||||||
}
|
|
||||||
|
|
||||||
case distros.FamilySUSE:
|
case distros.FamilySUSE:
|
||||||
if sudoPassword != "" {
|
installCmd = privesc.ExecCommand(ctx, sudoPassword, "zypper install -y acl")
|
||||||
installCmd = distros.ExecSudoCommand(ctx, sudoPassword, "zypper install -y acl")
|
case distros.FamilyUbuntu, distros.FamilyDebian:
|
||||||
} else {
|
installCmd = privesc.ExecCommand(ctx, sudoPassword, "apt-get install -y acl")
|
||||||
installCmd = exec.CommandContext(ctx, "sudo", "zypper", "install", "-y", "acl")
|
|
||||||
}
|
|
||||||
|
|
||||||
case distros.FamilyUbuntu:
|
|
||||||
if sudoPassword != "" {
|
|
||||||
installCmd = distros.ExecSudoCommand(ctx, sudoPassword, "apt-get install -y acl")
|
|
||||||
} else {
|
|
||||||
installCmd = exec.CommandContext(ctx, "sudo", "apt-get", "install", "-y", "acl")
|
|
||||||
}
|
|
||||||
|
|
||||||
case distros.FamilyDebian:
|
|
||||||
if sudoPassword != "" {
|
|
||||||
installCmd = distros.ExecSudoCommand(ctx, sudoPassword, "apt-get install -y acl")
|
|
||||||
} else {
|
|
||||||
installCmd = exec.CommandContext(ctx, "sudo", "apt-get", "install", "-y", "acl")
|
|
||||||
}
|
|
||||||
|
|
||||||
case distros.FamilyGentoo:
|
case distros.FamilyGentoo:
|
||||||
if sudoPassword != "" {
|
installCmd = privesc.ExecCommand(ctx, sudoPassword, "emerge --ask n sys-fs/acl")
|
||||||
installCmd = distros.ExecSudoCommand(ctx, sudoPassword, "emerge --ask n sys-fs/acl")
|
|
||||||
} else {
|
|
||||||
installCmd = exec.CommandContext(ctx, "sudo", "emerge", "--ask", "n", "sys-fs/acl")
|
|
||||||
}
|
|
||||||
|
|
||||||
case distros.FamilyNix:
|
case distros.FamilyNix:
|
||||||
return fmt.Errorf("on NixOS, please add pkgs.acl to your configuration.nix")
|
return fmt.Errorf("on NixOS, please add pkgs.acl to your configuration.nix")
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported distribution family for automatic acl installation: %s", config.Family)
|
return fmt.Errorf("unsupported distribution family for automatic acl installation: %s", config.Family)
|
||||||
}
|
}
|
||||||
@@ -877,7 +806,7 @@ func SetupParentDirectoryACLs(logFunc func(string), sudoPassword string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Group ACL covers daemon users regardless of username (e.g. greetd ≠ greeter on Fedora).
|
// Group ACL covers daemon users regardless of username (e.g. greetd ≠ greeter on Fedora).
|
||||||
if err := runSudoCmd(sudoPassword, "setfacl", "-m", fmt.Sprintf("g:%s:rX", group), dir.path); err != nil {
|
if err := privesc.Run(context.Background(), sudoPassword, "setfacl", "-m", fmt.Sprintf("g:%s:rX", group), dir.path); err != nil {
|
||||||
logFunc(fmt.Sprintf("⚠ Warning: Failed to set ACL on %s: %v", dir.desc, err))
|
logFunc(fmt.Sprintf("⚠ Warning: Failed to set ACL on %s: %v", dir.desc, err))
|
||||||
logFunc(fmt.Sprintf(" You may need to run manually: setfacl -m g:%s:rX %s", group, dir.path))
|
logFunc(fmt.Sprintf(" You may need to run manually: setfacl -m g:%s:rX %s", group, dir.path))
|
||||||
continue
|
continue
|
||||||
@@ -934,7 +863,7 @@ func RemediateStaleACLs(logFunc func(string), sudoPassword string) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, user := range existingUsers {
|
for _, user := range existingUsers {
|
||||||
_ = runSudoCmd(sudoPassword, "setfacl", "-x", fmt.Sprintf("u:%s", user), dir)
|
_ = privesc.Run(context.Background(), sudoPassword, "setfacl", "-x", fmt.Sprintf("u:%s", user), dir)
|
||||||
cleaned = true
|
cleaned = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -974,7 +903,7 @@ func SetupDMSGroup(logFunc func(string), sudoPassword string) error {
|
|||||||
|
|
||||||
// Create the group if it doesn't exist yet (e.g. before greetd package is installed).
|
// Create the group if it doesn't exist yet (e.g. before greetd package is installed).
|
||||||
if !utils.HasGroup(group) {
|
if !utils.HasGroup(group) {
|
||||||
if err := runSudoCmd(sudoPassword, "groupadd", "-r", group); err != nil {
|
if err := privesc.Run(context.Background(), sudoPassword, "groupadd", "-r", group); err != nil {
|
||||||
return fmt.Errorf("failed to create %s group: %w", group, err)
|
return fmt.Errorf("failed to create %s group: %w", group, err)
|
||||||
}
|
}
|
||||||
logFunc(fmt.Sprintf("✓ Created system group %s", group))
|
logFunc(fmt.Sprintf("✓ Created system group %s", group))
|
||||||
@@ -985,7 +914,7 @@ func SetupDMSGroup(logFunc func(string), sudoPassword string) error {
|
|||||||
if err == nil && strings.Contains(string(groupsOutput), group) {
|
if err == nil && strings.Contains(string(groupsOutput), group) {
|
||||||
logFunc(fmt.Sprintf("✓ %s is already in %s group", currentUser, group))
|
logFunc(fmt.Sprintf("✓ %s is already in %s group", currentUser, group))
|
||||||
} else {
|
} else {
|
||||||
if err := runSudoCmd(sudoPassword, "usermod", "-aG", group, currentUser); err != nil {
|
if err := privesc.Run(context.Background(), sudoPassword, "usermod", "-aG", group, currentUser); err != nil {
|
||||||
return fmt.Errorf("failed to add %s to %s group: %w", currentUser, group, err)
|
return fmt.Errorf("failed to add %s to %s group: %w", currentUser, group, err)
|
||||||
}
|
}
|
||||||
logFunc(fmt.Sprintf("✓ Added %s to %s group (logout/login required for changes to take effect)", currentUser, group))
|
logFunc(fmt.Sprintf("✓ Added %s to %s group (logout/login required for changes to take effect)", currentUser, group))
|
||||||
@@ -1000,7 +929,7 @@ func SetupDMSGroup(logFunc func(string), sudoPassword string) error {
|
|||||||
if strings.Contains(string(daemonGroupsOutput), group) {
|
if strings.Contains(string(daemonGroupsOutput), group) {
|
||||||
logFunc(fmt.Sprintf("✓ Greeter daemon user %s is already in %s group", daemonUser, group))
|
logFunc(fmt.Sprintf("✓ Greeter daemon user %s is already in %s group", daemonUser, group))
|
||||||
} else {
|
} else {
|
||||||
if err := runSudoCmd(sudoPassword, "usermod", "-aG", group, daemonUser); err != nil {
|
if err := privesc.Run(context.Background(), sudoPassword, "usermod", "-aG", group, daemonUser); err != nil {
|
||||||
logFunc(fmt.Sprintf("⚠ Warning: could not add %s to %s group: %v", daemonUser, group, err))
|
logFunc(fmt.Sprintf("⚠ Warning: could not add %s to %s group: %v", daemonUser, group, err))
|
||||||
} else {
|
} else {
|
||||||
logFunc(fmt.Sprintf("✓ Added greeter daemon user %s to %s group", daemonUser, group))
|
logFunc(fmt.Sprintf("✓ Added greeter daemon user %s to %s group", daemonUser, group))
|
||||||
@@ -1030,12 +959,12 @@ func SetupDMSGroup(logFunc func(string), sudoPassword string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := runSudoCmd(sudoPassword, "chgrp", "-R", group, dir.path); err != nil {
|
if err := privesc.Run(context.Background(), sudoPassword, "chgrp", "-R", group, dir.path); err != nil {
|
||||||
logFunc(fmt.Sprintf("⚠ Warning: Failed to set group for %s: %v", dir.desc, err))
|
logFunc(fmt.Sprintf("⚠ Warning: Failed to set group for %s: %v", dir.desc, err))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := runSudoCmd(sudoPassword, "chmod", "-R", "g+rX", dir.path); err != nil {
|
if err := privesc.Run(context.Background(), sudoPassword, "chmod", "-R", "g+rX", dir.path); err != nil {
|
||||||
logFunc(fmt.Sprintf("⚠ Warning: Failed to set permissions for %s: %v", dir.desc, err))
|
logFunc(fmt.Sprintf("⚠ Warning: Failed to set permissions for %s: %v", dir.desc, err))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -1247,8 +1176,8 @@ func syncGreeterColorSource(homeDir, cacheDir string, state greeterThemeSyncStat
|
|||||||
}
|
}
|
||||||
|
|
||||||
target := filepath.Join(cacheDir, "colors.json")
|
target := filepath.Join(cacheDir, "colors.json")
|
||||||
_ = runSudoCmd(sudoPassword, "rm", "-f", target)
|
_ = privesc.Run(context.Background(), sudoPassword, "rm", "-f", target)
|
||||||
if err := runSudoCmd(sudoPassword, "ln", "-sf", source, target); err != nil {
|
if err := privesc.Run(context.Background(), sudoPassword, "ln", "-sf", source, target); err != nil {
|
||||||
return fmt.Errorf("failed to create symlink for wallpaper based theming (%s -> %s): %w", target, source, err)
|
return fmt.Errorf("failed to create symlink for wallpaper based theming (%s -> %s): %w", target, source, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1300,9 +1229,9 @@ func SyncDMSConfigs(dmsPath, compositor string, logFunc func(string), sudoPasswo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = runSudoCmd(sudoPassword, "rm", "-f", link.target)
|
_ = privesc.Run(context.Background(), sudoPassword, "rm", "-f", link.target)
|
||||||
|
|
||||||
if err := runSudoCmd(sudoPassword, "ln", "-sf", link.source, link.target); err != nil {
|
if err := privesc.Run(context.Background(), sudoPassword, "ln", "-sf", link.source, link.target); err != nil {
|
||||||
return fmt.Errorf("failed to create symlink for %s (%s -> %s): %w", link.desc, link.target, link.source, err)
|
return fmt.Errorf("failed to create symlink for %s (%s -> %s): %w", link.desc, link.target, link.source, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1340,13 +1269,13 @@ func SyncDMSConfigs(dmsPath, compositor string, logFunc func(string), sudoPasswo
|
|||||||
func syncGreeterWallpaperOverride(cacheDir string, logFunc func(string), sudoPassword string, state greeterThemeSyncState) error {
|
func syncGreeterWallpaperOverride(cacheDir string, logFunc func(string), sudoPassword string, state greeterThemeSyncState) error {
|
||||||
destPath := filepath.Join(cacheDir, "greeter_wallpaper_override.jpg")
|
destPath := filepath.Join(cacheDir, "greeter_wallpaper_override.jpg")
|
||||||
if state.ResolvedGreeterWallpaperPath == "" {
|
if state.ResolvedGreeterWallpaperPath == "" {
|
||||||
if err := runSudoCmd(sudoPassword, "rm", "-f", destPath); err != nil {
|
if err := privesc.Run(context.Background(), sudoPassword, "rm", "-f", destPath); err != nil {
|
||||||
return fmt.Errorf("failed to clear override file %s: %w", destPath, err)
|
return fmt.Errorf("failed to clear override file %s: %w", destPath, err)
|
||||||
}
|
}
|
||||||
logFunc("✓ Cleared greeter wallpaper override")
|
logFunc("✓ Cleared greeter wallpaper override")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if err := runSudoCmd(sudoPassword, "rm", "-f", destPath); err != nil {
|
if err := privesc.Run(context.Background(), sudoPassword, "rm", "-f", destPath); err != nil {
|
||||||
return fmt.Errorf("failed to remove old override file %s: %w", destPath, err)
|
return fmt.Errorf("failed to remove old override file %s: %w", destPath, err)
|
||||||
}
|
}
|
||||||
src := state.ResolvedGreeterWallpaperPath
|
src := state.ResolvedGreeterWallpaperPath
|
||||||
@@ -1357,17 +1286,17 @@ func syncGreeterWallpaperOverride(cacheDir string, logFunc func(string), sudoPas
|
|||||||
if st.IsDir() {
|
if st.IsDir() {
|
||||||
return fmt.Errorf("configured greeter wallpaper path points to a directory: %s", src)
|
return fmt.Errorf("configured greeter wallpaper path points to a directory: %s", src)
|
||||||
}
|
}
|
||||||
if err := runSudoCmd(sudoPassword, "cp", src, destPath); err != nil {
|
if err := privesc.Run(context.Background(), sudoPassword, "cp", src, destPath); err != nil {
|
||||||
return fmt.Errorf("failed to copy override wallpaper to %s: %w", destPath, err)
|
return fmt.Errorf("failed to copy override wallpaper to %s: %w", destPath, err)
|
||||||
}
|
}
|
||||||
greeterGroup := DetectGreeterGroup()
|
greeterGroup := DetectGreeterGroup()
|
||||||
daemonUser := DetectGreeterUser()
|
daemonUser := DetectGreeterUser()
|
||||||
if err := runSudoCmd(sudoPassword, "chown", daemonUser+":"+greeterGroup, destPath); err != nil {
|
if err := privesc.Run(context.Background(), sudoPassword, "chown", daemonUser+":"+greeterGroup, destPath); err != nil {
|
||||||
if fallbackErr := runSudoCmd(sudoPassword, "chown", "root:"+greeterGroup, destPath); fallbackErr != nil {
|
if fallbackErr := privesc.Run(context.Background(), sudoPassword, "chown", "root:"+greeterGroup, destPath); fallbackErr != nil {
|
||||||
return fmt.Errorf("failed to set override ownership on %s: %w", destPath, err)
|
return fmt.Errorf("failed to set override ownership on %s: %w", destPath, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := runSudoCmd(sudoPassword, "chmod", "644", destPath); err != nil {
|
if err := privesc.Run(context.Background(), sudoPassword, "chmod", "644", destPath); err != nil {
|
||||||
return fmt.Errorf("failed to set override permissions on %s: %w", destPath, err)
|
return fmt.Errorf("failed to set override permissions on %s: %w", destPath, err)
|
||||||
}
|
}
|
||||||
logFunc("✓ Synced greeter wallpaper override")
|
logFunc("✓ Synced greeter wallpaper override")
|
||||||
@@ -1422,13 +1351,13 @@ func syncNiriGreeterConfig(logFunc func(string), sudoPassword string) error {
|
|||||||
|
|
||||||
greeterDir := "/etc/greetd/niri"
|
greeterDir := "/etc/greetd/niri"
|
||||||
greeterGroup := DetectGreeterGroup()
|
greeterGroup := DetectGreeterGroup()
|
||||||
if err := runSudoCmd(sudoPassword, "mkdir", "-p", greeterDir); err != nil {
|
if err := privesc.Run(context.Background(), sudoPassword, "mkdir", "-p", greeterDir); err != nil {
|
||||||
return fmt.Errorf("failed to create greetd niri directory: %w", err)
|
return fmt.Errorf("failed to create greetd niri directory: %w", err)
|
||||||
}
|
}
|
||||||
if err := runSudoCmd(sudoPassword, "chown", fmt.Sprintf("root:%s", greeterGroup), greeterDir); err != nil {
|
if err := privesc.Run(context.Background(), sudoPassword, "chown", fmt.Sprintf("root:%s", greeterGroup), greeterDir); err != nil {
|
||||||
return fmt.Errorf("failed to set greetd niri directory ownership: %w", err)
|
return fmt.Errorf("failed to set greetd niri directory ownership: %w", err)
|
||||||
}
|
}
|
||||||
if err := runSudoCmd(sudoPassword, "chmod", "755", greeterDir); err != nil {
|
if err := privesc.Run(context.Background(), sudoPassword, "chmod", "755", greeterDir); err != nil {
|
||||||
return fmt.Errorf("failed to set greetd niri directory permissions: %w", err)
|
return fmt.Errorf("failed to set greetd niri directory permissions: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1450,7 +1379,7 @@ func syncNiriGreeterConfig(logFunc func(string), sudoPassword string) error {
|
|||||||
if err := backupFileIfExists(sudoPassword, dmsPath, ".backup"); err != nil {
|
if err := backupFileIfExists(sudoPassword, dmsPath, ".backup"); err != nil {
|
||||||
return fmt.Errorf("failed to backup %s: %w", dmsPath, err)
|
return fmt.Errorf("failed to backup %s: %w", dmsPath, err)
|
||||||
}
|
}
|
||||||
if err := runSudoCmd(sudoPassword, "install", "-o", "root", "-g", greeterGroup, "-m", "0644", dmsTemp.Name(), dmsPath); err != nil {
|
if err := privesc.Run(context.Background(), sudoPassword, "install", "-o", "root", "-g", greeterGroup, "-m", "0644", dmsTemp.Name(), dmsPath); err != nil {
|
||||||
return fmt.Errorf("failed to install greetd niri dms config: %w", err)
|
return fmt.Errorf("failed to install greetd niri dms config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1473,7 +1402,7 @@ func syncNiriGreeterConfig(logFunc func(string), sudoPassword string) error {
|
|||||||
if err := backupFileIfExists(sudoPassword, mainPath, ".backup"); err != nil {
|
if err := backupFileIfExists(sudoPassword, mainPath, ".backup"); err != nil {
|
||||||
return fmt.Errorf("failed to backup %s: %w", mainPath, err)
|
return fmt.Errorf("failed to backup %s: %w", mainPath, err)
|
||||||
}
|
}
|
||||||
if err := runSudoCmd(sudoPassword, "install", "-o", "root", "-g", greeterGroup, "-m", "0644", mainTemp.Name(), mainPath); err != nil {
|
if err := privesc.Run(context.Background(), sudoPassword, "install", "-o", "root", "-g", greeterGroup, "-m", "0644", mainTemp.Name(), mainPath); err != nil {
|
||||||
return fmt.Errorf("failed to install greetd niri main config: %w", err)
|
return fmt.Errorf("failed to install greetd niri main config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1549,7 +1478,7 @@ func ensureGreetdNiriConfig(logFunc func(string), sudoPassword string, niriConfi
|
|||||||
return fmt.Errorf("failed to close temp greetd config: %w", err)
|
return fmt.Errorf("failed to close temp greetd config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := runSudoCmd(sudoPassword, "mv", tmpFile.Name(), configPath); err != nil {
|
if err := privesc.Run(context.Background(), sudoPassword, "mv", tmpFile.Name(), configPath); err != nil {
|
||||||
return fmt.Errorf("failed to update greetd config: %w", err)
|
return fmt.Errorf("failed to update greetd config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1565,10 +1494,10 @@ func backupFileIfExists(sudoPassword string, path string, suffix string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
backupPath := fmt.Sprintf("%s%s-%s", path, suffix, time.Now().Format("20060102-150405"))
|
backupPath := fmt.Sprintf("%s%s-%s", path, suffix, time.Now().Format("20060102-150405"))
|
||||||
if err := runSudoCmd(sudoPassword, "cp", path, backupPath); err != nil {
|
if err := privesc.Run(context.Background(), sudoPassword, "cp", path, backupPath); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return runSudoCmd(sudoPassword, "chmod", "644", backupPath)
|
return privesc.Run(context.Background(), sudoPassword, "chmod", "644", backupPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *niriGreeterSync) processFile(filePath string) error {
|
func (s *niriGreeterSync) processFile(filePath string) error {
|
||||||
@@ -1804,11 +1733,11 @@ vt = 1
|
|||||||
return fmt.Errorf("failed to close temp greetd config: %w", err)
|
return fmt.Errorf("failed to close temp greetd config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := runSudoCmd(sudoPassword, "mkdir", "-p", "/etc/greetd"); err != nil {
|
if err := privesc.Run(context.Background(), sudoPassword, "mkdir", "-p", "/etc/greetd"); err != nil {
|
||||||
return fmt.Errorf("failed to create /etc/greetd: %w", err)
|
return fmt.Errorf("failed to create /etc/greetd: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := runSudoCmd(sudoPassword, "install", "-o", "root", "-g", "root", "-m", "0644", tmpFile.Name(), configPath); err != nil {
|
if err := privesc.Run(context.Background(), sudoPassword, "install", "-o", "root", "-g", "root", "-m", "0644", tmpFile.Name(), configPath); err != nil {
|
||||||
return fmt.Errorf("failed to install greetd config: %w", err)
|
return fmt.Errorf("failed to install greetd config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1912,27 +1841,6 @@ func getOpenSUSEOBSRepoURL(osInfo *distros.OSInfo) string {
|
|||||||
return fmt.Sprintf("%s/%s/home:AvengeMedia:danklinux.repo", base, slug)
|
return fmt.Sprintf("%s/%s/home:AvengeMedia:danklinux.repo", base, slug)
|
||||||
}
|
}
|
||||||
|
|
||||||
func runSudoCmd(sudoPassword string, command string, args ...string) error {
|
|
||||||
var cmd *exec.Cmd
|
|
||||||
|
|
||||||
if sudoPassword != "" {
|
|
||||||
fullArgs := append([]string{command}, args...)
|
|
||||||
quotedArgs := make([]string, len(fullArgs))
|
|
||||||
for i, arg := range fullArgs {
|
|
||||||
quotedArgs[i] = "'" + strings.ReplaceAll(arg, "'", "'\\''") + "'"
|
|
||||||
}
|
|
||||||
cmdStr := strings.Join(quotedArgs, " ")
|
|
||||||
|
|
||||||
cmd = distros.ExecSudoCommand(context.Background(), sudoPassword, cmdStr)
|
|
||||||
} else {
|
|
||||||
cmd = exec.Command("sudo", append([]string{command}, args...)...)
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd.Stdout = os.Stdout
|
|
||||||
cmd.Stderr = os.Stderr
|
|
||||||
return cmd.Run()
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkSystemdEnabled(service string) (string, error) {
|
func checkSystemdEnabled(service string) (string, error) {
|
||||||
cmd := exec.Command("systemctl", "is-enabled", service)
|
cmd := exec.Command("systemctl", "is-enabled", service)
|
||||||
output, _ := cmd.Output()
|
output, _ := cmd.Output()
|
||||||
@@ -1949,7 +1857,7 @@ func DisableConflictingDisplayManagers(sudoPassword string, logFunc func(string)
|
|||||||
switch state {
|
switch state {
|
||||||
case "enabled", "enabled-runtime", "static", "indirect", "alias":
|
case "enabled", "enabled-runtime", "static", "indirect", "alias":
|
||||||
logFunc(fmt.Sprintf("Disabling conflicting display manager: %s", dm))
|
logFunc(fmt.Sprintf("Disabling conflicting display manager: %s", dm))
|
||||||
if err := runSudoCmd(sudoPassword, "systemctl", "disable", dm); err != nil {
|
if err := privesc.Run(context.Background(), sudoPassword, "systemctl", "disable", dm); err != nil {
|
||||||
logFunc(fmt.Sprintf("⚠ Warning: Failed to disable %s: %v", dm, err))
|
logFunc(fmt.Sprintf("⚠ Warning: Failed to disable %s: %v", dm, err))
|
||||||
} else {
|
} else {
|
||||||
logFunc(fmt.Sprintf("✓ Disabled %s", dm))
|
logFunc(fmt.Sprintf("✓ Disabled %s", dm))
|
||||||
@@ -1970,13 +1878,13 @@ func EnableGreetd(sudoPassword string, logFunc func(string)) error {
|
|||||||
}
|
}
|
||||||
if state == "masked" || state == "masked-runtime" {
|
if state == "masked" || state == "masked-runtime" {
|
||||||
logFunc(" Unmasking greetd...")
|
logFunc(" Unmasking greetd...")
|
||||||
if err := runSudoCmd(sudoPassword, "systemctl", "unmask", "greetd"); err != nil {
|
if err := privesc.Run(context.Background(), sudoPassword, "systemctl", "unmask", "greetd"); err != nil {
|
||||||
return fmt.Errorf("failed to unmask greetd: %w", err)
|
return fmt.Errorf("failed to unmask greetd: %w", err)
|
||||||
}
|
}
|
||||||
logFunc(" ✓ Unmasked greetd")
|
logFunc(" ✓ Unmasked greetd")
|
||||||
}
|
}
|
||||||
logFunc(" Enabling greetd service (--force)...")
|
logFunc(" Enabling greetd service (--force)...")
|
||||||
if err := runSudoCmd(sudoPassword, "systemctl", "enable", "--force", "greetd"); err != nil {
|
if err := privesc.Run(context.Background(), sudoPassword, "systemctl", "enable", "--force", "greetd"); err != nil {
|
||||||
return fmt.Errorf("failed to enable greetd: %w", err)
|
return fmt.Errorf("failed to enable greetd: %w", err)
|
||||||
}
|
}
|
||||||
logFunc("✓ greetd enabled")
|
logFunc("✓ greetd enabled")
|
||||||
@@ -1996,7 +1904,7 @@ func EnsureGraphicalTarget(sudoPassword string, logFunc func(string)) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
logFunc(fmt.Sprintf(" Setting default target to graphical.target (was: %s)...", current))
|
logFunc(fmt.Sprintf(" Setting default target to graphical.target (was: %s)...", current))
|
||||||
if err := runSudoCmd(sudoPassword, "systemctl", "set-default", "graphical.target"); err != nil {
|
if err := privesc.Run(context.Background(), sudoPassword, "systemctl", "set-default", "graphical.target"); err != nil {
|
||||||
return fmt.Errorf("failed to set graphical target: %w", err)
|
return fmt.Errorf("failed to set graphical target: %w", err)
|
||||||
}
|
}
|
||||||
logFunc("✓ Default target set to graphical.target")
|
logFunc("✓ Default target set to graphical.target")
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/greeter"
|
"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
|
// ErrConfirmationRequired is returned when --yes is not set and the user
|
||||||
@@ -383,20 +383,41 @@ func (r *Runner) parseTerminal() (deps.Terminal, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *Runner) resolveSudoPassword() (string, error) {
|
func (r *Runner) resolveSudoPassword() (string, error) {
|
||||||
// Check if sudo credentials are cached (via sudo -v or NOPASSWD)
|
tool, err := privesc.Detect()
|
||||||
cmd := exec.Command("sudo", "-n", "true")
|
if err != nil {
|
||||||
if err := cmd.Run(); err == nil {
|
return "", err
|
||||||
r.log("sudo cache is valid, no password needed")
|
}
|
||||||
fmt.Fprintln(os.Stdout, "sudo: using cached credentials")
|
|
||||||
|
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
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
switch tool {
|
||||||
|
case privesc.ToolSudo:
|
||||||
return "", fmt.Errorf(
|
return "", fmt.Errorf(
|
||||||
"sudo authentication required but no cached credentials found\n" +
|
"sudo authentication required but no cached credentials found\n" +
|
||||||
"Options:\n" +
|
"Options:\n" +
|
||||||
" 1. Run 'sudo -v' before dankinstall to cache credentials\n" +
|
" 1. Run 'sudo -v' before dankinstall to cache credentials\n" +
|
||||||
" 2. Configure passwordless sudo for your user",
|
" 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 {
|
func (r *Runner) anyConfigEnabled(m map[string]bool) bool {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
|
||||||
@@ -48,7 +49,7 @@ func (h *HyprlandProvider) GetCheatSheet() (*keybinds.CheatSheet, error) {
|
|||||||
h.parsed = true
|
h.parsed = true
|
||||||
|
|
||||||
categorizedBinds := make(map[string][]keybinds.Keybind)
|
categorizedBinds := make(map[string][]keybinds.Keybind)
|
||||||
h.convertSection(result.Section, "", categorizedBinds, result.ConflictingConfigs)
|
h.convertSection(result.Section, "", categorizedBinds, result.ConflictingConfigs, result.DefaultDMSKeys)
|
||||||
|
|
||||||
sheet := &keybinds.CheatSheet{
|
sheet := &keybinds.CheatSheet{
|
||||||
Title: "Hyprland Keybinds",
|
Title: "Hyprland Keybinds",
|
||||||
@@ -88,7 +89,7 @@ func (h *HyprlandProvider) HasDMSBindsIncluded() bool {
|
|||||||
return h.dmsBindsIncluded
|
return h.dmsBindsIncluded
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *HyprlandProvider) convertSection(section *HyprlandSection, subcategory string, categorizedBinds map[string][]keybinds.Keybind, conflicts map[string]*HyprlandKeyBinding) {
|
func (h *HyprlandProvider) convertSection(section *HyprlandSection, subcategory string, categorizedBinds map[string][]keybinds.Keybind, conflicts map[string]*HyprlandKeyBinding, defaultKeys map[string]bool) {
|
||||||
currentSubcat := subcategory
|
currentSubcat := subcategory
|
||||||
if section.Name != "" {
|
if section.Name != "" {
|
||||||
currentSubcat = section.Name
|
currentSubcat = section.Name
|
||||||
@@ -96,12 +97,12 @@ func (h *HyprlandProvider) convertSection(section *HyprlandSection, subcategory
|
|||||||
|
|
||||||
for _, kb := range section.Keybinds {
|
for _, kb := range section.Keybinds {
|
||||||
category := h.categorizeByDispatcher(kb.Dispatcher)
|
category := h.categorizeByDispatcher(kb.Dispatcher)
|
||||||
bind := h.convertKeybind(&kb, currentSubcat, conflicts)
|
bind := h.convertKeybind(&kb, currentSubcat, conflicts, defaultKeys)
|
||||||
categorizedBinds[category] = append(categorizedBinds[category], bind)
|
categorizedBinds[category] = append(categorizedBinds[category], bind)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, child := range section.Children {
|
for _, child := range section.Children {
|
||||||
h.convertSection(&child, currentSubcat, categorizedBinds, conflicts)
|
h.convertSection(&child, currentSubcat, categorizedBinds, conflicts, defaultKeys)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +134,7 @@ func (h *HyprlandProvider) categorizeByDispatcher(dispatcher string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *HyprlandProvider) convertKeybind(kb *HyprlandKeyBinding, subcategory string, conflicts map[string]*HyprlandKeyBinding) keybinds.Keybind {
|
func (h *HyprlandProvider) convertKeybind(kb *HyprlandKeyBinding, subcategory string, conflicts map[string]*HyprlandKeyBinding, defaultKeys map[string]bool) keybinds.Keybind {
|
||||||
keyStr := h.formatKey(kb)
|
keyStr := h.formatKey(kb)
|
||||||
rawAction := h.formatRawAction(kb.Dispatcher, kb.Params)
|
rawAction := h.formatRawAction(kb.Dispatcher, kb.Params)
|
||||||
desc := kb.Comment
|
desc := kb.Comment
|
||||||
@@ -143,8 +144,15 @@ func (h *HyprlandProvider) convertKeybind(kb *HyprlandKeyBinding, subcategory st
|
|||||||
}
|
}
|
||||||
|
|
||||||
source := "config"
|
source := "config"
|
||||||
if strings.Contains(kb.Source, "dms/binds.conf") {
|
if isDMSBindsUserOverridePath(kb.Source) {
|
||||||
source = "dms"
|
source = "dms"
|
||||||
|
} else if isDMSBindsPrimarySourcePath(kb.Source) {
|
||||||
|
source = "dms-default"
|
||||||
|
}
|
||||||
|
|
||||||
|
hasDefault := false
|
||||||
|
if source == "dms" && defaultKeys != nil {
|
||||||
|
hasDefault = defaultKeys[strings.ToLower(keyStr)]
|
||||||
}
|
}
|
||||||
|
|
||||||
bind := keybinds.Keybind{
|
bind := keybinds.Keybind{
|
||||||
@@ -154,9 +162,10 @@ func (h *HyprlandProvider) convertKeybind(kb *HyprlandKeyBinding, subcategory st
|
|||||||
Subcategory: subcategory,
|
Subcategory: subcategory,
|
||||||
Source: source,
|
Source: source,
|
||||||
Flags: kb.Flags,
|
Flags: kb.Flags,
|
||||||
|
HasDefault: hasDefault,
|
||||||
}
|
}
|
||||||
|
|
||||||
if source == "dms" && conflicts != nil {
|
if (source == "dms" || source == "dms-default") && conflicts != nil {
|
||||||
normalizedKey := strings.ToLower(keyStr)
|
normalizedKey := strings.ToLower(keyStr)
|
||||||
if conflictKb, ok := conflicts[normalizedKey]; ok {
|
if conflictKb, ok := conflicts[normalizedKey]; ok {
|
||||||
bind.Conflict = &keybinds.Keybind{
|
bind.Conflict = &keybinds.Keybind{
|
||||||
@@ -188,9 +197,9 @@ func (h *HyprlandProvider) formatKey(kb *HyprlandKeyBinding) string {
|
|||||||
func (h *HyprlandProvider) GetOverridePath() string {
|
func (h *HyprlandProvider) GetOverridePath() string {
|
||||||
expanded, err := utils.ExpandPath(h.configPath)
|
expanded, err := utils.ExpandPath(h.configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return filepath.Join(h.configPath, "dms", "binds.conf")
|
return filepath.Join(h.configPath, "dms", "binds-user.lua")
|
||||||
}
|
}
|
||||||
return filepath.Join(expanded, "dms", "binds.conf")
|
return filepath.Join(expanded, "dms", "binds-user.lua")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *HyprlandProvider) validateAction(action string) error {
|
func (h *HyprlandProvider) validateAction(action string) error {
|
||||||
@@ -250,7 +259,16 @@ func (h *HyprlandProvider) RemoveBind(key string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
normalizedKey := strings.ToLower(key)
|
||||||
|
existingBinds[normalizedKey] = &hyprlandOverrideBind{Key: key, Unbind: true}
|
||||||
|
return h.writeOverrideBinds(existingBinds)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HyprlandProvider) ResetBind(key string) error {
|
||||||
|
existingBinds, err := h.loadOverrideBinds()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
normalizedKey := strings.ToLower(key)
|
normalizedKey := strings.ToLower(key)
|
||||||
delete(existingBinds, normalizedKey)
|
delete(existingBinds, normalizedKey)
|
||||||
return h.writeOverrideBinds(existingBinds)
|
return h.writeOverrideBinds(existingBinds)
|
||||||
@@ -262,116 +280,12 @@ type hyprlandOverrideBind struct {
|
|||||||
Description string
|
Description string
|
||||||
Flags string // Bind flags: l=locked, r=release, e=repeat, n=non-consuming, m=mouse, t=transparent, i=ignore-mods, s=separate, d=description, o=long-press
|
Flags string // Bind flags: l=locked, r=release, e=repeat, n=non-consuming, m=mouse, t=transparent, i=ignore-mods, s=separate, d=description, o=long-press
|
||||||
Options map[string]any
|
Options map[string]any
|
||||||
|
// Unbind: negative override (hl.unbind only, no rebind).
|
||||||
|
Unbind bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *HyprlandProvider) loadOverrideBinds() (map[string]*hyprlandOverrideBind, error) {
|
func (h *HyprlandProvider) loadOverrideBinds() (map[string]*hyprlandOverrideBind, error) {
|
||||||
overridePath := h.GetOverridePath()
|
return readLuaOrHyprlangOverride(h.GetOverridePath())
|
||||||
binds := make(map[string]*hyprlandOverrideBind)
|
|
||||||
|
|
||||||
data, err := os.ReadFile(overridePath)
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return binds, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
lines := strings.Split(string(data), "\n")
|
|
||||||
for _, line := range lines {
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
if line == "" || strings.HasPrefix(line, "#") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.HasPrefix(line, "bind") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
parts := strings.SplitN(line, "=", 2)
|
|
||||||
if len(parts) < 2 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract flags from bind type
|
|
||||||
bindType := strings.TrimSpace(parts[0])
|
|
||||||
flags := extractBindFlags(bindType)
|
|
||||||
hasDescFlag := strings.Contains(flags, "d")
|
|
||||||
|
|
||||||
content := strings.TrimSpace(parts[1])
|
|
||||||
commentParts := strings.SplitN(content, "#", 2)
|
|
||||||
bindContent := strings.TrimSpace(commentParts[0])
|
|
||||||
|
|
||||||
var comment string
|
|
||||||
if len(commentParts) > 1 {
|
|
||||||
comment = strings.TrimSpace(commentParts[1])
|
|
||||||
}
|
|
||||||
|
|
||||||
// For bindd, format is: mods, key, description, dispatcher, params
|
|
||||||
var minFields, descIndex, dispatcherIndex int
|
|
||||||
if hasDescFlag {
|
|
||||||
minFields = 4
|
|
||||||
descIndex = 2
|
|
||||||
dispatcherIndex = 3
|
|
||||||
} else {
|
|
||||||
minFields = 3
|
|
||||||
dispatcherIndex = 2
|
|
||||||
}
|
|
||||||
|
|
||||||
fields := strings.SplitN(bindContent, ",", minFields+2)
|
|
||||||
if len(fields) < minFields {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
mods := strings.TrimSpace(fields[0])
|
|
||||||
keyName := strings.TrimSpace(fields[1])
|
|
||||||
|
|
||||||
var dispatcher, params string
|
|
||||||
if hasDescFlag {
|
|
||||||
if comment == "" {
|
|
||||||
comment = strings.TrimSpace(fields[descIndex])
|
|
||||||
}
|
|
||||||
dispatcher = strings.TrimSpace(fields[dispatcherIndex])
|
|
||||||
if len(fields) > dispatcherIndex+1 {
|
|
||||||
paramParts := fields[dispatcherIndex+1:]
|
|
||||||
params = strings.TrimSpace(strings.Join(paramParts, ","))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
dispatcher = strings.TrimSpace(fields[dispatcherIndex])
|
|
||||||
if len(fields) > dispatcherIndex+1 {
|
|
||||||
paramParts := fields[dispatcherIndex+1:]
|
|
||||||
params = strings.TrimSpace(strings.Join(paramParts, ","))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
keyStr := h.buildKeyString(mods, keyName)
|
|
||||||
normalizedKey := strings.ToLower(keyStr)
|
|
||||||
action := dispatcher
|
|
||||||
if params != "" {
|
|
||||||
action = dispatcher + " " + params
|
|
||||||
}
|
|
||||||
|
|
||||||
binds[normalizedKey] = &hyprlandOverrideBind{
|
|
||||||
Key: keyStr,
|
|
||||||
Action: action,
|
|
||||||
Description: comment,
|
|
||||||
Flags: flags,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return binds, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *HyprlandProvider) buildKeyString(mods, key string) string {
|
|
||||||
if mods == "" {
|
|
||||||
return key
|
|
||||||
}
|
|
||||||
|
|
||||||
modList := strings.FieldsFunc(mods, func(r rune) bool {
|
|
||||||
return r == '+' || r == ' '
|
|
||||||
})
|
|
||||||
|
|
||||||
parts := append(modList, key)
|
|
||||||
return strings.Join(parts, "+")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *HyprlandProvider) getBindSortPriority(action string) int {
|
func (h *HyprlandProvider) getBindSortPriority(action string) int {
|
||||||
@@ -420,78 +334,203 @@ func (h *HyprlandProvider) generateBindsContent(binds map[string]*hyprlandOverri
|
|||||||
})
|
})
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
sb.WriteString("-- DMS user keybind overrides (edit via Control Center or dms; do not remove this header)\n\n")
|
||||||
for _, bind := range bindList {
|
for _, bind := range bindList {
|
||||||
h.writeBindLine(&sb, bind)
|
writeLuaBindLine(&sb, bind)
|
||||||
}
|
}
|
||||||
|
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *HyprlandProvider) writeBindLine(sb *strings.Builder, bind *hyprlandOverrideBind) {
|
func formatLuaBindKey(internalKey string) string {
|
||||||
mods, key := h.parseKeyString(bind.Key)
|
internalKey = strings.TrimSpace(internalKey)
|
||||||
dispatcher, params := h.parseAction(bind.Action)
|
parts := strings.Split(internalKey, "+")
|
||||||
|
for i := range parts {
|
||||||
// Write bind type with flags (e.g., "bind", "binde", "bindel")
|
parts[i] = normalizeLuaBindKeyPart(strings.TrimSpace(parts[i]))
|
||||||
sb.WriteString("bind")
|
|
||||||
if bind.Flags != "" {
|
|
||||||
sb.WriteString(bind.Flags)
|
|
||||||
}
|
}
|
||||||
sb.WriteString(" = ")
|
return strings.Join(parts, " + ")
|
||||||
sb.WriteString(mods)
|
|
||||||
sb.WriteString(", ")
|
|
||||||
sb.WriteString(key)
|
|
||||||
sb.WriteString(", ")
|
|
||||||
|
|
||||||
// For bindd (description flag), include description before dispatcher
|
|
||||||
if strings.Contains(bind.Flags, "d") && bind.Description != "" {
|
|
||||||
sb.WriteString(bind.Description)
|
|
||||||
sb.WriteString(", ")
|
|
||||||
}
|
|
||||||
|
|
||||||
sb.WriteString(dispatcher)
|
|
||||||
|
|
||||||
if params != "" {
|
|
||||||
sb.WriteString(", ")
|
|
||||||
sb.WriteString(params)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only add comment if not using bindd (which has inline description)
|
|
||||||
if bind.Description != "" && !strings.Contains(bind.Flags, "d") {
|
|
||||||
sb.WriteString(" # ")
|
|
||||||
sb.WriteString(bind.Description)
|
|
||||||
}
|
|
||||||
|
|
||||||
sb.WriteString("\n")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *HyprlandProvider) parseKeyString(keyStr string) (mods, key string) {
|
func normalizeLuaBindKeyPart(part string) string {
|
||||||
parts := strings.Split(keyStr, "+")
|
switch strings.ToLower(part) {
|
||||||
switch len(parts) {
|
case "super", "mod4", "mainmod":
|
||||||
case 0:
|
return "SUPER"
|
||||||
return "", keyStr
|
case "ctrl", "control":
|
||||||
case 1:
|
return "CTRL"
|
||||||
return "", parts[0]
|
case "shift":
|
||||||
|
return "SHIFT"
|
||||||
|
case "alt", "mod1":
|
||||||
|
return "ALT"
|
||||||
|
}
|
||||||
|
if len(part) == 1 {
|
||||||
|
return strings.ToUpper(part)
|
||||||
|
}
|
||||||
|
return part
|
||||||
|
}
|
||||||
|
|
||||||
|
func luaActionStringFromHyprlangAction(action string) string {
|
||||||
|
action = strings.TrimSpace(action)
|
||||||
|
if strings.HasPrefix(action, "spawn ") {
|
||||||
|
return fmt.Sprintf(`hl.dsp.exec_cmd(%s)`, strconv.Quote(strings.TrimSpace(strings.TrimPrefix(action, "spawn "))))
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(action, "exec ") {
|
||||||
|
return fmt.Sprintf(`hl.dsp.exec_cmd(%s)`, strconv.Quote(strings.TrimPrefix(action, "exec ")))
|
||||||
|
}
|
||||||
|
switch action {
|
||||||
|
case "killactive":
|
||||||
|
return `hl.dsp.window.kill()`
|
||||||
|
case "togglefloating":
|
||||||
|
return `hl.dsp.window.float({ action = "toggle" })`
|
||||||
|
case "exit":
|
||||||
|
return `hl.dsp.exit()`
|
||||||
default:
|
default:
|
||||||
return strings.Join(parts[:len(parts)-1], " "), parts[len(parts)-1]
|
return fmt.Sprintf(`hl.dsp.exec_cmd(%s)`, strconv.Quote("hyprctl dispatch "+action))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *HyprlandProvider) parseAction(action string) (dispatcher, params string) {
|
func luaExprToInternalAction(expr string) string {
|
||||||
parts := strings.SplitN(action, " ", 2)
|
d, p := luaExprToDispatcherParams(expr)
|
||||||
switch len(parts) {
|
if d == "exec" && p != "" && !strings.HasPrefix(p, "hyprctl dispatch lua:") {
|
||||||
case 0:
|
return "exec " + p
|
||||||
return action, ""
|
|
||||||
case 1:
|
|
||||||
dispatcher = parts[0]
|
|
||||||
default:
|
|
||||||
dispatcher = parts[0]
|
|
||||||
params = parts[1]
|
|
||||||
}
|
}
|
||||||
|
if p != "" {
|
||||||
// Convert internal spawn format to Hyprland's exec
|
return d + " " + p
|
||||||
if dispatcher == "spawn" {
|
|
||||||
dispatcher = "exec"
|
|
||||||
}
|
}
|
||||||
|
return d
|
||||||
return dispatcher, params
|
}
|
||||||
|
|
||||||
|
func luaBindOptions(bind *hyprlandOverrideBind) []string {
|
||||||
|
var opts []string
|
||||||
|
if strings.Contains(bind.Flags, "l") {
|
||||||
|
opts = append(opts, "locked = true")
|
||||||
|
}
|
||||||
|
if strings.Contains(bind.Flags, "e") {
|
||||||
|
opts = append(opts, "repeating = true")
|
||||||
|
}
|
||||||
|
if bind.Description != "" && strings.Contains(bind.Flags, "d") {
|
||||||
|
opts = append(opts, fmt.Sprintf("description = %s", strconv.Quote(bind.Description)))
|
||||||
|
}
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeLuaBindLine(sb *strings.Builder, bind *hyprlandOverrideBind) {
|
||||||
|
key := formatLuaBindKey(bind.Key)
|
||||||
|
if bind.Unbind {
|
||||||
|
fmt.Fprintf(sb, `hl.unbind("%s")`, key)
|
||||||
|
sb.WriteByte('\n')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
expr := luaActionStringFromHyprlangAction(bind.Action)
|
||||||
|
opts := luaBindOptions(bind)
|
||||||
|
fmt.Fprintf(sb, `hl.unbind("%s")`, key)
|
||||||
|
sb.WriteByte('\n')
|
||||||
|
if len(opts) > 0 {
|
||||||
|
fmt.Fprintf(sb, `hl.bind("%s", %s, { %s })`, key, expr, strings.Join(opts, ", "))
|
||||||
|
} else {
|
||||||
|
if bind.Description != "" {
|
||||||
|
fmt.Fprintf(sb, `hl.bind("%s", %s) -- %s`, key, expr, bind.Description)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(sb, `hl.bind("%s", %s)`, key, expr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.WriteByte('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseLuaBindOverrideLine(line string) (*hyprlandOverrideBind, bool) {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" || strings.HasPrefix(line, "--") {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
kbc, actionExpr, optSuffix, ok := parseLuaBindInvocation(line)
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
internalKey := luaKeyComboToInternalKey(kbc)
|
||||||
|
|
||||||
|
action := luaExprToInternalAction(actionExpr)
|
||||||
|
flags := luaBindOptFlags(optSuffix)
|
||||||
|
description := luaBindOptDescription(optSuffix)
|
||||||
|
return &hyprlandOverrideBind{
|
||||||
|
Key: internalKey,
|
||||||
|
Action: action,
|
||||||
|
Description: description,
|
||||||
|
Flags: flags,
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseLuaUnbindLine(line string) (string, bool) {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if !strings.HasPrefix(line, "hl.unbind") {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
rest := strings.TrimSpace(line[len("hl.unbind"):])
|
||||||
|
if !strings.HasPrefix(rest, "(") {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
rest = rest[1:]
|
||||||
|
combo, _, ok := parseLuaStringLiteral(rest, 0)
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return luaKeyComboToInternalKey(combo), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func luaKeyComboToInternalKey(combo string) string {
|
||||||
|
parts := strings.Fields(strings.ReplaceAll(strings.ReplaceAll(combo, "+", " "), " ", " "))
|
||||||
|
return strings.Join(parts, "+")
|
||||||
|
}
|
||||||
|
|
||||||
|
func readLuaOrHyprlangOverride(path string) (map[string]*hyprlandOverrideBind, error) {
|
||||||
|
binds := make(map[string]*hyprlandOverrideBind)
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return binds, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
parser := NewHyprlandParser("")
|
||||||
|
pendingUnbinds := make(map[string]string)
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" || strings.HasPrefix(line, "--") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if key, ok := parseLuaUnbindLine(line); ok {
|
||||||
|
pendingUnbinds[strings.ToLower(key)] = key
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if kb, ok := parseLuaBindOverrideLine(line); ok {
|
||||||
|
normalizedKey := strings.ToLower(kb.Key)
|
||||||
|
binds[normalizedKey] = kb
|
||||||
|
delete(pendingUnbinds, normalizedKey)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(line, "bind") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kb := parser.parseBindLine(line)
|
||||||
|
if kb == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
keyStr := parser.formatBindKey(kb)
|
||||||
|
action := kb.Dispatcher
|
||||||
|
if kb.Params != "" {
|
||||||
|
action = kb.Dispatcher + " " + kb.Params
|
||||||
|
}
|
||||||
|
flags := kb.Flags
|
||||||
|
normalizedKey := strings.ToLower(keyStr)
|
||||||
|
binds[normalizedKey] = &hyprlandOverrideBind{
|
||||||
|
Key: keyStr,
|
||||||
|
Action: action,
|
||||||
|
Description: kb.Comment,
|
||||||
|
Flags: flags,
|
||||||
|
}
|
||||||
|
delete(pendingUnbinds, normalizedKey)
|
||||||
|
}
|
||||||
|
for normKey, origKey := range pendingUnbinds {
|
||||||
|
binds[normKey] = &hyprlandOverrideBind{Key: origKey, Unbind: true}
|
||||||
|
}
|
||||||
|
return binds, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/luaconfig"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -50,6 +52,8 @@ type HyprlandParser struct {
|
|||||||
bindOrder []string
|
bindOrder []string
|
||||||
processedFiles map[string]bool
|
processedFiles map[string]bool
|
||||||
dmsProcessed bool
|
dmsProcessed bool
|
||||||
|
removedKeys map[string]bool // bare hl.unbind targets (negative overrides)
|
||||||
|
defaultDMSKeys map[string]bool // keys present in dms/binds.{lua,conf}
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHyprlandParser(configDir string) *HyprlandParser {
|
func NewHyprlandParser(configDir string) *HyprlandParser {
|
||||||
@@ -64,6 +68,8 @@ func NewHyprlandParser(configDir string) *HyprlandParser {
|
|||||||
bindMap: make(map[string]*HyprlandKeyBinding),
|
bindMap: make(map[string]*HyprlandKeyBinding),
|
||||||
bindOrder: []string{},
|
bindOrder: []string{},
|
||||||
processedFiles: make(map[string]bool),
|
processedFiles: make(map[string]bool),
|
||||||
|
removedKeys: make(map[string]bool),
|
||||||
|
defaultDMSKeys: make(map[string]bool),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,6 +298,7 @@ type HyprlandParseResult struct {
|
|||||||
DMSBindsIncluded bool
|
DMSBindsIncluded bool
|
||||||
DMSStatus *HyprlandDMSStatus
|
DMSStatus *HyprlandDMSStatus
|
||||||
ConflictingConfigs map[string]*HyprlandKeyBinding
|
ConflictingConfigs map[string]*HyprlandKeyBinding
|
||||||
|
DefaultDMSKeys map[string]bool // keys with a DMS default in binds.{lua,conf}
|
||||||
}
|
}
|
||||||
|
|
||||||
type HyprlandDMSStatus struct {
|
type HyprlandDMSStatus struct {
|
||||||
@@ -317,10 +324,10 @@ func (p *HyprlandParser) buildDMSStatus() *HyprlandDMSStatus {
|
|||||||
switch {
|
switch {
|
||||||
case !p.dmsBindsExists:
|
case !p.dmsBindsExists:
|
||||||
status.Effective = false
|
status.Effective = false
|
||||||
status.StatusMessage = "dms/binds.conf does not exist"
|
status.StatusMessage = "dms/binds.lua (or legacy binds.conf) does not exist"
|
||||||
case !p.dmsBindsIncluded:
|
case !p.dmsBindsIncluded:
|
||||||
status.Effective = false
|
status.Effective = false
|
||||||
status.StatusMessage = "dms/binds.conf is not sourced in config"
|
status.StatusMessage = "dms binds are not loaded from Hyprland config (require / source)"
|
||||||
case p.bindsAfterDMS > 0:
|
case p.bindsAfterDMS > 0:
|
||||||
status.Effective = true
|
status.Effective = true
|
||||||
status.OverriddenBy = p.bindsAfterDMS
|
status.OverriddenBy = p.bindsAfterDMS
|
||||||
@@ -347,8 +354,11 @@ func (p *HyprlandParser) normalizeKey(key string) string {
|
|||||||
func (p *HyprlandParser) addBind(kb *HyprlandKeyBinding) bool {
|
func (p *HyprlandParser) addBind(kb *HyprlandKeyBinding) bool {
|
||||||
key := p.formatBindKey(kb)
|
key := p.formatBindKey(kb)
|
||||||
normalizedKey := p.normalizeKey(key)
|
normalizedKey := p.normalizeKey(key)
|
||||||
isDMSBind := strings.Contains(kb.Source, "dms/binds.conf")
|
isDMSBind := isDMSBindsSourcePath(kb.Source)
|
||||||
|
|
||||||
|
if isDMSBindsPrimarySourcePath(kb.Source) {
|
||||||
|
p.defaultDMSKeys[normalizedKey] = true
|
||||||
|
}
|
||||||
if isDMSBind {
|
if isDMSBind {
|
||||||
p.dmsBindKeys[normalizedKey] = true
|
p.dmsBindKeys[normalizedKey] = true
|
||||||
} else if p.dmsBindKeys[normalizedKey] {
|
} else if p.dmsBindKeys[normalizedKey] {
|
||||||
@@ -373,12 +383,21 @@ func (p *HyprlandParser) ParseWithDMS() (*HyprlandSection, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
dmsBindsPath := filepath.Join(expandedDir, "dms", "binds.conf")
|
dmsBindsLua := filepath.Join(expandedDir, "dms", "binds.lua")
|
||||||
if _, err := os.Stat(dmsBindsPath); err == nil {
|
dmsBindsConf := filepath.Join(expandedDir, "dms", "binds.conf")
|
||||||
|
dmsBindsPath := ""
|
||||||
|
if _, err := os.Stat(dmsBindsLua); err == nil {
|
||||||
p.dmsBindsExists = true
|
p.dmsBindsExists = true
|
||||||
|
dmsBindsPath = dmsBindsLua
|
||||||
|
} else if _, err := os.Stat(dmsBindsConf); err == nil {
|
||||||
|
p.dmsBindsExists = true
|
||||||
|
dmsBindsPath = dmsBindsConf
|
||||||
}
|
}
|
||||||
|
|
||||||
mainConfig := filepath.Join(expandedDir, "hyprland.conf")
|
mainConfig, err := hyprlandMainConfigPath(p.configDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
section, err := p.parseFileWithSource(mainConfig, "")
|
section, err := p.parseFileWithSource(mainConfig, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -387,10 +406,65 @@ func (p *HyprlandParser) ParseWithDMS() (*HyprlandSection, error) {
|
|||||||
if p.dmsBindsExists && !p.dmsProcessed {
|
if p.dmsBindsExists && !p.dmsProcessed {
|
||||||
p.parseDMSBindsDirectly(dmsBindsPath, section)
|
p.parseDMSBindsDirectly(dmsBindsPath, section)
|
||||||
}
|
}
|
||||||
|
p.removeShadowedDMSBinds(section)
|
||||||
|
p.removeUnboundDMSBinds(section)
|
||||||
|
|
||||||
return section, nil
|
return section, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *HyprlandParser) removeUnboundDMSBinds(section *HyprlandSection) {
|
||||||
|
if len(p.removedKeys) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
filtered := section.Keybinds[:0]
|
||||||
|
for i := range section.Keybinds {
|
||||||
|
kb := section.Keybinds[i]
|
||||||
|
if isDMSBindsSourcePath(kb.Source) && p.removedKeys[p.normalizeKey(p.formatBindKey(&kb))] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, kb)
|
||||||
|
}
|
||||||
|
section.Keybinds = filtered
|
||||||
|
for i := range section.Children {
|
||||||
|
p.removeUnboundDMSBinds(§ion.Children[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *HyprlandParser) removeShadowedDMSBinds(section *HyprlandSection) {
|
||||||
|
counts := make(map[string]int)
|
||||||
|
p.countDMSBinds(section, counts)
|
||||||
|
p.filterShadowedDMSBinds(section, counts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *HyprlandParser) countDMSBinds(section *HyprlandSection, counts map[string]int) {
|
||||||
|
for i := range section.Keybinds {
|
||||||
|
kb := §ion.Keybinds[i]
|
||||||
|
if isDMSBindsSourcePath(kb.Source) {
|
||||||
|
counts[p.normalizeKey(p.formatBindKey(kb))]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range section.Children {
|
||||||
|
p.countDMSBinds(§ion.Children[i], counts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *HyprlandParser) filterShadowedDMSBinds(section *HyprlandSection, counts map[string]int) {
|
||||||
|
filtered := section.Keybinds[:0]
|
||||||
|
for i := range section.Keybinds {
|
||||||
|
kb := section.Keybinds[i]
|
||||||
|
key := p.normalizeKey(p.formatBindKey(&kb))
|
||||||
|
if isDMSBindsSourcePath(kb.Source) && counts[key] > 1 {
|
||||||
|
counts[key]--
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, kb)
|
||||||
|
}
|
||||||
|
section.Keybinds = filtered
|
||||||
|
for i := range section.Children {
|
||||||
|
p.filterShadowedDMSBinds(§ion.Children[i], counts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (p *HyprlandParser) parseFileWithSource(filePath, sectionName string) (*HyprlandSection, error) {
|
func (p *HyprlandParser) parseFileWithSource(filePath, sectionName string) (*HyprlandSection, error) {
|
||||||
absPath, err := filepath.Abs(filePath)
|
absPath, err := filepath.Abs(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -407,6 +481,10 @@ func (p *HyprlandParser) parseFileWithSource(filePath, sectionName string) (*Hyp
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.EqualFold(filepath.Ext(absPath), ".lua") {
|
||||||
|
return p.parseLuaLines(string(data), filepath.Dir(absPath), absPath, sectionName)
|
||||||
|
}
|
||||||
|
|
||||||
prevSource := p.currentSource
|
prevSource := p.currentSource
|
||||||
p.currentSource = absPath
|
p.currentSource = absPath
|
||||||
|
|
||||||
@@ -446,7 +524,7 @@ func (p *HyprlandParser) handleSource(line string, section *HyprlandSection, bas
|
|||||||
}
|
}
|
||||||
|
|
||||||
sourcePath := strings.TrimSpace(parts[1])
|
sourcePath := strings.TrimSpace(parts[1])
|
||||||
isDMSSource := sourcePath == "dms/binds.conf" || strings.HasSuffix(sourcePath, "/dms/binds.conf")
|
isDMSSource := isDMSBindsPrimarySourcePath(sourcePath)
|
||||||
|
|
||||||
p.includeCount++
|
p.includeCount++
|
||||||
if isDMSSource {
|
if isDMSSource {
|
||||||
@@ -474,6 +552,17 @@ func (p *HyprlandParser) handleSource(line string, section *HyprlandSection, bas
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *HyprlandParser) parseDMSBindsDirectly(dmsBindsPath string, section *HyprlandSection) {
|
func (p *HyprlandParser) parseDMSBindsDirectly(dmsBindsPath string, section *HyprlandSection) {
|
||||||
|
if strings.EqualFold(filepath.Ext(dmsBindsPath), ".lua") {
|
||||||
|
sub, err := p.parseLuaLinesFromPath(dmsBindsPath)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
section.Keybinds = append(section.Keybinds, sub.Keybinds...)
|
||||||
|
section.Children = append(section.Children, sub.Children...)
|
||||||
|
p.dmsProcessed = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
data, err := os.ReadFile(dmsBindsPath)
|
data, err := os.ReadFile(dmsBindsPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
@@ -503,6 +592,124 @@ func (p *HyprlandParser) parseDMSBindsDirectly(dmsBindsPath string, section *Hyp
|
|||||||
p.dmsProcessed = true
|
p.dmsProcessed = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *HyprlandParser) parseLuaLinesFromPath(absPath string) (*HyprlandSection, error) {
|
||||||
|
data, err := os.ReadFile(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return p.parseLuaLines(string(data), filepath.Dir(absPath), absPath, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseLuaLines reads a Hyprland Lua config fragment: require() includes and hl.bind keybinds.
|
||||||
|
func (p *HyprlandParser) parseLuaLines(content string, baseDir, absPath, sectionName string) (*HyprlandSection, error) {
|
||||||
|
section := &HyprlandSection{Name: sectionName}
|
||||||
|
prevSource := p.currentSource
|
||||||
|
p.currentSource = absPath
|
||||||
|
|
||||||
|
lines := strings.Split(content, "\n")
|
||||||
|
boundInFile := make(map[string]bool)
|
||||||
|
for _, line := range lines {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if trimmed == "" || strings.HasPrefix(trimmed, "--") || !strings.Contains(trimmed, "hl.bind") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if kbc, _, _, ok := parseLuaBindInvocation(trimmed); ok {
|
||||||
|
boundInFile[strings.ToLower(luaKeyComboToInternalKey(kbc))] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rootDir := baseDir
|
||||||
|
if expanded, err := utils.ExpandPath(p.configDir); err == nil && expanded != "" {
|
||||||
|
rootDir = expanded
|
||||||
|
}
|
||||||
|
for _, line := range lines {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if trimmed == "" || strings.HasPrefix(trimmed, "--") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if modules := luaconfig.Requires(trimmed); len(modules) > 0 {
|
||||||
|
for _, mod := range modules {
|
||||||
|
rel := luaconfig.ModuleToRelPath(mod)
|
||||||
|
if rel == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
isDMS := isDMSBindsPrimarySourcePath(rel)
|
||||||
|
p.includeCount++
|
||||||
|
if isDMS {
|
||||||
|
p.dmsBindsIncluded = true
|
||||||
|
p.dmsIncludePos = p.includeCount
|
||||||
|
p.dmsProcessed = true
|
||||||
|
}
|
||||||
|
fullPath := luaconfig.ModuleToPath(rootDir, mod)
|
||||||
|
expanded, err := utils.ExpandPath(fullPath)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
includedSection, err := p.parseFileWithSource(expanded, "")
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
section.Children = append(section.Children, *includedSection)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(trimmed, "hl.unbind") {
|
||||||
|
if key, ok := parseLuaUnbindLine(trimmed); ok {
|
||||||
|
normalized := strings.ToLower(key)
|
||||||
|
if !boundInFile[normalized] {
|
||||||
|
p.removedKeys[normalized] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(trimmed, "hl.bind") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
kbc, action, optSuffix, ok := parseLuaBindInvocation(trimmed)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
flags := luaBindOptFlags(optSuffix)
|
||||||
|
desc := luaBindOptDescription(optSuffix)
|
||||||
|
if desc == "" {
|
||||||
|
desc = luaLineTrailingComment(line)
|
||||||
|
}
|
||||||
|
kb := luaKeyComboToBinding(kbc, action, p.currentSource, desc)
|
||||||
|
kb.Flags = flags
|
||||||
|
if p.addBind(kb) {
|
||||||
|
section.Keybinds = append(section.Keybinds, *kb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p.currentSource = prevSource
|
||||||
|
return section, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func luaBindOptFlags(optSuffix string) string {
|
||||||
|
optSuffix = strings.TrimSpace(optSuffix)
|
||||||
|
if optSuffix == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var flags string
|
||||||
|
if strings.Contains(optSuffix, "repeating") {
|
||||||
|
flags += "e"
|
||||||
|
}
|
||||||
|
if strings.Contains(optSuffix, "locked") {
|
||||||
|
flags += "l"
|
||||||
|
}
|
||||||
|
if strings.Contains(optSuffix, "description") {
|
||||||
|
flags += "d"
|
||||||
|
}
|
||||||
|
return flags
|
||||||
|
}
|
||||||
|
|
||||||
|
func luaBindOptDescription(optSuffix string) string {
|
||||||
|
return luaTableStringField(optSuffix, "description")
|
||||||
|
}
|
||||||
|
|
||||||
func (p *HyprlandParser) parseBindLine(line string) *HyprlandKeyBinding {
|
func (p *HyprlandParser) parseBindLine(line string) *HyprlandKeyBinding {
|
||||||
parts := strings.SplitN(line, "=", 2)
|
parts := strings.SplitN(line, "=", 2)
|
||||||
if len(parts) < 2 {
|
if len(parts) < 2 {
|
||||||
@@ -623,5 +830,356 @@ func ParseHyprlandKeysWithDMS(path string) (*HyprlandParseResult, error) {
|
|||||||
DMSBindsIncluded: parser.dmsBindsIncluded,
|
DMSBindsIncluded: parser.dmsBindsIncluded,
|
||||||
DMSStatus: parser.buildDMSStatus(),
|
DMSStatus: parser.buildDMSStatus(),
|
||||||
ConflictingConfigs: parser.conflictingConfigs,
|
ConflictingConfigs: parser.conflictingConfigs,
|
||||||
|
DefaultDMSKeys: parser.defaultDMSKeys,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func skipLuaWS(s string, i int) int {
|
||||||
|
for i < len(s) && (s[i] == ' ' || s[i] == '\t' || s[i] == '\r') {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseLuaStringLiteral reads a Lua "..." or '...' starting at i (first quote).
|
||||||
|
func parseLuaStringLiteral(line string, i int) (value string, next int, ok bool) {
|
||||||
|
if i >= len(line) {
|
||||||
|
return "", i, false
|
||||||
|
}
|
||||||
|
q := line[i]
|
||||||
|
if q != '"' && q != '\'' {
|
||||||
|
return "", i, false
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
var sb strings.Builder
|
||||||
|
for i < len(line) {
|
||||||
|
c := line[i]
|
||||||
|
if c == '\\' && i+1 < len(line) {
|
||||||
|
i++
|
||||||
|
sb.WriteByte(line[i])
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c == q {
|
||||||
|
return sb.String(), i + 1, true
|
||||||
|
}
|
||||||
|
sb.WriteByte(c)
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return "", i, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseLuaFirstArgExpr parses a single Lua expression starting at i, stopping when parentheses
|
||||||
|
// opened from the first '(' are balanced (handles nested () and {} and double-quoted strings).
|
||||||
|
func parseLuaFirstArgExpr(line string, start int) (expr string, next int, ok bool) {
|
||||||
|
start = skipLuaWS(line, start)
|
||||||
|
if start >= len(line) {
|
||||||
|
return "", start, false
|
||||||
|
}
|
||||||
|
// Find first '(' of the call (e.g. hl.dsp.exec_cmd(...)
|
||||||
|
firstParen := strings.IndexByte(line[start:], '(')
|
||||||
|
if firstParen < 0 {
|
||||||
|
return "", start, false
|
||||||
|
}
|
||||||
|
i := start + firstParen
|
||||||
|
depth := 0
|
||||||
|
inStr := byte(0)
|
||||||
|
esc := false
|
||||||
|
exprStart := start
|
||||||
|
for ; i < len(line); i++ {
|
||||||
|
c := line[i]
|
||||||
|
if inStr != 0 {
|
||||||
|
if esc {
|
||||||
|
esc = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c == '\\' && inStr == '"' {
|
||||||
|
esc = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c == inStr {
|
||||||
|
inStr = 0
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch c {
|
||||||
|
case '"', '\'':
|
||||||
|
inStr = c
|
||||||
|
case '(':
|
||||||
|
depth++
|
||||||
|
case ')':
|
||||||
|
depth--
|
||||||
|
if depth == 0 {
|
||||||
|
return strings.TrimSpace(line[exprStart : i+1]), i + 1, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", start, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseLuaBindInvocation parses one hl.bind("KEY", expr [, opts]) on a single line.
|
||||||
|
func parseLuaBindInvocation(line string) (keyCombo, actionExpr, optSuffix string, ok bool) {
|
||||||
|
idx := strings.Index(line, "hl.bind")
|
||||||
|
if idx < 0 {
|
||||||
|
return "", "", "", false
|
||||||
|
}
|
||||||
|
i := idx + len("hl.bind")
|
||||||
|
i = skipLuaWS(line, i)
|
||||||
|
if i >= len(line) || line[i] != '(' {
|
||||||
|
return "", "", "", false
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
i = skipLuaWS(line, i)
|
||||||
|
keyCombo, i, ok = parseLuaStringLiteral(line, i)
|
||||||
|
if !ok {
|
||||||
|
return "", "", "", false
|
||||||
|
}
|
||||||
|
i = skipLuaWS(line, i)
|
||||||
|
if i >= len(line) || line[i] != ',' {
|
||||||
|
return "", "", "", false
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
i = skipLuaWS(line, i)
|
||||||
|
actionExpr, i, ok = parseLuaFirstArgExpr(line, i)
|
||||||
|
if !ok {
|
||||||
|
return "", "", "", false
|
||||||
|
}
|
||||||
|
i = skipLuaWS(line, i)
|
||||||
|
if i < len(line) && line[i] == ',' {
|
||||||
|
optSuffix = strings.TrimSpace(line[i:])
|
||||||
|
}
|
||||||
|
return keyCombo, strings.TrimSpace(actionExpr), optSuffix, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func luaKeyComboToBinding(keyCombo, actionExpr, source, lineComment string) *HyprlandKeyBinding {
|
||||||
|
keyCombo = strings.TrimSpace(keyCombo)
|
||||||
|
mods, leaf := luaKeyComboToModsKey(keyCombo)
|
||||||
|
dispatcher, params := luaExprToDispatcherParams(actionExpr)
|
||||||
|
comment := lineComment
|
||||||
|
if comment == "" {
|
||||||
|
comment = hyprlandAutogenerateComment(dispatcher, params)
|
||||||
|
}
|
||||||
|
return &HyprlandKeyBinding{
|
||||||
|
Mods: mods,
|
||||||
|
Key: leaf,
|
||||||
|
Dispatcher: dispatcher,
|
||||||
|
Params: params,
|
||||||
|
Comment: comment,
|
||||||
|
Source: source,
|
||||||
|
Flags: "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func luaKeyComboToModsKey(combo string) (mods []string, leaf string) {
|
||||||
|
parts := strings.Split(combo, "+")
|
||||||
|
for i := range parts {
|
||||||
|
parts[i] = strings.TrimSpace(parts[i])
|
||||||
|
}
|
||||||
|
switch len(parts) {
|
||||||
|
case 0:
|
||||||
|
return nil, ""
|
||||||
|
case 1:
|
||||||
|
return nil, parts[0]
|
||||||
|
default:
|
||||||
|
return parts[:len(parts)-1], parts[len(parts)-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func luaExprToDispatcherParams(expr string) (dispatcher, params string) {
|
||||||
|
expr = strings.TrimSpace(expr)
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(expr, "hl.dsp.exec_cmd("):
|
||||||
|
arg := extractLuaCallStringArg(expr, "hl.dsp.exec_cmd")
|
||||||
|
if arg != "" {
|
||||||
|
if u, err := strconv.Unquote(arg); err == nil {
|
||||||
|
if strings.HasPrefix(u, "hyprctl dispatch ") {
|
||||||
|
rest := strings.TrimSpace(strings.TrimPrefix(u, "hyprctl dispatch "))
|
||||||
|
parts := strings.SplitN(rest, " ", 2)
|
||||||
|
if len(parts) == 1 {
|
||||||
|
return parts[0], ""
|
||||||
|
}
|
||||||
|
return parts[0], parts[1]
|
||||||
|
}
|
||||||
|
return "exec", u
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "exec", strings.TrimSpace(strings.TrimPrefix(expr, "hl.dsp.exec_cmd"))
|
||||||
|
case strings.Contains(expr, "hl.dsp.window.kill()"):
|
||||||
|
return "killactive", ""
|
||||||
|
case strings.HasPrefix(expr, "hl.dsp.window.fullscreen("):
|
||||||
|
switch luaTableStringField(expr, "mode") {
|
||||||
|
case "maximized", "maximize":
|
||||||
|
return "fullscreen", "1"
|
||||||
|
case "fullscreen":
|
||||||
|
return "fullscreen", "0"
|
||||||
|
}
|
||||||
|
return "fullscreen", luaTableStringField(expr, "mode")
|
||||||
|
case strings.HasPrefix(expr, "hl.dsp.window.float("):
|
||||||
|
return "togglefloating", ""
|
||||||
|
case strings.Contains(expr, "hl.dsp.group.toggle()"):
|
||||||
|
return "togglegroup", ""
|
||||||
|
case strings.HasPrefix(expr, "hl.dsp.focus("):
|
||||||
|
switch {
|
||||||
|
case luaTableStringField(expr, "direction") != "":
|
||||||
|
return "movefocus", luaTableStringField(expr, "direction")
|
||||||
|
case luaTableStringField(expr, "monitor") != "":
|
||||||
|
return "focusmonitor", luaTableStringField(expr, "monitor")
|
||||||
|
case luaTableStringField(expr, "workspace") != "":
|
||||||
|
return "workspace", luaTableStringField(expr, "workspace")
|
||||||
|
case luaTableStringField(expr, "window") != "":
|
||||||
|
return "focuswindow", luaTableStringField(expr, "window")
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(expr, "hl.dsp.window.move("):
|
||||||
|
switch {
|
||||||
|
case luaTableStringField(expr, "direction") != "":
|
||||||
|
return "movewindow", luaTableStringField(expr, "direction")
|
||||||
|
case luaTableStringField(expr, "monitor") != "":
|
||||||
|
return "movewindow", "mon:" + luaTableStringField(expr, "monitor")
|
||||||
|
case luaTableStringField(expr, "workspace") != "":
|
||||||
|
return "movetoworkspace", luaTableStringField(expr, "workspace")
|
||||||
|
}
|
||||||
|
case expr == "hl.dsp.window.drag()":
|
||||||
|
return "movewindow", ""
|
||||||
|
case expr == "hl.dsp.window.resize()":
|
||||||
|
return "resizewindow", ""
|
||||||
|
case strings.HasPrefix(expr, "hl.dsp.window.resize("):
|
||||||
|
x := luaStringValue(luaTableScalarField(expr, "x"))
|
||||||
|
y := luaStringValue(luaTableScalarField(expr, "y"))
|
||||||
|
if x != "" || y != "" {
|
||||||
|
if x == "" {
|
||||||
|
x = "0"
|
||||||
|
}
|
||||||
|
if y == "" {
|
||||||
|
y = "0"
|
||||||
|
}
|
||||||
|
return "resizeactive", x + " " + y
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(expr, "hl.dsp.layout("):
|
||||||
|
arg := extractLuaCallStringArg(expr, "hl.dsp.layout")
|
||||||
|
if arg != "" {
|
||||||
|
if u, err := strconv.Unquote(arg); err == nil {
|
||||||
|
return "layoutmsg", u
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(expr, "hl.dsp.dpms("):
|
||||||
|
if action := luaTableStringField(expr, "action"); action != "" {
|
||||||
|
return "dpms", action
|
||||||
|
}
|
||||||
|
case strings.Contains(expr, "hl.dsp.exit()"):
|
||||||
|
return "exit", ""
|
||||||
|
default:
|
||||||
|
return "exec", "hyprctl dispatch lua:" + expr
|
||||||
|
}
|
||||||
|
return "exec", "hyprctl dispatch lua:" + expr
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractLuaCallStringArg(callExpr, funcName string) string {
|
||||||
|
callExpr = strings.TrimSpace(callExpr)
|
||||||
|
prefix := funcName + "("
|
||||||
|
if !strings.HasPrefix(callExpr, prefix) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
inner := callExpr[len(prefix):]
|
||||||
|
inner = strings.TrimSpace(inner)
|
||||||
|
if len(inner) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch inner[0] {
|
||||||
|
case '"', '\'':
|
||||||
|
s, _, ok := parseLuaStringLiteral(inner, 0)
|
||||||
|
if ok {
|
||||||
|
return strconv.Quote(s)
|
||||||
|
}
|
||||||
|
case '[':
|
||||||
|
if strings.HasPrefix(inner, "[[") {
|
||||||
|
if end := strings.Index(inner[2:], "]]"); end >= 0 {
|
||||||
|
return strconv.Quote(inner[2 : 2+end])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func luaTableStringField(expr, field string) string {
|
||||||
|
return luaStringValue(luaTableScalarField(expr, field))
|
||||||
|
}
|
||||||
|
|
||||||
|
func luaTableScalarField(expr, field string) string {
|
||||||
|
re := regexp.MustCompile(`(?s)\b` + regexp.QuoteMeta(field) + `\s*=\s*("(?:\\.|[^"])*"|'(?:\\.|[^'])*'|\[\[.*?\]\]|-?\d+(?:\.\d+)?|true|false)`)
|
||||||
|
m := re.FindStringSubmatch(expr)
|
||||||
|
if len(m) < 2 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(m[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
func luaStringValue(raw string) string {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(raw, "[[") && strings.HasSuffix(raw, "]]") {
|
||||||
|
return raw[2 : len(raw)-2]
|
||||||
|
}
|
||||||
|
if len(raw) >= 2 {
|
||||||
|
q := raw[0]
|
||||||
|
if (q == '"' || q == '\'') && raw[len(raw)-1] == q {
|
||||||
|
if q == '"' {
|
||||||
|
if u, err := strconv.Unquote(raw); err == nil {
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.ReplaceAll(raw[1:len(raw)-1], `\'`, `'`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
func luaLineTrailingComment(line string) string {
|
||||||
|
if idx := strings.Index(line, "--"); idx >= 0 {
|
||||||
|
return strings.TrimSpace(line[idx+2:])
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDMSBindsSourcePath(p string) bool {
|
||||||
|
p = filepath.ToSlash(strings.TrimSpace(p))
|
||||||
|
if isDMSBindsPrimarySourcePath(p) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return isDMSBindsUserOverridePath(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDMSBindsUserOverridePath(p string) bool {
|
||||||
|
p = filepath.ToSlash(strings.TrimSpace(p))
|
||||||
|
return p == "dms/binds-user.lua" || p == "./dms/binds-user.lua" ||
|
||||||
|
strings.HasSuffix(p, "/dms/binds-user.lua")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDMSBindsPrimarySourcePath(p string) bool {
|
||||||
|
p = filepath.ToSlash(strings.TrimSpace(p))
|
||||||
|
if strings.Contains(p, "/dms/binds.lua") || strings.HasSuffix(p, "dms/binds.lua") || p == "dms/binds.lua" || p == "./dms/binds.lua" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.Contains(p, "/dms/binds.conf") || strings.HasSuffix(p, "dms/binds.conf") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return p == "dms/binds.conf" || p == "./dms/binds.conf"
|
||||||
|
}
|
||||||
|
|
||||||
|
// hyprlandMainConfigPath returns hyprland.lua if present, else hyprland.conf if present.
|
||||||
|
func hyprlandMainConfigPath(dir string) (string, error) {
|
||||||
|
expandedDir, err := utils.ExpandPath(dir)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
luaPath := filepath.Join(expandedDir, "hyprland.lua")
|
||||||
|
if st, err := os.Stat(luaPath); err == nil && st.Mode().IsRegular() {
|
||||||
|
return luaPath, nil
|
||||||
|
}
|
||||||
|
confPath := filepath.Join(expandedDir, "hyprland.conf")
|
||||||
|
if st, err := os.Stat(confPath); err == nil && st.Mode().IsRegular() {
|
||||||
|
return confPath, nil
|
||||||
|
}
|
||||||
|
return "", os.ErrNotExist
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ package providers
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestHyprlandAutogenerateComment(t *testing.T) {
|
func TestHyprlandAutogenerateComment(t *testing.T) {
|
||||||
@@ -60,6 +63,341 @@ func TestHyprlandAutogenerateComment(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHyprlandLuaBindRoundTripHelpers(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
expr string
|
||||||
|
wantDispatcher string
|
||||||
|
wantParams string
|
||||||
|
}{
|
||||||
|
{`hl.dsp.exec_cmd([[dms ipc call brightness increment 5 ""]])`, "exec", `dms ipc call brightness increment 5 ""`},
|
||||||
|
{`hl.dsp.window.fullscreen({ mode = "maximized", action = "toggle" })`, "fullscreen", "1"},
|
||||||
|
{`hl.dsp.focus({ workspace = "e+1" })`, "workspace", "e+1"},
|
||||||
|
{`hl.dsp.window.move({ monitor = "l" })`, "movewindow", "mon:l"},
|
||||||
|
{`hl.dsp.window.resize({ x = "-10%", y = 0, relative = true })`, "resizeactive", "-10% 0"},
|
||||||
|
{`hl.dsp.layout("togglesplit")`, "layoutmsg", "togglesplit"},
|
||||||
|
{`hl.dsp.dpms({ action = "toggle" })`, "dpms", "toggle"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.expr, func(t *testing.T) {
|
||||||
|
gotDispatcher, gotParams := luaExprToDispatcherParams(tt.expr)
|
||||||
|
if gotDispatcher != tt.wantDispatcher || gotParams != tt.wantParams {
|
||||||
|
t.Fatalf("luaExprToDispatcherParams() = %q, %q; want %q, %q", gotDispatcher, gotParams, tt.wantDispatcher, tt.wantParams)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteLuaBindLineOptionsInsideCall(t *testing.T) {
|
||||||
|
var sb strings.Builder
|
||||||
|
writeLuaBindLine(&sb, &hyprlandOverrideBind{
|
||||||
|
Key: "Super+k",
|
||||||
|
Action: "exec kitty",
|
||||||
|
Description: "Open terminal",
|
||||||
|
Flags: "led",
|
||||||
|
})
|
||||||
|
|
||||||
|
want := `hl.unbind("SUPER + K")
|
||||||
|
hl.bind("SUPER + K", hl.dsp.exec_cmd("kitty"), { locked = true, repeating = true, description = "Open terminal" })`
|
||||||
|
if got := strings.TrimSpace(sb.String()); got != want {
|
||||||
|
t.Fatalf("writeLuaBindLine() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteLuaBindLineMapsSpawnActionForHyprland(t *testing.T) {
|
||||||
|
var sb strings.Builder
|
||||||
|
writeLuaBindLine(&sb, &hyprlandOverrideBind{
|
||||||
|
Key: "Super+n",
|
||||||
|
Action: "spawn dms ipc call notepad toggle",
|
||||||
|
Description: "Notepad: Toggle",
|
||||||
|
})
|
||||||
|
|
||||||
|
want := `hl.unbind("SUPER + N")
|
||||||
|
hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle")) -- Notepad: Toggle`
|
||||||
|
if got := strings.TrimSpace(sb.String()); got != want {
|
||||||
|
t.Fatalf("writeLuaBindLine() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHyprlandLuaBindsUserOverridesDefaults(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
dmsDir := filepath.Join(tmpDir, "dms")
|
||||||
|
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "hyprland.lua"), []byte(`
|
||||||
|
require("dms.binds")
|
||||||
|
require("dms.binds-user")
|
||||||
|
`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dmsDir, "binds.lua"), []byte(`hl.bind("SUPER + T", hl.dsp.exec_cmd("kitty"))`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dmsDir, "binds-user.lua"), []byte(`hl.bind("SUPER + T", hl.dsp.exec_cmd("foot"), { description = "User terminal" })`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := ParseHyprlandKeysWithDMS(tmpDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var found []HyprlandKeyBinding
|
||||||
|
var walk func(HyprlandSection)
|
||||||
|
walk = func(section HyprlandSection) {
|
||||||
|
for _, kb := range section.Keybinds {
|
||||||
|
if strings.EqualFold(strings.Join(append(kb.Mods, kb.Key), "+"), "SUPER+T") {
|
||||||
|
found = append(found, kb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, child := range section.Children {
|
||||||
|
walk(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(*result.Section)
|
||||||
|
|
||||||
|
if len(found) != 1 {
|
||||||
|
t.Fatalf("expected one effective SUPER+T bind, got %d: %#v", len(found), found)
|
||||||
|
}
|
||||||
|
if found[0].Params != "foot" || found[0].Comment != "User terminal" {
|
||||||
|
t.Fatalf("expected user override bind, got %#v", found[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteLuaBindLineEmitsUnbindOnlyForNegativeOverride(t *testing.T) {
|
||||||
|
var sb strings.Builder
|
||||||
|
writeLuaBindLine(&sb, &hyprlandOverrideBind{Key: "Super+i", Unbind: true})
|
||||||
|
|
||||||
|
want := `hl.unbind("SUPER + I")`
|
||||||
|
if got := strings.TrimSpace(sb.String()); got != want {
|
||||||
|
t.Fatalf("writeLuaBindLine() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadLuaOverrideRecognizesLoneUnbindAsNegativeOverride(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
overridePath := filepath.Join(tmpDir, "binds-user.lua")
|
||||||
|
contents := `-- DMS user keybind overrides
|
||||||
|
hl.unbind("SUPER + I")
|
||||||
|
hl.unbind("SUPER + N")
|
||||||
|
hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle"))
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(overridePath, []byte(contents), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
binds, err := readLuaOrHyprlangOverride(overridePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, ok := binds["super+i"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected SUPER+I entry in override map, got: %#v", binds)
|
||||||
|
}
|
||||||
|
if !got.Unbind {
|
||||||
|
t.Fatalf("expected SUPER+I to be marked Unbind, got: %#v", got)
|
||||||
|
}
|
||||||
|
if rebind, ok := binds["super+n"]; !ok || rebind.Unbind {
|
||||||
|
t.Fatalf("expected SUPER+N to be a normal rebind override, got: %#v", rebind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParserDropsDMSDefaultsSuppressedByBindsUserUnbind(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
dmsDir := filepath.Join(tmpDir, "dms")
|
||||||
|
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "hyprland.lua"), []byte(`
|
||||||
|
require("dms.binds")
|
||||||
|
require("dms.binds-user")
|
||||||
|
`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dmsDir, "binds.lua"), []byte(
|
||||||
|
`hl.bind("SUPER + I", hl.dsp.focus({ workspace = "e-1" }))
|
||||||
|
hl.bind("SUPER + T", hl.dsp.exec_cmd("kitty"))`,
|
||||||
|
), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dmsDir, "binds-user.lua"), []byte(`hl.unbind("SUPER + I")`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := ParseHyprlandKeysWithDMS(tmpDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var keys []string
|
||||||
|
var walk func(HyprlandSection)
|
||||||
|
walk = func(section HyprlandSection) {
|
||||||
|
for _, kb := range section.Keybinds {
|
||||||
|
keys = append(keys, strings.ToUpper(strings.Join(append(kb.Mods, kb.Key), "+")))
|
||||||
|
}
|
||||||
|
for _, child := range section.Children {
|
||||||
|
walk(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(*result.Section)
|
||||||
|
|
||||||
|
for _, k := range keys {
|
||||||
|
if k == "SUPER+I" {
|
||||||
|
t.Fatalf("expected SUPER+I to be suppressed by binds-user.lua unbind, got: %v", keys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foundT := false
|
||||||
|
for _, k := range keys {
|
||||||
|
if k == "SUPER+T" {
|
||||||
|
foundT = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundT {
|
||||||
|
t.Fatalf("expected SUPER+T to remain (only SUPER+I was unbound), got: %v", keys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHyprlandRemoveBindWritesNegativeOverrideForDefault(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
dmsDir := filepath.Join(tmpDir, "dms")
|
||||||
|
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := NewHyprlandProvider(tmpDir)
|
||||||
|
if err := provider.RemoveBind("SUPER+I"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(filepath.Join(dmsDir, "binds-user.lua"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), `hl.unbind("SUPER + I")`) {
|
||||||
|
t.Fatalf("expected negative override hl.unbind line, got:\n%s", string(data))
|
||||||
|
}
|
||||||
|
if strings.Contains(string(data), `hl.bind("SUPER + I"`) {
|
||||||
|
t.Fatalf("expected NO hl.bind for SUPER+I, got:\n%s", string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHyprlandRemoveBindReplacesExistingOverrideWithNegativeOverride(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
dmsDir := filepath.Join(tmpDir, "dms")
|
||||||
|
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
override := `hl.unbind("SUPER + N")
|
||||||
|
hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle"))
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(dmsDir, "binds-user.lua"), []byte(override), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := NewHyprlandProvider(tmpDir)
|
||||||
|
if err := provider.RemoveBind("SUPER+N"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(filepath.Join(dmsDir, "binds-user.lua"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), `hl.unbind("SUPER + N")`) {
|
||||||
|
t.Fatalf("expected negative override hl.unbind line, got:\n%s", string(data))
|
||||||
|
}
|
||||||
|
if strings.Contains(string(data), `hl.bind("SUPER + N"`) {
|
||||||
|
t.Fatalf("expected NO hl.bind for SUPER+N after remove, got:\n%s", string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHyprlandResetBindRevertsExistingOverrideToDefault(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
dmsDir := filepath.Join(tmpDir, "dms")
|
||||||
|
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
override := `hl.unbind("SUPER + N")
|
||||||
|
hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle"))
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(dmsDir, "binds-user.lua"), []byte(override), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := NewHyprlandProvider(tmpDir)
|
||||||
|
if err := provider.ResetBind("SUPER+N"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(filepath.Join(dmsDir, "binds-user.lua"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(data), `SUPER + N`) {
|
||||||
|
t.Fatalf("expected SUPER+N to be fully removed (revert to default), got:\n%s", string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHyprlandHasDefaultSetForOverrideOfDefaultKey(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
dmsDir := filepath.Join(tmpDir, "dms")
|
||||||
|
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "hyprland.lua"), []byte(`
|
||||||
|
require("dms.binds")
|
||||||
|
require("dms.binds-user")
|
||||||
|
`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dmsDir, "binds.lua"), []byte(
|
||||||
|
`hl.bind("SUPER + T", hl.dsp.exec_cmd("kitty"))`,
|
||||||
|
), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dmsDir, "binds-user.lua"), []byte(
|
||||||
|
`hl.unbind("SUPER + T")
|
||||||
|
hl.bind("SUPER + T", hl.dsp.exec_cmd("foot"))
|
||||||
|
hl.bind("SUPER + Z", hl.dsp.exec_cmd("custom"))`,
|
||||||
|
), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := NewHyprlandProvider(tmpDir)
|
||||||
|
sheet, err := provider.GetCheatSheet()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var foundT, foundZ *keybinds.Keybind
|
||||||
|
for _, group := range sheet.Binds {
|
||||||
|
for i := range group {
|
||||||
|
kb := group[i]
|
||||||
|
keyUpper := strings.ToUpper(kb.Key)
|
||||||
|
if keyUpper == "SUPER+T" {
|
||||||
|
foundT = &group[i]
|
||||||
|
}
|
||||||
|
if keyUpper == "SUPER+Z" {
|
||||||
|
foundZ = &group[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if foundT == nil {
|
||||||
|
t.Fatalf("expected SUPER+T override in cheatsheet")
|
||||||
|
}
|
||||||
|
if !foundT.HasDefault {
|
||||||
|
t.Fatalf("expected SUPER+T HasDefault=true (default exists in binds.lua), got %+v", foundT)
|
||||||
|
}
|
||||||
|
if foundZ == nil {
|
||||||
|
t.Fatalf("expected SUPER+Z (user-only) in cheatsheet")
|
||||||
|
}
|
||||||
|
if foundZ.HasDefault {
|
||||||
|
t.Fatalf("expected SUPER+Z HasDefault=false (no default), got %+v", foundZ)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHyprlandGetKeybindAtLine(t *testing.T) {
|
func TestHyprlandGetKeybindAtLine(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ func (m *MangoWCProvider) convertKeybind(kb *MangoWCKeyBinding, conflicts map[st
|
|||||||
|
|
||||||
source := "config"
|
source := "config"
|
||||||
if strings.Contains(kb.Source, "dms/binds.conf") || strings.Contains(kb.Source, "dms"+string(filepath.Separator)+"binds.conf") {
|
if strings.Contains(kb.Source, "dms/binds.conf") || strings.Contains(kb.Source, "dms"+string(filepath.Separator)+"binds.conf") {
|
||||||
source = "dms"
|
source = "dms-default"
|
||||||
}
|
}
|
||||||
|
|
||||||
bind := keybinds.Keybind{
|
bind := keybinds.Keybind{
|
||||||
@@ -151,7 +151,7 @@ func (m *MangoWCProvider) convertKeybind(kb *MangoWCKeyBinding, conflicts map[st
|
|||||||
Source: source,
|
Source: source,
|
||||||
}
|
}
|
||||||
|
|
||||||
if source == "dms" && conflicts != nil {
|
if source == "dms-default" && conflicts != nil {
|
||||||
normalizedKey := strings.ToLower(keyStr)
|
normalizedKey := strings.ToLower(keyStr)
|
||||||
if conflictKb, ok := conflicts[normalizedKey]; ok {
|
if conflictKb, ok := conflicts[normalizedKey]; ok {
|
||||||
bind.Conflict = &keybinds.Keybind{
|
bind.Conflict = &keybinds.Keybind{
|
||||||
@@ -249,6 +249,10 @@ func (m *MangoWCProvider) RemoveBind(key string) error {
|
|||||||
return m.writeOverrideBinds(existingBinds)
|
return m.writeOverrideBinds(existingBinds)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *MangoWCProvider) ResetBind(key string) error {
|
||||||
|
return m.RemoveBind(key)
|
||||||
|
}
|
||||||
|
|
||||||
type mangowcOverrideBind struct {
|
type mangowcOverrideBind struct {
|
||||||
Key string
|
Key string
|
||||||
Action string
|
Action string
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
|
||||||
"github.com/sblinch/kdl-go"
|
|
||||||
"github.com/sblinch/kdl-go/document"
|
"github.com/sblinch/kdl-go/document"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -150,7 +149,7 @@ func (n *NiriProvider) convertKeybind(kb *NiriKeyBinding, subcategory string, co
|
|||||||
|
|
||||||
source := "config"
|
source := "config"
|
||||||
if strings.Contains(kb.Source, "dms/binds.kdl") {
|
if strings.Contains(kb.Source, "dms/binds.kdl") {
|
||||||
source = "dms"
|
source = "dms-default"
|
||||||
}
|
}
|
||||||
|
|
||||||
bind := keybinds.Keybind{
|
bind := keybinds.Keybind{
|
||||||
@@ -166,8 +165,8 @@ func (n *NiriProvider) convertKeybind(kb *NiriKeyBinding, subcategory string, co
|
|||||||
Repeat: kb.Repeat,
|
Repeat: kb.Repeat,
|
||||||
}
|
}
|
||||||
|
|
||||||
if source == "dms" && conflicts != nil {
|
if source == "dms-default" && conflicts != nil {
|
||||||
if conflictKb, ok := conflicts[keyStr]; ok {
|
if conflictKb, ok := conflicts[normalizeNiriBindKey(keyStr)]; ok {
|
||||||
bind.Conflict = &keybinds.Keybind{
|
bind.Conflict = &keybinds.Keybind{
|
||||||
Key: keyStr,
|
Key: keyStr,
|
||||||
Description: conflictKb.Description,
|
Description: conflictKb.Description,
|
||||||
@@ -250,7 +249,7 @@ func (n *NiriProvider) SetBind(key, action, description string, options map[stri
|
|||||||
existingBinds = make(map[string]*overrideBind)
|
existingBinds = make(map[string]*overrideBind)
|
||||||
}
|
}
|
||||||
|
|
||||||
existingBinds[key] = &overrideBind{
|
existingBinds[normalizeNiriBindKey(key)] = &overrideBind{
|
||||||
Key: key,
|
Key: key,
|
||||||
Action: action,
|
Action: action,
|
||||||
Description: description,
|
Description: description,
|
||||||
@@ -266,10 +265,14 @@ func (n *NiriProvider) RemoveBind(key string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
delete(existingBinds, key)
|
delete(existingBinds, normalizeNiriBindKey(key))
|
||||||
return n.writeOverrideBinds(existingBinds)
|
return n.writeOverrideBinds(existingBinds)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (n *NiriProvider) ResetBind(key string) error {
|
||||||
|
return n.RemoveBind(key)
|
||||||
|
}
|
||||||
|
|
||||||
type overrideBind struct {
|
type overrideBind struct {
|
||||||
Key string
|
Key string
|
||||||
Action string
|
Action string
|
||||||
@@ -292,7 +295,7 @@ func (n *NiriProvider) loadOverrideBinds() (map[string]*overrideBind, error) {
|
|||||||
parser := NewNiriParser(filepath.Dir(overridePath))
|
parser := NewNiriParser(filepath.Dir(overridePath))
|
||||||
parser.currentSource = overridePath
|
parser.currentSource = overridePath
|
||||||
|
|
||||||
doc, err := kdl.Parse(strings.NewReader(string(data)))
|
doc, err := parseKDL(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -313,7 +316,7 @@ func (n *NiriProvider) loadOverrideBinds() (map[string]*overrideBind, error) {
|
|||||||
action = n.formatRawAction(kb.Action, kb.Args)
|
action = n.formatRawAction(kb.Action, kb.Args)
|
||||||
}
|
}
|
||||||
|
|
||||||
binds[keyStr] = &overrideBind{
|
binds[normalizeNiriBindKey(keyStr)] = &overrideBind{
|
||||||
Key: keyStr,
|
Key: keyStr,
|
||||||
Action: action,
|
Action: action,
|
||||||
Description: kb.Description,
|
Description: kb.Description,
|
||||||
|
|||||||
@@ -50,6 +50,103 @@ type NiriParser struct {
|
|||||||
conflictingConfigs map[string]*NiriKeyBinding
|
conflictingConfigs map[string]*NiriKeyBinding
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseKDL(data []byte) (*document.Document, error) {
|
||||||
|
return kdl.Parse(strings.NewReader(normalizeKDLBraces(string(data))))
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeKDLBraces(input string) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.Grow(len(input))
|
||||||
|
|
||||||
|
var prev byte
|
||||||
|
n := len(input)
|
||||||
|
for i := 0; i < n; {
|
||||||
|
c := input[i]
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case c == '"':
|
||||||
|
end := findStringEnd(input, i)
|
||||||
|
sb.WriteString(input[i:end])
|
||||||
|
prev = '"'
|
||||||
|
i = end
|
||||||
|
case c == '/' && i+1 < n && input[i+1] == '/':
|
||||||
|
end := findLineCommentEnd(input, i)
|
||||||
|
sb.WriteString(input[i:end])
|
||||||
|
prev = '\n'
|
||||||
|
i = end
|
||||||
|
case c == '/' && i+1 < n && input[i+1] == '*':
|
||||||
|
end := findBlockCommentEnd(input, i)
|
||||||
|
sb.WriteString(input[i:end])
|
||||||
|
prev = '/'
|
||||||
|
i = end
|
||||||
|
case c == '{' && prev != 0 && !isBraceAdjacentSpace(prev):
|
||||||
|
sb.WriteByte(' ')
|
||||||
|
sb.WriteByte(c)
|
||||||
|
prev = c
|
||||||
|
i++
|
||||||
|
default:
|
||||||
|
sb.WriteByte(c)
|
||||||
|
prev = c
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func findStringEnd(s string, start int) int {
|
||||||
|
n := len(s)
|
||||||
|
for i := start + 1; i < n; {
|
||||||
|
switch s[i] {
|
||||||
|
case '\\':
|
||||||
|
i += 2
|
||||||
|
case '"':
|
||||||
|
return i + 1
|
||||||
|
default:
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func findLineCommentEnd(s string, start int) int {
|
||||||
|
for i := start + 2; i < len(s); i++ {
|
||||||
|
if s[i] == '\n' {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findBlockCommentEnd(s string, start int) int {
|
||||||
|
n := len(s)
|
||||||
|
depth := 1
|
||||||
|
for i := start + 2; i < n && depth > 0; {
|
||||||
|
switch {
|
||||||
|
case i+1 < n && s[i] == '/' && s[i+1] == '*':
|
||||||
|
depth++
|
||||||
|
i += 2
|
||||||
|
case i+1 < n && s[i] == '*' && s[i+1] == '/':
|
||||||
|
depth--
|
||||||
|
i += 2
|
||||||
|
if depth == 0 {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func isBraceAdjacentSpace(b byte) bool {
|
||||||
|
switch b {
|
||||||
|
case ' ', '\t', '\n', '\r', '{':
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func NewNiriParser(configDir string) *NiriParser {
|
func NewNiriParser(configDir string) *NiriParser {
|
||||||
return &NiriParser{
|
return &NiriParser{
|
||||||
configDir: configDir,
|
configDir: configDir,
|
||||||
@@ -65,6 +162,14 @@ func NewNiriParser(configDir string) *NiriParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeNiriBindKey(key string) string {
|
||||||
|
parts := strings.Split(key, "+")
|
||||||
|
for i := range parts {
|
||||||
|
parts[i] = strings.ToLower(strings.TrimSpace(parts[i]))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "+")
|
||||||
|
}
|
||||||
|
|
||||||
func (p *NiriParser) Parse() (*NiriSection, error) {
|
func (p *NiriParser) Parse() (*NiriSection, error) {
|
||||||
dmsBindsPath := filepath.Join(p.configDir, "dms", "binds.kdl")
|
dmsBindsPath := filepath.Join(p.configDir, "dms", "binds.kdl")
|
||||||
if _, err := os.Stat(dmsBindsPath); err == nil {
|
if _, err := os.Stat(dmsBindsPath); err == nil {
|
||||||
@@ -91,7 +196,7 @@ func (p *NiriParser) parseDMSBindsDirectly(dmsBindsPath string, section *NiriSec
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
doc, err := kdl.Parse(strings.NewReader(string(data)))
|
doc, err := parseKDL(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -116,24 +221,25 @@ func (p *NiriParser) finalizeBinds() []NiriKeyBinding {
|
|||||||
|
|
||||||
func (p *NiriParser) addBind(kb *NiriKeyBinding) {
|
func (p *NiriParser) addBind(kb *NiriKeyBinding) {
|
||||||
key := p.formatBindKey(kb)
|
key := p.formatBindKey(kb)
|
||||||
|
normalizedKey := normalizeNiriBindKey(key)
|
||||||
isDMSBind := strings.Contains(kb.Source, "dms/binds.kdl")
|
isDMSBind := strings.Contains(kb.Source, "dms/binds.kdl")
|
||||||
|
|
||||||
if isDMSBind {
|
if isDMSBind {
|
||||||
p.dmsBindKeys[key] = true
|
p.dmsBindKeys[normalizedKey] = true
|
||||||
p.dmsBindMap[key] = kb
|
p.dmsBindMap[normalizedKey] = kb
|
||||||
} else if p.dmsBindKeys[key] {
|
} else if p.dmsBindKeys[normalizedKey] {
|
||||||
p.bindsAfterDMS++
|
p.bindsAfterDMS++
|
||||||
p.conflictingConfigs[key] = kb
|
p.conflictingConfigs[normalizedKey] = kb
|
||||||
p.configBindKeys[key] = true
|
p.configBindKeys[normalizedKey] = true
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
p.configBindKeys[key] = true
|
p.configBindKeys[normalizedKey] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, exists := p.bindMap[key]; !exists {
|
if _, exists := p.bindMap[normalizedKey]; !exists {
|
||||||
p.bindOrder = append(p.bindOrder, key)
|
p.bindOrder = append(p.bindOrder, normalizedKey)
|
||||||
}
|
}
|
||||||
p.bindMap[key] = kb
|
p.bindMap[normalizedKey] = kb
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *NiriParser) formatBindKey(kb *NiriKeyBinding) string {
|
func (p *NiriParser) formatBindKey(kb *NiriKeyBinding) string {
|
||||||
@@ -159,7 +265,7 @@ func (p *NiriParser) parseFile(filePath, sectionName string) (*NiriSection, erro
|
|||||||
return nil, fmt.Errorf("failed to read %s: %w", absPath, err)
|
return nil, fmt.Errorf("failed to read %s: %w", absPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
doc, err := kdl.Parse(strings.NewReader(string(data)))
|
doc, err := parseKDL(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse KDL in %s: %w", absPath, err)
|
return nil, fmt.Errorf("failed to parse KDL in %s: %w", absPath, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,74 @@ package providers
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestNiriParse_NoSpaceBeforeBrace(t *testing.T) {
|
||||||
|
config := `recent-windows {
|
||||||
|
binds {
|
||||||
|
Alt+Tab { next-window scope="output"; }
|
||||||
|
Alt+Shift+Tab { previous-window scope="output"; }
|
||||||
|
Alt+grave { next-window filter="app-id"; }
|
||||||
|
Alt+Shift+grave { previous-window filter="app-id"; }
|
||||||
|
Alt+Escape { next-window scope="all"; }
|
||||||
|
Alt+Shift+Escape{ previous-window scope="all"; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "config.kdl"), []byte(config), 0o644); err != nil {
|
||||||
|
t.Fatalf("Failed to write test config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := ParseNiriKeys(tmpDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseNiriKeys failed on valid niri config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var found *NiriKeyBinding
|
||||||
|
for i := range result.Section.Keybinds {
|
||||||
|
kb := &result.Section.Keybinds[i]
|
||||||
|
if kb.Key == "Escape" && slices.Contains(kb.Mods, "Alt") && slices.Contains(kb.Mods, "Shift") {
|
||||||
|
found = kb
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found == nil {
|
||||||
|
t.Fatal("Alt+Shift+Escape bind missing — '{' without preceding space was not handled")
|
||||||
|
}
|
||||||
|
if found.Action != "previous-window" {
|
||||||
|
t.Errorf("Action = %q, want %q", found.Action, "previous-window")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeKDLBraces(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
out string
|
||||||
|
}{
|
||||||
|
{"already spaced", "node { child }\n", "node { child }\n"},
|
||||||
|
{"missing space", "node{ child }\n", "node { child }\n"},
|
||||||
|
{"niri keybind", "Alt+Shift+Escape{ previous-window; }", "Alt+Shift+Escape { previous-window; }"},
|
||||||
|
{"brace inside string", `node "a{b" { child }`, `node "a{b" { child }`},
|
||||||
|
{"brace in line comment", "// foo{bar\nnode { }", "// foo{bar\nnode { }"},
|
||||||
|
{"brace in block comment", "/* foo{bar */ node{ }", "/* foo{bar */ node { }"},
|
||||||
|
{"escaped quote in string", `node "a\"b{c" { }`, `node "a\"b{c" { }`},
|
||||||
|
{"leading brace", "{ child }", "{ child }"},
|
||||||
|
{"nested missing space", "a{b{ c }}", "a {b { c }}"},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := normalizeKDLBraces(tc.in)
|
||||||
|
if got != tc.out {
|
||||||
|
t.Errorf("normalizeKDLBraces(%q) = %q, want %q", tc.in, got, tc.out)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNiriParseKeyCombo(t *testing.T) {
|
func TestNiriParseKeyCombo(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
combo string
|
combo string
|
||||||
@@ -461,6 +526,50 @@ binds {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNiriKeyIdentityIsCaseInsensitive(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
dmsDir := filepath.Join(tmpDir, "dms")
|
||||||
|
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("Failed to create dms dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
config := `binds {
|
||||||
|
Alt+Space hotkey-overlay-title="Spotlight Bar" { spawn "dms" "ipc" "call" "spotlight-bar" "toggle"; }
|
||||||
|
}
|
||||||
|
include "dms/binds.kdl"
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "config.kdl"), []byte(config), 0o644); err != nil {
|
||||||
|
t.Fatalf("Failed to write config: %v", err)
|
||||||
|
}
|
||||||
|
include := `binds {
|
||||||
|
Alt+space hotkey-overlay-title="Default Launcher" { spawn "dms" "ipc" "call" "spotlight" "toggle"; }
|
||||||
|
}
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(dmsDir, "binds.kdl"), []byte(include), 0o644); err != nil {
|
||||||
|
t.Fatalf("Failed to write binds include: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := ParseNiriKeys(tmpDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseNiriKeys failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var altSpaceBinds []NiriKeyBinding
|
||||||
|
parser := NewNiriParser("")
|
||||||
|
for _, kb := range result.Section.Keybinds {
|
||||||
|
if normalizeNiriBindKey(parser.formatBindKey(&kb)) == "alt+space" {
|
||||||
|
altSpaceBinds = append(altSpaceBinds, kb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(altSpaceBinds) != 1 {
|
||||||
|
t.Fatalf("Expected one Alt+Space identity, got %d", len(altSpaceBinds))
|
||||||
|
}
|
||||||
|
if got := altSpaceBinds[0].Args; len(got) < 5 || got[3] != "spotlight" || got[4] != "toggle" {
|
||||||
|
t.Fatalf("Expected later DMS include to win with spotlight toggle, got action=%s args=%v", altSpaceBinds[0].Action, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNiriParseMultipleArgs(t *testing.T) {
|
func TestNiriParseMultipleArgs(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
configFile := filepath.Join(tmpDir, "config.kdl")
|
configFile := filepath.Join(tmpDir, "config.kdl")
|
||||||
|
|||||||
@@ -367,7 +367,7 @@ func TestNiriEmptyArgsPreservation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for key, expected := range binds {
|
for key, expected := range binds {
|
||||||
loaded, ok := loadedBinds[key]
|
loaded, ok := loadedBinds[normalizeNiriBindKey(key)]
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Errorf("Missing bind for key %s", key)
|
t.Errorf("Missing bind for key %s", key)
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type Keybind struct {
|
|||||||
AllowInhibiting *bool `json:"allowInhibiting,omitempty"` // nil=default(true), false=explicitly disabled
|
AllowInhibiting *bool `json:"allowInhibiting,omitempty"` // nil=default(true), false=explicitly disabled
|
||||||
Repeat *bool `json:"repeat,omitempty"` // nil=default(true), false=explicitly disabled
|
Repeat *bool `json:"repeat,omitempty"` // nil=default(true), false=explicitly disabled
|
||||||
Conflict *Keybind `json:"conflict,omitempty"`
|
Conflict *Keybind `json:"conflict,omitempty"`
|
||||||
|
HasDefault bool `json:"hasDefault,omitempty"` // override has a DMS default to revert to
|
||||||
}
|
}
|
||||||
|
|
||||||
type DMSBindsStatus struct {
|
type DMSBindsStatus struct {
|
||||||
@@ -42,6 +43,11 @@ type Provider interface {
|
|||||||
type WritableProvider interface {
|
type WritableProvider interface {
|
||||||
Provider
|
Provider
|
||||||
SetBind(key, action, description string, options map[string]any) error
|
SetBind(key, action, description string, options map[string]any) error
|
||||||
|
// RemoveBind removes the bind. Hyprland writes a negative override to
|
||||||
|
// dms/binds-user.lua; single-file providers delete the line.
|
||||||
RemoveBind(key string) error
|
RemoveBind(key string) error
|
||||||
|
// ResetBind reverts a user override to its DMS default. On single-file
|
||||||
|
// providers this aliases to RemoveBind.
|
||||||
|
ResetBind(key string) error
|
||||||
GetOverridePath() string
|
GetOverridePath() string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
package log
|
package log
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
cblog "github.com/charmbracelet/log"
|
cblog "github.com/charmbracelet/log"
|
||||||
|
"github.com/mattn/go-isatty"
|
||||||
|
"github.com/muesli/termenv"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Logger embeds the Charm Logger and adds Printf/Fatalf
|
// Logger embeds the Charm Logger and adds Printf/Fatalf
|
||||||
@@ -21,8 +25,26 @@ func (l *Logger) Fatalf(format string, v ...any) { l.Logger.Fatalf(format, v...)
|
|||||||
var (
|
var (
|
||||||
logger *Logger
|
logger *Logger
|
||||||
initLogger sync.Once
|
initLogger sync.Once
|
||||||
|
|
||||||
|
logMu sync.Mutex
|
||||||
|
logFile *os.File
|
||||||
|
logStderr io.Writer = os.Stderr
|
||||||
|
|
||||||
|
ansiRe = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ansiStripWriter strips ANSI escape sequences before forwarding to w. Used
|
||||||
|
// for the file sink so colored stderr stays colored while the file stays plain.
|
||||||
|
type ansiStripWriter struct{ w io.Writer }
|
||||||
|
|
||||||
|
func (a *ansiStripWriter) Write(p []byte) (int, error) {
|
||||||
|
stripped := ansiRe.ReplaceAll(p, nil)
|
||||||
|
if _, err := a.w.Write(stripped); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
func parseLogLevel(level string) cblog.Level {
|
func parseLogLevel(level string) cblog.Level {
|
||||||
switch strings.ToLower(level) {
|
switch strings.ToLower(level) {
|
||||||
case "debug":
|
case "debug":
|
||||||
@@ -86,7 +108,7 @@ func GetLogger() *Logger {
|
|||||||
SetString(" DEBUG").
|
SetString(" DEBUG").
|
||||||
Foreground(lipgloss.Color("4"))
|
Foreground(lipgloss.Color("4"))
|
||||||
|
|
||||||
base := cblog.New(os.Stderr)
|
base := cblog.New(logStderr)
|
||||||
base.SetStyles(styles)
|
base.SetStyles(styles)
|
||||||
base.SetReportTimestamp(false)
|
base.SetReportTimestamp(false)
|
||||||
|
|
||||||
@@ -98,10 +120,85 @@ func GetLogger() *Logger {
|
|||||||
base.SetPrefix(" go")
|
base.SetPrefix(" go")
|
||||||
|
|
||||||
logger = &Logger{base}
|
logger = &Logger{base}
|
||||||
|
|
||||||
|
if path := os.Getenv("DMS_LOG_FILE"); path != "" {
|
||||||
|
_ = SetLogFile(path)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
return logger
|
return logger
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetLevel updates the active log level. Accepts the same strings as
|
||||||
|
// DMS_LOG_LEVEL. Unknown values default to info.
|
||||||
|
func SetLevel(level string) {
|
||||||
|
GetLogger().SetLevel(parseLogLevel(level))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLogFile makes the logger append to path in addition to stderr. Passing an
|
||||||
|
// empty string detaches the file sink. Atomic per-line writes (≤PIPE_BUF) on
|
||||||
|
// O_APPEND keep concurrent Go and QML writers from corrupting each other.
|
||||||
|
//
|
||||||
|
// Color handling: charmbracelet/log auto-detects color support from its
|
||||||
|
// io.Writer, and io.MultiWriter doesn't pass that through, so we force the ANSI
|
||||||
|
// profile when stderr is a TTY and route the file through ansiStripWriter so
|
||||||
|
// the file stays plain while stderr keeps its colors.
|
||||||
|
func SetLogFile(path string) error {
|
||||||
|
logMu.Lock()
|
||||||
|
defer logMu.Unlock()
|
||||||
|
|
||||||
|
if logFile != nil {
|
||||||
|
logFile.Close()
|
||||||
|
logFile = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
l := GetLogger()
|
||||||
|
if path == "" {
|
||||||
|
l.SetOutput(logStderr)
|
||||||
|
applyColorProfile(l, logStderr)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
logFile = f
|
||||||
|
out := io.MultiWriter(logStderr, &ansiStripWriter{w: f})
|
||||||
|
l.SetOutput(out)
|
||||||
|
applyColorProfile(l, logStderr)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyColorProfile forces the renderer's color profile to match what stderr
|
||||||
|
// would produce on its own, undoing the auto-downgrade triggered by wrapping
|
||||||
|
// stderr in a non-TTY writer (e.g. io.MultiWriter).
|
||||||
|
func applyColorProfile(l *Logger, stderr io.Writer) {
|
||||||
|
f, ok := stderr.(*os.File)
|
||||||
|
if !ok {
|
||||||
|
l.SetColorProfile(termenv.Ascii)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if isatty.IsTerminal(f.Fd()) {
|
||||||
|
l.SetColorProfile(termenv.ANSI)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l.SetColorProfile(termenv.Ascii)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyEnvOverrides re-reads DMS_LOG_LEVEL and DMS_LOG_FILE and reconfigures
|
||||||
|
// the singleton. Safe to call after CLI flags have rewritten the environment.
|
||||||
|
func ApplyEnvOverrides() {
|
||||||
|
GetLogger()
|
||||||
|
if level := os.Getenv("DMS_LOG_LEVEL"); level != "" {
|
||||||
|
SetLevel(level)
|
||||||
|
}
|
||||||
|
if path := os.Getenv("DMS_LOG_FILE"); path != "" {
|
||||||
|
if err := SetLogFile(path); err != nil {
|
||||||
|
Warnf("Failed to open log file %q: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// * Convenience wrappers
|
// * Convenience wrappers
|
||||||
|
|
||||||
func Debug(msg any, keyvals ...any) { GetLogger().Debug(msg, keyvals...) }
|
func Debug(msg any, keyvals ...any) { GetLogger().Debug(msg, keyvals...) }
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package luaconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var luaRequireRE = regexp.MustCompile(`(?i)\brequire\s*\(\s*["']([^"']+)["']\s*\)`)
|
||||||
|
|
||||||
|
func ModuleToRelPath(module string) string {
|
||||||
|
module = strings.TrimSpace(module)
|
||||||
|
if module == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
module = strings.NewReplacer(".", string(filepath.Separator), "/", string(filepath.Separator)).Replace(module)
|
||||||
|
return filepath.Clean(module + ".lua")
|
||||||
|
}
|
||||||
|
|
||||||
|
func ModuleToPath(baseDir, module string) string {
|
||||||
|
rel := ModuleToRelPath(module)
|
||||||
|
if rel == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return filepath.Clean(filepath.Join(baseDir, rel))
|
||||||
|
}
|
||||||
|
|
||||||
|
func Requires(line string) []string {
|
||||||
|
line = stripLineComment(line)
|
||||||
|
if strings.TrimSpace(line) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
matches := luaRequireRE.FindAllStringSubmatch(line, -1)
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
modules := make([]string, 0, len(matches))
|
||||||
|
for _, match := range matches {
|
||||||
|
if len(match) > 1 && strings.TrimSpace(match[1]) != "" {
|
||||||
|
modules = append(modules, strings.TrimSpace(match[1]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return modules
|
||||||
|
}
|
||||||
|
|
||||||
|
func Require(line string) (string, bool) {
|
||||||
|
modules := Requires(line)
|
||||||
|
if len(modules) != 1 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return modules[0], true
|
||||||
|
}
|
||||||
|
|
||||||
|
func RequiresTarget(filePath, targetAbs string, processed map[string]bool) bool {
|
||||||
|
absPath, err := filepath.Abs(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return requiresTarget(absPath, filepath.Dir(absPath), targetAbs, processed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requiresTarget(filePath, rootDir, targetAbs string, processed map[string]bool) bool {
|
||||||
|
absPath, err := filepath.Abs(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
targetAbsClean := filepath.Clean(targetAbs)
|
||||||
|
|
||||||
|
if processed[absPath] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
processed[absPath] = true
|
||||||
|
|
||||||
|
data, err := os.ReadFile(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, raw := range strings.Split(string(data), "\n") {
|
||||||
|
for _, module := range Requires(raw) {
|
||||||
|
candidate := ModuleToPath(rootDir, module)
|
||||||
|
if candidate == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if filepath.Clean(candidate) == targetAbsClean {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
|
||||||
|
if requiresTarget(candidate, rootDir, targetAbs, processed) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripLineComment(line string) string {
|
||||||
|
inStr := byte(0)
|
||||||
|
esc := false
|
||||||
|
for i := 0; i+1 < len(line); i++ {
|
||||||
|
c := line[i]
|
||||||
|
if inStr != 0 {
|
||||||
|
if esc {
|
||||||
|
esc = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c == '\\' && inStr == '"' {
|
||||||
|
esc = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c == inStr {
|
||||||
|
inStr = 0
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch c {
|
||||||
|
case '"', '\'':
|
||||||
|
inStr = c
|
||||||
|
case '-':
|
||||||
|
if line[i+1] == '-' {
|
||||||
|
return line[:i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return line
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package luaconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestModuleToRelPath(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
"dms.binds": filepath.Join("dms", "binds.lua"),
|
||||||
|
"dms/binds-user": filepath.Join("dms", "binds-user.lua"),
|
||||||
|
"awesome/anim": filepath.Join("awesome", "anim.lua"),
|
||||||
|
"awesome.colors": filepath.Join("awesome", "colors.lua"),
|
||||||
|
" awesome.binds ": filepath.Join("awesome", "binds.lua"),
|
||||||
|
}
|
||||||
|
|
||||||
|
for input, want := range tests {
|
||||||
|
if got := ModuleToRelPath(input); got != want {
|
||||||
|
t.Fatalf("ModuleToRelPath(%q) = %q, want %q", input, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequiresSkipsComments(t *testing.T) {
|
||||||
|
if modules := Requires(`-- require("dms.binds")`); len(modules) != 0 {
|
||||||
|
t.Fatalf("expected commented require to be ignored, got %#v", modules)
|
||||||
|
}
|
||||||
|
|
||||||
|
modules := Requires(`print("-- not a comment") require("dms.binds") -- require("ignored")`)
|
||||||
|
if len(modules) != 1 || modules[0] != "dms.binds" {
|
||||||
|
t.Fatalf("unexpected modules: %#v", modules)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequiresTargetRecurses(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
dmsDir := filepath.Join(tmpDir, "dms")
|
||||||
|
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
target := filepath.Join(dmsDir, "windowrules.lua")
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "hyprland.lua"), []byte(`require("dms.extra")`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dmsDir, "extra.lua"), []byte(`require("dms.windowrules")`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(target, []byte(`-- rules`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !RequiresTarget(filepath.Join(tmpDir, "hyprland.lua"), target, make(map[string]bool)) {
|
||||||
|
t.Fatal("expected recursive require lookup to find target")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,6 +60,7 @@ var templateRegistry = []TemplateDef{
|
|||||||
{ID: "pywalfox", Commands: []string{"pywalfox"}, ConfigFile: "pywalfox.toml"},
|
{ID: "pywalfox", Commands: []string{"pywalfox"}, ConfigFile: "pywalfox.toml"},
|
||||||
{ID: "zenbrowser", Commands: []string{"zen", "zen-browser", "zen-beta", "zen-twilight"}, Flatpaks: []string{"app.zen_browser.zen"}, ConfigFile: "zenbrowser.toml"},
|
{ID: "zenbrowser", Commands: []string{"zen", "zen-browser", "zen-beta", "zen-twilight"}, Flatpaks: []string{"app.zen_browser.zen"}, ConfigFile: "zenbrowser.toml"},
|
||||||
{ID: "vesktop", Commands: []string{"vesktop"}, Flatpaks: []string{"dev.vencord.Vesktop"}, ConfigFile: "vesktop.toml"},
|
{ID: "vesktop", Commands: []string{"vesktop"}, Flatpaks: []string{"dev.vencord.Vesktop"}, ConfigFile: "vesktop.toml"},
|
||||||
|
{ID: "vencord", Commands: []string{"discord", "Discord", "discord-canary", "DiscordCanary"}, Flatpaks: []string{"com.discordapp.Discord", "com.discordapp.DiscordCanary"}, ConfigFile: "vencord.toml"},
|
||||||
{ID: "equibop", Commands: []string{"equibop"}, ConfigFile: "equibop.toml"},
|
{ID: "equibop", Commands: []string{"equibop"}, ConfigFile: "equibop.toml"},
|
||||||
{ID: "ghostty", Commands: []string{"ghostty"}, ConfigFile: "ghostty.toml", Kind: TemplateKindTerminal},
|
{ID: "ghostty", Commands: []string{"ghostty"}, ConfigFile: "ghostty.toml", Kind: TemplateKindTerminal},
|
||||||
{ID: "kitty", Commands: []string{"kitty"}, ConfigFile: "kitty.toml", Kind: TemplateKindTerminal},
|
{ID: "kitty", Commands: []string{"kitty"}, ConfigFile: "kitty.toml", Kind: TemplateKindTerminal},
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user