mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-08-02 03:28:28 -04:00
Compare commits
418 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 069ddab041 | |||
| 45cf6ecefd | |||
| 7058b00091 | |||
| 6ba4b79039 | |||
| b8e2ce1da8 | |||
| c58d55db7d | |||
| e15db00714 | |||
| 906eba5c17 | |||
| d2b61b91ef | |||
| 6c27b92445 | |||
| a64bd80cc9 | |||
| 661dc27d26 | |||
| 3d5a6fb9d7 | |||
| c2a5397d8a | |||
| 9b5538323d | |||
| 8321d28d18 | |||
| 39278860c8 | |||
| f455456ff1 | |||
| 5a80a32951 | |||
| 3cedd56f23 | |||
| dc1ad5b329 | |||
| 25c12a14a9 | |||
| b72b276fb4 | |||
| f89fb67316 | |||
| f457e04cb1 | |||
| 23daca0b57 | |||
| accdd0972c | |||
| 00c28f69fd | |||
| 2e0337b68c | |||
| 4975960ea4 | |||
| 0381a112b3 | |||
| ed5e2a22c0 | |||
| 847718459c | |||
| 95b120b475 | |||
| 4dd2f1d5be | |||
| bf5faf725b | |||
| e5eff919b8 | |||
| 606115748a | |||
| ad936bfdc9 | |||
| 110396cb2a | |||
| 7b08e61863 | |||
| 876ead665b | |||
| 74896fb87c | |||
| f1f528b2cb | |||
| 9853e1431b | |||
| e7b8196a4a | |||
| 85ebb402cb | |||
| f37f4a1f35 | |||
| fc11dfbc57 | |||
| 7806cf4a58 | |||
| 89895da4fa | |||
| 4ff93bf46e | |||
| 63ffd7caec | |||
| dac08b34c6 | |||
| 840a448b4a | |||
| 26d429fa31 | |||
| fd20cce9b7 | |||
| ad5a96d95b | |||
| 8ba5e1a082 | |||
| 7d6a7bae06 | |||
| 986a6e0230 | |||
| 64ba1fbb1b | |||
| ab9df791b3 | |||
| 65f4a9a044 | |||
| 59164cbd30 | |||
| 7ab1f6a956 | |||
| 841167ac44 | |||
| c2a6a7aebd | |||
| b17ef80c91 | |||
| a55177f1d2 | |||
| 3d0ce17a8c | |||
| 6306bc7aa2 | |||
| eca92898f9 | |||
| db9fb0bd90 | |||
| 815820aa27 | |||
| 01c0ed2778 | |||
| 9967db0c5a | |||
| 883a787db7 | |||
| 3b1a3c1c22 | |||
| 970262f276 | |||
| b708e97ec5 | |||
| e94fc7fe99 | |||
| c4bca6f6cd | |||
| 9e7fc833e9 | |||
| 9e2d493749 | |||
| 93120fd73b | |||
| 536ebf7b0a | |||
| 1105047a80 | |||
| 8b46397200 | |||
| 6b70491ba9 | |||
| deb0138198 | |||
| 2e0587ca16 | |||
| 45cafc6bdb | |||
| 82d5081b7d | |||
| b5b083305f | |||
| 039d14c790 | |||
| ee7ac9d2bd | |||
| bd159be414 | |||
| 4dda0e3a5e | |||
| 724ee9422d | |||
| c91cb5d89a | |||
| 660953ebc5 | |||
| bd70a6286e | |||
| d87e2fcb01 | |||
| 928b272628 | |||
| 781ed16538 | |||
| 417da7408d | |||
| 0d550662de | |||
| 35e35a16fa | |||
| d96559f7df | |||
| 7f37799e53 | |||
| fcda17f517 | |||
| 4b05e7a0fa | |||
| 7c6ad65b5a | |||
| acda199e28 | |||
| b3a129af2f | |||
| e9d958b32b | |||
| dd9edd8a00 | |||
| 28c25d46d5 | |||
| 58530ae56c | |||
| c0134c249a | |||
| f7157f0e92 | |||
| e29b9749df | |||
| 6791c20f40 | |||
| 6c2572fbfe | |||
| 3ee07f7f20 | |||
| ef244cb645 | |||
| 860c347362 | |||
| c7b58e52d7 | |||
| e0fb817a75 | |||
| b5908f4d1b | |||
| ee6c91e094 | |||
| 59d367f4a4 | |||
| ee84489b95 | |||
| b1beecb083 | |||
| ffd3840058 | |||
| 447b4d89b6 | |||
| 36772735f3 | |||
| 375afaaf3a | |||
| fe0214d522 | |||
| fce1a09790 | |||
| 554e55c63a | |||
| f7d8e2b56c | |||
| 1d4572ac1c | |||
| c02a24fecf | |||
| bf644516f8 | |||
| 61f9ea4954 | |||
| 5a169bea72 | |||
| 3d60f35683 | |||
| b95f4cdbf3 | |||
| b834e82668 | |||
| 302cfda2f4 | |||
| c86676df9b | |||
| fa26dbb544 | |||
| 067d01f678 | |||
| 95c8cc80f6 | |||
| b8f216dc0f | |||
| e84e63fa3a | |||
| 6d08b91004 | |||
| bdfd565b72 | |||
| f3b698590c | |||
| 7826d42ce4 | |||
| 6cc30c289b | |||
| 43cc8e69d1 | |||
| a62ae336e0 | |||
| e94af2a7aa | |||
| bb9e69e0c1 | |||
| 1cc9218ff6 | |||
| 56712f46fa | |||
| 93168ee073 | |||
| 1724aedd49 | |||
| d799175c07 | |||
| ee6f7b4798 | |||
| 0511cd19df | |||
| 71b1901ab0 | |||
| 32e2d96e55 | |||
| 647766b9fa | |||
| 0cd8974110 | |||
| 0c4c5fc146 | |||
| b447e16374 | |||
| 0bb8353a33 | |||
| 19a7dcf17d | |||
| 8a1acb63c9 | |||
| 6f298d3f52 | |||
| 3e481a566b | |||
| 405ea708b3 | |||
| cc0bec2682 | |||
| 240adfa28f | |||
| c175f4823a | |||
| 35613ebba8 | |||
| 583e46f2f0 | |||
| 590555dfc6 | |||
| c11169eb4c | |||
| bddcdb3f99 | |||
| 6ec67cefd0 | |||
| 8d9d7ff0ef | |||
| acc39ceb16 | |||
| 43863a86fb | |||
| 36ad34a555 | |||
| 78e823e23a | |||
| b6a27dc713 | |||
| 79dfd34ca2 | |||
| eedba0e8c9 | |||
| 2b12292895 | |||
| dd7f4f3ac5 | |||
| 3df801e3b2 | |||
| 74d220767d | |||
| a3fba8fcb6 | |||
| 33384e4d5d | |||
| 78f2ea4642 | |||
| 7dffe85e94 | |||
| c5c3105469 | |||
| 8f100960cf | |||
| 8a5e7c78fb | |||
| 8a85ab06a1 | |||
| 30ceda97a0 | |||
| fefc0afa6f | |||
| b01042d530 | |||
| 7355cc54b2 | |||
| 187b7ba8ff | |||
| c0e7c1b2b4 | |||
| ea0c235d6e | |||
| 57a04cf409 | |||
| fd99558ce5 | |||
| 81a4d3b4e0 | |||
| bfda5f2d66 | |||
| 65c4167ab2 | |||
| e7bd8e4002 | |||
| 25a1b125b7 | |||
| d766c0773c | |||
| 5d12962fd6 | |||
| 9911698237 | |||
| 7e095415a7 | |||
| 68d5d7c5ed | |||
| 7b1c542585 | |||
| 52ed719489 | |||
| 6e508d416d | |||
| 518cc93276 | |||
| 8cb4e21716 | |||
| 19b44b1782 | |||
| 0509694d78 | |||
| da44e07cf7 | |||
| b34941e3b8 | |||
| c554d973ef | |||
| 2861cc89c6 | |||
| 1c5965f1e4 | |||
| 66d552b3d5 | |||
| ebad8e2dda | |||
| 8dadf73d52 | |||
| 9f77b3127c | |||
| 46f8457777 | |||
| 8465ed4311 | |||
| 6e3e178721 | |||
| 27230227ef | |||
| 7178563c2c | |||
| dfe309a543 | |||
| 04962ffed1 | |||
| 5008406be8 | |||
| 930a36bcfc | |||
| 3253adb6dd | |||
| b8fd52e00f | |||
| 8e7126472e | |||
| 61108b7668 | |||
| 09ca37c9fc | |||
| a2513e939a | |||
| a73380b310 | |||
| 69d7c1fb13 | |||
| d1eb655e46 | |||
| a3ecef7627 | |||
| 186621ab81 | |||
| 31efe9b385 | |||
| 6cdb891551 | |||
| 3bb3da228c | |||
| 0c99e4b8d1 | |||
| 8b79d1dad3 | |||
| 85fa3326c1 | |||
| 2fade1a617 | |||
| 597ba597e5 | |||
| 957020c9ea | |||
| ae520620ac | |||
| 4c43bed2f8 | |||
| 2663bcd96c | |||
| e423e17807 | |||
| 70fa0ec903 | |||
| db3615805c | |||
| 8162e60475 | |||
| 5594ec27ff | |||
| ca050ace9c | |||
| 093acdbff7 | |||
| a98417d66b | |||
| 83b9cdcb27 | |||
| 4ae8ef927c | |||
| 2128e6f14e | |||
| 9ff085c46a | |||
| 57d08f6b3b | |||
| 7964b66827 | |||
| 38a03a4485 | |||
| a1727d18fd | |||
| aabcef0c9c | |||
| 19406e99b9 | |||
| 5432d264f1 | |||
| d704a0ba3d | |||
| 161118122e | |||
| 9d550d7c11 | |||
| e40faaa442 | |||
| 894efa58a3 | |||
| 50570023ff | |||
| f293def3a8 | |||
| de3e8cffa0 | |||
| a874f76d72 | |||
| 424d4ed701 | |||
| 8f2a50c00e | |||
| 46cddb965f | |||
| 1ca64abad9 | |||
| 32d68a034f | |||
| 1a176920d4 | |||
| afa6fd9e2e | |||
| 6bee1b2c86 | |||
| 48f6a0c632 | |||
| 03d86f78f4 | |||
| 0bc89429bc | |||
| dd3eb71485 | |||
| 016527fda9 | |||
| d1f833fbbb | |||
| fd65a8cea9 | |||
| bcd6006e25 | |||
| cde0d83620 | |||
| 92994061da | |||
| 1e7ee25e13 | |||
| 838d21aae8 | |||
| 2fc3b8ee4a | |||
| 52834c6a35 | |||
| 1a39b7f66c | |||
| aea5189abb | |||
| 774796ada2 | |||
| 14f21b6c6d | |||
| 21ddc3cc3f | |||
| 8b94b149b2 | |||
| 99cc3b8449 | |||
| b2e728315b | |||
| bed11feaa4 | |||
| 28f40afccf | |||
| 0e7901ebbe | |||
| 73f3a72d00 | |||
| 63feabf517 | |||
| 84f87dd7f3 | |||
| 39c247d9cb | |||
| a98c4f6c54 | |||
| bed6622530 | |||
| c5e08a6f2c | |||
| ad32af6eeb | |||
| 02ced4b87b | |||
| 5b28a63f75 | |||
| b5e2e68a22 | |||
| 556c0819c4 | |||
| b0edccb80e | |||
| 4e1c921ffb | |||
| fdbbcafb6c | |||
| a043d477fb | |||
| 4cbe766cbd | |||
| 8610b915ec | |||
| 59fd6db83e | |||
| 465cf7355b | |||
| de91b78943 | |||
| 4203148cab | |||
| 5adc0c464a | |||
| 097290f7da | |||
| 475ef5d1ca | |||
| 2f37019782 | |||
| 9f4123cc3c | |||
| 482a87a80d | |||
| b925010cb3 | |||
| 085ce01da6 | |||
| 7af530de8f | |||
| 9a4cff4e49 | |||
| 480ffa4ac2 | |||
| d5ac0c9aa0 | |||
| 29f19b07a9 | |||
| 39301c534c | |||
| 58b9e4bda7 | |||
| 820a9ce983 | |||
| 68410e882d | |||
| f26c0af39a | |||
| 0ca451483f | |||
| 2849dd0ba2 | |||
| df41ae4acb | |||
| 2692777707 | |||
| ca1a45ccf8 | |||
| 2f39f248fc | |||
| 90f8ce5035 | |||
| cb29125580 | |||
| 988b54515e | |||
| 2fd9de5062 | |||
| fd5aabcb17 | |||
| 85b63219b9 | |||
| ddf943846f | |||
| e7221ec623 | |||
| 78daaf0cb4 | |||
| a6ab3bab4c | |||
| 53cea7023f | |||
| a098088f03 | |||
| 59998e9fd2 | |||
| 1df7e478df | |||
| 1fc4890857 | |||
| f5d52f1506 | |||
| 2026ba5bd2 | |||
| db56c8d74d | |||
| 9d1a81c93c | |||
| 3701b3d7a3 | |||
| bae98daa5c | |||
| b34a04f723 | |||
| 1c0245f2db | |||
| 7777e87dc8 | |||
| 820fa07846 | |||
| 66794582c9 | |||
| 73eb471ae3 | |||
| 0f2f4b96c4 | |||
| d53809cf2b |
@@ -2,15 +2,16 @@
|
|||||||
name: dms-plugin-dev
|
name: dms-plugin-dev
|
||||||
description: >
|
description: >
|
||||||
Develop plugins for DankMaterialShell (DMS), a QML-based Linux desktop shell built on
|
Develop plugins for DankMaterialShell (DMS), a QML-based Linux desktop shell built on
|
||||||
Quickshell. Supports four plugin types: widget (bar + Control Center), daemon (background
|
Quickshell. Supports five plugin types: widget (bar + Control Center), daemon (background
|
||||||
service), launcher (search + actions), and desktop (draggable desktop widgets). Covers
|
service), launcher (search + actions), desktop (draggable desktop widgets), and composite
|
||||||
manifest creation, QML component development, settings UI, data persistence, theme
|
(multi-surface). Covers manifest creation, QML component development, startup checks,
|
||||||
integration, PopoutService usage, and external command execution. Use when the user wants
|
settings UI, data persistence, theme integration, PopoutService usage, IPC runtime
|
||||||
to create, modify, or debug a DMS plugin, or asks about the DMS plugin API.
|
discovery, 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)
|
compatibility: Designed for Claude Code (or similar products)
|
||||||
metadata:
|
metadata:
|
||||||
author: DankMaterialShell
|
author: DankMaterialShell
|
||||||
version: "1.0"
|
version: "1.1"
|
||||||
domain: qml-desktop-development
|
domain: qml-desktop-development
|
||||||
framework: DankMaterialShell
|
framework: DankMaterialShell
|
||||||
languages: qml, javascript
|
languages: qml, javascript
|
||||||
@@ -37,14 +38,15 @@ integrations, and desktop widgets. Plugins are QML components discovered from
|
|||||||
|
|
||||||
**Plugin registry:** Community plugins are available at https://plugins.danklinux.com/
|
**Plugin registry:** Community plugins are available at https://plugins.danklinux.com/
|
||||||
|
|
||||||
**Four plugin types:**
|
**Five plugin types:**
|
||||||
|
|
||||||
| Type | Purpose | Base Component | Bar pills | CC integration |
|
| Type | Purpose | Base Component | Bar pills | CC integration |
|
||||||
|------------|--------------------------------|----------------------------|-----------|----------------|
|
|-------------|--------------------------------|----------------------------|-----------|----------------|
|
||||||
| `widget` | Bar widget + popout | `PluginComponent` | Yes | Yes |
|
| `widget` | Bar widget + popout | `PluginComponent` | Yes | Yes |
|
||||||
| `daemon` | Background service | `PluginComponent` (no UI) | No | Optional |
|
| `daemon` | Background service | `PluginComponent` (no UI) | No | Optional |
|
||||||
| `launcher` | Searchable items in launcher | `Item` | No | No |
|
| `launcher` | Searchable items in launcher | `Item` | No | No |
|
||||||
| `desktop` | Draggable desktop widget | `DesktopPluginComponent` | No | No |
|
| `desktop` | Draggable desktop widget | `DesktopPluginComponent` | No | No |
|
||||||
|
| `composite` | Multi-surface plugin | One component per surface | Optional | Optional |
|
||||||
|
|
||||||
## Step 1: Determine Plugin Type
|
## Step 1: Determine Plugin Type
|
||||||
|
|
||||||
@@ -58,6 +60,9 @@ Choose the type based on what the plugin does:
|
|||||||
with trigger-based filtering (e.g., type `=` for calculator, `:` for emoji).
|
with trigger-based filtering (e.g., type `=` for calculator, `:` for emoji).
|
||||||
- **Shows on the desktop background?** - Use `desktop`. Draggable, resizable widget on the
|
- **Shows on the desktop background?** - Use `desktop`. Draggable, resizable widget on the
|
||||||
desktop layer.
|
desktop layer.
|
||||||
|
- **Needs multiple surfaces?** - Use `composite`. A single plugin that registers any combination
|
||||||
|
of the above (e.g., a daemon + bar widget + desktop widget). Each surface gets its own
|
||||||
|
QML component file.
|
||||||
|
|
||||||
## Step 2: Create the Manifest
|
## Step 2: Create the Manifest
|
||||||
|
|
||||||
@@ -78,7 +83,7 @@ Create `plugin.json` in your plugin directory. See [plugin-manifest-reference.md
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**With settings and permissions:**
|
**With settings, startup check, and permissions:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -92,7 +97,31 @@ Create `plugin.json` in your plugin directory. See [plugin-manifest-reference.md
|
|||||||
"component": "./YourWidget.qml",
|
"component": "./YourWidget.qml",
|
||||||
"icon": "extension",
|
"icon": "extension",
|
||||||
"settings": "./Settings.qml",
|
"settings": "./Settings.qml",
|
||||||
|
"startupCheck": "./StartupCheck.qml",
|
||||||
"requires_dms": ">=0.1.0",
|
"requires_dms": ">=0.1.0",
|
||||||
|
"dependencies": ["mytool"],
|
||||||
|
"permissions": ["settings_read", "settings_write"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Composite plugin (multi-surface):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "myComposite",
|
||||||
|
"name": "My Composite Plugin",
|
||||||
|
"description": "Daemon + widget + desktop from one plugin",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "Your Name",
|
||||||
|
"type": "composite",
|
||||||
|
"capabilities": ["daemon", "dankbar-widget", "desktop-widget"],
|
||||||
|
"icon": "extension",
|
||||||
|
"components": {
|
||||||
|
"daemon": "./MyDaemon.qml",
|
||||||
|
"widget": "./MyBarWidget.qml",
|
||||||
|
"desktop": "./MyDesktopWidget.qml"
|
||||||
|
},
|
||||||
|
"settings": "./Settings.qml",
|
||||||
"permissions": ["settings_read", "settings_write"]
|
"permissions": ["settings_read", "settings_write"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -100,9 +129,11 @@ Create `plugin.json` in your plugin directory. See [plugin-manifest-reference.md
|
|||||||
**Key rules:**
|
**Key rules:**
|
||||||
- `id` must be camelCase, matching pattern `^[a-zA-Z][a-zA-Z0-9]*$`
|
- `id` must be camelCase, matching pattern `^[a-zA-Z][a-zA-Z0-9]*$`
|
||||||
- `version` must be semver (e.g., `1.0.0`)
|
- `version` must be semver (e.g., `1.0.0`)
|
||||||
- `component` must start with `./` and end with `.qml`
|
- Provide either `component` (single-surface) or `components` (multi-surface), not both
|
||||||
- `type: "launcher"` requires a `trigger` field
|
- `component` / component paths must start with `./` and end with `.qml`
|
||||||
|
- `type: "launcher"` (or a `components` object with a `launcher` key) requires a `trigger` field
|
||||||
- `settings_write` permission is **required** if the plugin has a settings component
|
- `settings_write` permission is **required** if the plugin has a settings component
|
||||||
|
- `dependencies` replaces the deprecated `requires` field
|
||||||
|
|
||||||
## Step 3: Create the Main Component
|
## Step 3: Create the Main Component
|
||||||
|
|
||||||
@@ -249,7 +280,69 @@ PluginComponent {
|
|||||||
|
|
||||||
See [daemon-plugin-guide.md](references/daemon-plugin-guide.md) for event-driven patterns and process execution.
|
See [daemon-plugin-guide.md](references/daemon-plugin-guide.md) for event-driven patterns and process execution.
|
||||||
|
|
||||||
## Step 4: Add Settings (Optional)
|
### Composite
|
||||||
|
|
||||||
|
For composite plugins, create a separate QML file per surface. Each surface uses the same
|
||||||
|
base component as the corresponding single-surface type (PluginComponent for widget/daemon,
|
||||||
|
Item for launcher, etc.). All surfaces share the same `pluginId` and `pluginService`.
|
||||||
|
|
||||||
|
```
|
||||||
|
MyCompositePlugin/
|
||||||
|
plugin.json
|
||||||
|
MyBarWidget.qml # PluginComponent (widget surface)
|
||||||
|
MyDaemon.qml # PluginComponent (daemon surface)
|
||||||
|
MyDesktopWidget.qml # Item with desktop widget properties
|
||||||
|
Settings.qml # Shared settings for all surfaces
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `pluginService.pluginHasSurface(pluginId, "widget")` to check whether a specific surface
|
||||||
|
is registered for a plugin at runtime.
|
||||||
|
|
||||||
|
## Step 4: Add Startup Check (Optional)
|
||||||
|
|
||||||
|
Gate plugin activation on dependency checks by providing a `startupCheck` component. This
|
||||||
|
runs before the plugin loads and blocks activation if a required tool or condition is missing.
|
||||||
|
|
||||||
|
Create a `StartupCheck.qml` (non-visual QtObject):
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import QtQuick
|
||||||
|
import qs.Common
|
||||||
|
|
||||||
|
QtObject {
|
||||||
|
function check(done) {
|
||||||
|
Proc.runCommand("myPlugin.depCheck", ["sh", "-c", "command -v mytool"], (stdout, exitCode) => {
|
||||||
|
if (exitCode === 0) {
|
||||||
|
done(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
done({
|
||||||
|
"title": I18n.tr("mytool is required"),
|
||||||
|
"details": I18n.tr("Install 'mytool' and re-enable this plugin.")
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `done` callback accepts:
|
||||||
|
- `null` - allow activation
|
||||||
|
- A string - block with a short error message
|
||||||
|
- `{ title, details }` - block with a title and expandable details body
|
||||||
|
|
||||||
|
A synchronous variant (no `done` parameter, return the result directly) is also supported.
|
||||||
|
|
||||||
|
Failed checks show a toast error and store the error in `pluginService.pluginLoadErrors`.
|
||||||
|
|
||||||
|
Add to your manifest:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"startupCheck": "./StartupCheck.qml",
|
||||||
|
"dependencies": ["mytool"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 5: Add Settings (Optional)
|
||||||
|
|
||||||
Wrap settings in `PluginSettings` with your `pluginId`. All settings auto-save and auto-load.
|
Wrap settings in `PluginSettings` with your `pluginId`. All settings auto-save and auto-load.
|
||||||
|
|
||||||
@@ -293,7 +386,7 @@ See [settings-components-reference.md](references/settings-components-reference.
|
|||||||
|
|
||||||
**Important:** Your plugin must declare `"permissions": ["settings_write"]` in plugin.json, or the settings UI will show an error.
|
**Important:** Your plugin must declare `"permissions": ["settings_write"]` in plugin.json, or the settings UI will show an error.
|
||||||
|
|
||||||
## Step 5: Use Data Persistence
|
## Step 6: Use Data Persistence
|
||||||
|
|
||||||
Three tiers of persistence:
|
Three tiers of persistence:
|
||||||
|
|
||||||
@@ -302,6 +395,7 @@ Three tiers of persistence:
|
|||||||
| `pluginService.savePluginData(id, key, val)` / `loadPluginData(id, key, default)` | Yes (settings.json) | User preferences, config |
|
| `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 |
|
| `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 |
|
| `PluginGlobalVar { varName; defaultValue; value; set() }` | No (runtime only) | Cross-instance shared state |
|
||||||
|
| `pluginService.getPluginPath(id)` | N/A | Get the plugin's installation directory path |
|
||||||
|
|
||||||
- `pluginData` is a reactive property on PluginComponent, auto-loaded from settings
|
- `pluginData` is a reactive property on PluginComponent, auto-loaded from settings
|
||||||
- React to settings changes with `Connections { target: pluginService; function onPluginDataChanged(id) { ... } }`
|
- React to settings changes with `Connections { target: pluginService; function onPluginDataChanged(id) { ... } }`
|
||||||
@@ -309,7 +403,7 @@ Three tiers of persistence:
|
|||||||
|
|
||||||
See [data-persistence-guide.md](references/data-persistence-guide.md) for details and examples.
|
See [data-persistence-guide.md](references/data-persistence-guide.md) for details and examples.
|
||||||
|
|
||||||
## Step 6: Theme Integration
|
## Step 7: Theme Integration
|
||||||
|
|
||||||
Always use `Theme.*` properties from `qs.Common` - never hardcode colors or sizes.
|
Always use `Theme.*` properties from `qs.Common` - never hardcode colors or sizes.
|
||||||
|
|
||||||
@@ -324,7 +418,7 @@ Always use `Theme.*` properties from `qs.Common` - never hardcode colors or size
|
|||||||
|
|
||||||
See [theme-reference.md](references/theme-reference.md) for the complete property list.
|
See [theme-reference.md](references/theme-reference.md) for the complete property list.
|
||||||
|
|
||||||
## Step 7: Add Popout Content (Widgets Only)
|
## Step 8: Add Popout Content (Widgets Only)
|
||||||
|
|
||||||
Add a popout that opens when the bar pill is clicked:
|
Add a popout that opens when the bar pill is clicked:
|
||||||
|
|
||||||
@@ -360,7 +454,7 @@ PluginComponent {
|
|||||||
|
|
||||||
Calculate available content height: `popoutHeight - headerHeight - detailsHeight - spacing`
|
Calculate available content height: `popoutHeight - headerHeight - detailsHeight - spacing`
|
||||||
|
|
||||||
## Step 8: Control Center Integration (Widgets Only)
|
## Step 9: Control Center Integration (Widgets Only)
|
||||||
|
|
||||||
Add your widget to the Control Center grid:
|
Add your widget to the Control Center grid:
|
||||||
|
|
||||||
@@ -389,7 +483,7 @@ PluginComponent {
|
|||||||
|
|
||||||
**CC sizing:** 25% width = SmallToggleButton (icon only), 50% width = ToggleButton or CompoundPill (if ccDetailContent is defined).
|
**CC sizing:** 25% width = SmallToggleButton (icon only), 50% width = ToggleButton or CompoundPill (if ccDetailContent is defined).
|
||||||
|
|
||||||
## Step 9: External Commands and Clipboard
|
## Step 10: External Commands and Clipboard
|
||||||
|
|
||||||
**Run commands and capture output:**
|
**Run commands and capture output:**
|
||||||
|
|
||||||
@@ -420,18 +514,33 @@ Quickshell.execDetached(["dms", "cl", "copy", textToCopy])
|
|||||||
|
|
||||||
**Do NOT use** `globalThis.clipboard` or browser JavaScript APIs - they don't exist in the QML runtime.
|
**Do NOT use** `globalThis.clipboard` or browser JavaScript APIs - they don't exist in the QML runtime.
|
||||||
|
|
||||||
## Step 10: Validate and Test
|
## Step 11: Validate and Test
|
||||||
|
|
||||||
1. Validate `plugin.json` against the schema at [assets/plugin-schema.json](assets/plugin-schema.json)
|
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`
|
2. Run the shell with verbose output: `qs -v -p $CONFIGPATH/quickshell/dms/shell.qml`
|
||||||
3. Open Settings > Plugins > Scan for Plugins
|
3. Open Settings > Plugins > Scan for Plugins
|
||||||
4. Enable your plugin and add it to the DankBar layout
|
4. Enable your plugin and add it to the DankBar layout
|
||||||
|
|
||||||
|
**Runtime plugin discovery via IPC:**
|
||||||
|
|
||||||
|
Plugins can be scanned, rescanned, and reloaded at runtime without restarting the shell:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dms ipc plugin-scan scan # Trigger a full rescan of all plugin directories
|
||||||
|
dms ipc plugin-scan rescan <id> # Force rescan of a specific plugin
|
||||||
|
dms ipc plugin-scan reload <id> # Force reload of a loaded plugin
|
||||||
|
dms ipc plugin-scan list # List all known plugins (TSV: id, loaded, type, name)
|
||||||
|
dms ipc plugin-scan status <id> # Get status of a specific plugin (TSV: loaded, type, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
Plugin IDs are validated against `^[a-zA-Z0-9_\-:]{1,64}$`.
|
||||||
|
|
||||||
**Common issues:**
|
**Common issues:**
|
||||||
- Plugin not detected: check plugin.json syntax with `jq . plugin.json`
|
- Plugin not detected: check plugin.json syntax with `jq . plugin.json`
|
||||||
- Widget not showing: ensure it's enabled AND added to a DankBar section
|
- Widget not showing: ensure it's enabled AND added to a DankBar section
|
||||||
- Settings error: verify `settings_write` permission is declared
|
- Settings error: verify `settings_write` permission is declared
|
||||||
- Data not persisting: check pluginService injection and permissions
|
- Data not persisting: check pluginService injection and permissions
|
||||||
|
- Startup check failing: check `pluginService.pluginLoadErrors` or run `dms ipc plugin-scan status <id>`
|
||||||
|
|
||||||
## Common Mistakes
|
## Common Mistakes
|
||||||
|
|
||||||
@@ -445,6 +554,9 @@ Quickshell.execDetached(["dms", "cl", "copy", textToCopy])
|
|||||||
8. **Forgetting `categories` in launcher items** - Items won't display without it
|
8. **Forgetting `categories` in launcher items** - Items won't display without it
|
||||||
9. **Not handling null pluginService** - Always use optional chaining or null checks
|
9. **Not handling null pluginService** - Always use optional chaining or null checks
|
||||||
10. **Using `PluginComponent` for launchers** - Launchers use plain `Item`, not `PluginComponent`
|
10. **Using `PluginComponent` for launchers** - Launchers use plain `Item`, not `PluginComponent`
|
||||||
|
11. **Using `requires` instead of `dependencies`** - `requires` is deprecated; use `dependencies`
|
||||||
|
12. **Providing both `component` and `components`** - Use one or the other, not both
|
||||||
|
13. **Missing `trigger` on composite with launcher surface** - Still required when `components` has a `launcher` key
|
||||||
|
|
||||||
## Quick Reference: Imports
|
## Quick Reference: Imports
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,7 @@
|
|||||||
"version",
|
"version",
|
||||||
"author",
|
"author",
|
||||||
"type",
|
"type",
|
||||||
"capabilities",
|
"capabilities"
|
||||||
"component"
|
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": {
|
"id": {
|
||||||
@@ -42,8 +41,8 @@
|
|||||||
},
|
},
|
||||||
"type": {
|
"type": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Plugin type",
|
"description": "Plugin type. Use 'composite' (or any value) together with 'components' to provide multiple surfaces from one plugin.",
|
||||||
"enum": ["widget", "daemon", "launcher", "desktop"]
|
"enum": ["widget", "daemon", "launcher", "desktop", "composite"]
|
||||||
},
|
},
|
||||||
"capabilities": {
|
"capabilities": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
@@ -55,9 +54,37 @@
|
|||||||
},
|
},
|
||||||
"component": {
|
"component": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Relative path to main QML component file",
|
"description": "Relative path to main QML component file. Required unless 'components' is provided.",
|
||||||
"pattern": "^\\./.*\\.qml$"
|
"pattern": "^\\./.*\\.qml$"
|
||||||
},
|
},
|
||||||
|
"components": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Map of surface name to relative QML component path, for multi-surface (composite) plugins. Provide any subset of surfaces; each is loaded independently.",
|
||||||
|
"properties": {
|
||||||
|
"widget": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Bar/Control Center widget component (PluginComponent)",
|
||||||
|
"pattern": "^\\./.*\\.qml$"
|
||||||
|
},
|
||||||
|
"desktop": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Desktop widget component",
|
||||||
|
"pattern": "^\\./.*\\.qml$"
|
||||||
|
},
|
||||||
|
"daemon": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Background daemon component (instantiated once)",
|
||||||
|
"pattern": "^\\./.*\\.qml$"
|
||||||
|
},
|
||||||
|
"launcher": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Launcher provider component (requires 'trigger')",
|
||||||
|
"pattern": "^\\./.*\\.qml$"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"minProperties": 1
|
||||||
|
},
|
||||||
"trigger": {
|
"trigger": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Trigger string for launcher activation (required for launcher type)"
|
"description": "Trigger string for launcher activation (required for launcher type)"
|
||||||
@@ -71,14 +98,26 @@
|
|||||||
"description": "Path to settings component QML file",
|
"description": "Path to settings component QML file",
|
||||||
"pattern": "^\\./.*\\.qml$"
|
"pattern": "^\\./.*\\.qml$"
|
||||||
},
|
},
|
||||||
|
"startupCheck": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Path to a non-visual (QtObject) component exposing a check(done) function that gates activation. done(null) allows; done(error) blocks, where error is a string or { title, details }.",
|
||||||
|
"pattern": "^\\./.*\\.qml$"
|
||||||
|
},
|
||||||
"requires_dms": {
|
"requires_dms": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Minimum DMS version requirement (e.g., '>=0.1.18', '>0.1.0')",
|
"description": "Minimum DMS version requirement (e.g., '>=0.1.18', '>0.1.0')",
|
||||||
"pattern": "^(>=?|<=?|=|>|<)\\d+\\.\\d+\\.\\d+$"
|
"pattern": "^(>=?|<=?|=|>|<)\\d+\\.\\d+\\.\\d+$"
|
||||||
},
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Array of required system tools/dependencies (registry metadata)",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
"requires": {
|
"requires": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"description": "Array of required system tools/dependencies",
|
"description": "Deprecated alias for 'dependencies'.",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
@@ -109,6 +148,29 @@
|
|||||||
"then": {
|
"then": {
|
||||||
"required": ["trigger"]
|
"required": ["trigger"]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"if": {
|
||||||
|
"required": ["components"],
|
||||||
|
"properties": {
|
||||||
|
"components": {
|
||||||
|
"required": ["launcher"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
|
"required": ["trigger"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"required": ["component"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"required": ["components"]
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"additionalProperties": true
|
"additionalProperties": true
|
||||||
|
|||||||
@@ -166,6 +166,16 @@ function increment() {
|
|||||||
| Cross-instance sync (multi-monitor data) | `PluginGlobalVar` or `getGlobalVar`/`setGlobalVar` | No (runtime only) | All instances |
|
| 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 |
|
| Quick reactive reads from settings | `pluginData` property | N/A (read-only) | Per instance |
|
||||||
|
|
||||||
|
## Plugin Path
|
||||||
|
|
||||||
|
Retrieve a plugin's installation directory at runtime:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
var dir = pluginService.getPluginPath(pluginId)
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns the absolute path to the plugin's directory (e.g., `~/.config/DankMaterialShell/plugins/MyPlugin`), or an empty string if the plugin is not found. Useful for loading bundled assets (images, data files) relative to the plugin's location.
|
||||||
|
|
||||||
## Important Notes
|
## Important Notes
|
||||||
|
|
||||||
1. **pluginData is reactive** - bindings update automatically when data changes
|
1. **pluginData is reactive** - bindings update automatically when data changes
|
||||||
|
|||||||
@@ -9,15 +9,22 @@
|
|||||||
| `description` | string | Short description (shown in UI) | 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.-]+)?$` |
|
| `version` | string | Semantic version | Pattern `^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$` |
|
||||||
| `author` | string | Creator name or email | Non-empty |
|
| `author` | string | Creator name or email | Non-empty |
|
||||||
| `type` | string | Plugin type | One of: `widget`, `daemon`, `launcher`, `desktop` |
|
| `type` | string | Plugin type | One of: `widget`, `daemon`, `launcher`, `desktop`, `composite` |
|
||||||
| `capabilities` | array | Plugin capabilities | At least 1 string item |
|
| `capabilities` | array | Plugin capabilities | At least 1 string item |
|
||||||
| `component` | string | Path to main QML file | Must start with `./`, end with `.qml` |
|
|
||||||
|
One of `component` or `components` is required (not both):
|
||||||
|
|
||||||
|
| Field | Type | Description | Validation |
|
||||||
|
|-------|------|-------------|------------|
|
||||||
|
| `component` | string | Path to main QML file (single-surface plugins) | Must start with `./`, end with `.qml` |
|
||||||
|
| `components` | object | Map of surface name to QML path (multi-surface plugins) | At least 1 entry; keys: `widget`, `desktop`, `daemon`, `launcher` |
|
||||||
|
|
||||||
## Conditional Requirements
|
## Conditional Requirements
|
||||||
|
|
||||||
| Condition | Required Field | Description |
|
| Condition | Required Field | Description |
|
||||||
|-----------|---------------|-------------|
|
|-----------|---------------|-------------|
|
||||||
| `type: "launcher"` | `trigger` | Trigger string for launcher activation (e.g., `=`, `#`, `!`) |
|
| `type: "launcher"` | `trigger` | Trigger string for launcher activation (e.g., `=`, `#`, `!`) |
|
||||||
|
| `components` has `launcher` key | `trigger` | Same requirement applies to composite plugins with a launcher surface |
|
||||||
|
|
||||||
## Optional Fields
|
## Optional Fields
|
||||||
|
|
||||||
@@ -25,8 +32,10 @@
|
|||||||
|-------|------|-------------|
|
|-------|------|-------------|
|
||||||
| `icon` | string | Material Design icon name (displayed in plugin list UI) |
|
| `icon` | string | Material Design icon name (displayed in plugin list UI) |
|
||||||
| `settings` | string | Path to settings QML file (must start with `./`, end with `.qml`) |
|
| `settings` | string | Path to settings QML file (must start with `./`, end with `.qml`) |
|
||||||
|
| `startupCheck` | string | Path to a QtObject component that gates plugin activation via a `check(done)` function (must start with `./`, end with `.qml`). See Startup Check section below. |
|
||||||
| `requires_dms` | string | Minimum DMS version (e.g., `>=0.1.18`), pattern `^(>=?\|<=?\|=\|>\|<)\d+\.\d+\.\d+$` |
|
| `requires_dms` | string | Minimum DMS version (e.g., `>=0.1.18`), pattern `^(>=?\|<=?\|=\|>\|<)\d+\.\d+\.\d+$` |
|
||||||
| `requires` | array | System tool dependencies (e.g., `["curl", "jq"]`) |
|
| `dependencies` | array | System tool dependencies (e.g., `["curl", "jq"]`). Registry metadata. |
|
||||||
|
| `requires` | array | Deprecated alias for `dependencies` |
|
||||||
| `permissions` | array | Required permissions |
|
| `permissions` | array | Required permissions |
|
||||||
| `trigger` | string | Launcher trigger string (required for launcher type) |
|
| `trigger` | string | Launcher trigger string (required for launcher type) |
|
||||||
|
|
||||||
@@ -53,6 +62,65 @@ Capabilities are free-form strings that describe what the plugin does. Common va
|
|||||||
- `ai` - AI/LLM integration
|
- `ai` - AI/LLM integration
|
||||||
- `slideout` - uses slideout panel
|
- `slideout` - uses slideout panel
|
||||||
|
|
||||||
|
## Startup Check
|
||||||
|
|
||||||
|
The `startupCheck` field points to a non-visual `QtObject` component that gates plugin activation on dependency checks. The component must expose a `check(done)` function:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import QtQuick
|
||||||
|
import qs.Common
|
||||||
|
|
||||||
|
QtObject {
|
||||||
|
function check(done) {
|
||||||
|
Proc.runCommand("myPlugin.depCheck", ["sh", "-c", "command -v mytool"], (stdout, exitCode) => {
|
||||||
|
if (exitCode === 0) {
|
||||||
|
done(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
done({
|
||||||
|
"title": I18n.tr("mytool is required"),
|
||||||
|
"details": I18n.tr("Install 'mytool' and re-enable this plugin.")
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `done` callback accepts:
|
||||||
|
- `null` - allow activation
|
||||||
|
- A string - block with a short error message
|
||||||
|
- `{ title, details }` - block with a title and expandable details
|
||||||
|
|
||||||
|
A synchronous variant (no `done` parameter, return the result directly) is also supported.
|
||||||
|
|
||||||
|
Failed startup checks show a toast error and store the error in `pluginService.pluginLoadErrors`.
|
||||||
|
|
||||||
|
## Components (Composite Plugins)
|
||||||
|
|
||||||
|
The `components` field maps surface names to QML paths, allowing a single plugin to register multiple surfaces:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "myComposite",
|
||||||
|
"name": "My Composite Plugin",
|
||||||
|
"description": "Daemon + widget + desktop from one plugin",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "Developer Name",
|
||||||
|
"type": "composite",
|
||||||
|
"capabilities": ["daemon", "dankbar-widget", "desktop-widget"],
|
||||||
|
"icon": "extension",
|
||||||
|
"components": {
|
||||||
|
"daemon": "./MyDaemon.qml",
|
||||||
|
"widget": "./MyBarWidget.qml",
|
||||||
|
"desktop": "./MyDesktopWidget.qml"
|
||||||
|
},
|
||||||
|
"settings": "./Settings.qml",
|
||||||
|
"permissions": ["settings_read", "settings_write"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Valid surface keys: `widget`, `desktop`, `daemon`, `launcher`. Provide any subset. Each surface is loaded independently in the appropriate registry.
|
||||||
|
|
||||||
## Complete Example
|
## Complete Example
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -67,8 +135,9 @@ Capabilities are free-form strings that describe what the plugin does. Common va
|
|||||||
"component": "./MyWidget.qml",
|
"component": "./MyWidget.qml",
|
||||||
"icon": "extension",
|
"icon": "extension",
|
||||||
"settings": "./Settings.qml",
|
"settings": "./Settings.qml",
|
||||||
|
"startupCheck": "./StartupCheck.qml",
|
||||||
"requires_dms": ">=0.1.18",
|
"requires_dms": ">=0.1.18",
|
||||||
"requires": ["curl", "jq"],
|
"dependencies": ["curl", "jq"],
|
||||||
"permissions": ["settings_read", "settings_write", "process", "network"]
|
"permissions": ["settings_read", "settings_write", "process", "network"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -235,10 +235,12 @@ Conditionally show/hide the bar pill:
|
|||||||
```qml
|
```qml
|
||||||
PluginComponent {
|
PluginComponent {
|
||||||
visibilityCommand: "pgrep -x myapp"
|
visibilityCommand: "pgrep -x myapp"
|
||||||
visibilityInterval: 5000 // check every 5 seconds
|
visibilityInterval: 5 // seconds between checks
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Bar reveal optimization:** The visibility timer automatically pauses while the bar is hidden (auto-hide mode) and resumes checks when the bar is revealed. This is handled via the internal `_barRevealed` property - no plugin code needed. Plugins using `visibilityCommand` with `visibilityInterval` benefit from this automatically.
|
||||||
|
|
||||||
## Popout Namespace
|
## Popout Namespace
|
||||||
|
|
||||||
For plugins with multiple popout instances, use `layerNamespacePlugin` to isolate popout state:
|
For plugins with multiple popout instances, use `layerNamespacePlugin` to isolate popout state:
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Categories for GitHub's auto-generated release notes
|
||||||
|
changelog:
|
||||||
|
exclude:
|
||||||
|
authors:
|
||||||
|
- github-actions[bot]
|
||||||
|
- dependabot[bot]
|
||||||
|
labels:
|
||||||
|
- internal
|
||||||
|
categories:
|
||||||
|
- title: Breaking Changes
|
||||||
|
labels: [breaking]
|
||||||
|
- title: Features
|
||||||
|
labels: [feature]
|
||||||
|
- title: Fixes
|
||||||
|
labels: [fix]
|
||||||
|
- title: Packaging
|
||||||
|
labels: [packaging]
|
||||||
|
- title: Internationalization
|
||||||
|
labels: [i18n]
|
||||||
|
- title: Documentation
|
||||||
|
labels: [docs]
|
||||||
|
- title: Other Changes
|
||||||
|
labels: ["*"]
|
||||||
Executable
+170
@@ -0,0 +1,170 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "usage: $0 download|publish" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
require_env() {
|
||||||
|
local name
|
||||||
|
for name in "$@"; do
|
||||||
|
if [[ -z "${!name:-}" ]]; then
|
||||||
|
echo "error: $name is required" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
require_env R2_BUCKET R2_ENDPOINT R2_PREFIX REPOSITORY_DIR
|
||||||
|
|
||||||
|
aws_r2() {
|
||||||
|
aws --endpoint-url "$R2_ENDPOINT" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
download() {
|
||||||
|
mkdir -p "$REPOSITORY_DIR/current" "$REPOSITORY_DIR/previous-current"
|
||||||
|
|
||||||
|
aws_r2 s3 sync \
|
||||||
|
"s3://${R2_BUCKET}/${R2_PREFIX}/current/" \
|
||||||
|
"$REPOSITORY_DIR/current/" \
|
||||||
|
--only-show-errors
|
||||||
|
|
||||||
|
cp -a "$REPOSITORY_DIR/current/." "$REPOSITORY_DIR/previous-current/"
|
||||||
|
}
|
||||||
|
|
||||||
|
build_manifest() {
|
||||||
|
local packages='[]'
|
||||||
|
local file filename pkgver name version revision sha size
|
||||||
|
|
||||||
|
for file in "$REPOSITORY_DIR"/current/*.xbps; do
|
||||||
|
filename="$(basename "$file")"
|
||||||
|
pkgver="$(xbps-uhelper binpkgver "$filename")"
|
||||||
|
name="$(xbps-uhelper getpkgname "$pkgver")"
|
||||||
|
version="$(xbps-uhelper getpkgversion "$pkgver")"
|
||||||
|
revision="$(xbps-uhelper getpkgrevision "$pkgver")"
|
||||||
|
version="${version%_"${revision}"}"
|
||||||
|
sha="$(sha256sum "$file" | cut -d' ' -f1)"
|
||||||
|
size="$(stat -c '%s' "$file")"
|
||||||
|
packages="$(jq \
|
||||||
|
--arg name "$name" \
|
||||||
|
--arg version "$version" \
|
||||||
|
--arg revision "$revision" \
|
||||||
|
--arg filename "$filename" \
|
||||||
|
--arg sha256 "$sha" \
|
||||||
|
--argjson size "$size" \
|
||||||
|
'. + [{name: $name, version: $version, revision: $revision, filename: $filename, sha256: $sha256, size: $size}]' \
|
||||||
|
<<<"$packages")"
|
||||||
|
done
|
||||||
|
|
||||||
|
jq -n \
|
||||||
|
--arg repository "$R2_PREFIX" \
|
||||||
|
--arg source_commit "$SOURCE_COMMIT" \
|
||||||
|
--arg published_at "$PUBLISHED_AT" \
|
||||||
|
--argjson packages "$packages" \
|
||||||
|
'{schema: 1, repository: $repository, source_commit: $source_commit, published_at: $published_at, packages: $packages}' \
|
||||||
|
> "$REPOSITORY_DIR/current/manifest.json"
|
||||||
|
}
|
||||||
|
|
||||||
|
verify_immutable_packages() {
|
||||||
|
local file previous
|
||||||
|
|
||||||
|
for file in "$REPOSITORY_DIR"/current/*.xbps "$REPOSITORY_DIR"/current/*.sig2; do
|
||||||
|
previous="$REPOSITORY_DIR/previous-current/$(basename "$file")"
|
||||||
|
if [[ -f "$previous" ]] && ! cmp -s "$previous" "$file"; then
|
||||||
|
echo "error: refusing to replace immutable object $(basename "$file")" >&2
|
||||||
|
echo "bump the XBPS revision or version before publishing a changed build" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
archive_retired() {
|
||||||
|
local old filename
|
||||||
|
local archive_prefix="archive/${R2_PREFIX}/${PUBLISHED_AT//:/-}"
|
||||||
|
|
||||||
|
shopt -s nullglob
|
||||||
|
for old in "$REPOSITORY_DIR"/previous-current/*.xbps "$REPOSITORY_DIR"/previous-current/*.sig2; do
|
||||||
|
filename="$(basename "$old")"
|
||||||
|
if [[ ! -e "$REPOSITORY_DIR/current/$filename" ]]; then
|
||||||
|
aws_r2 s3 cp \
|
||||||
|
"$old" \
|
||||||
|
"s3://${R2_BUCKET}/${archive_prefix}/${filename}" \
|
||||||
|
--cache-control 'private,no-store' \
|
||||||
|
--only-show-errors
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
upload_current() {
|
||||||
|
local file filename old
|
||||||
|
|
||||||
|
# Versioned package objects must exist before repodata can reference them.
|
||||||
|
for file in "$REPOSITORY_DIR"/current/*.xbps "$REPOSITORY_DIR"/current/*.sig2; do
|
||||||
|
filename="$(basename "$file")"
|
||||||
|
aws_r2 s3 cp \
|
||||||
|
"$file" \
|
||||||
|
"s3://${R2_BUCKET}/${R2_PREFIX}/current/${filename}" \
|
||||||
|
--cache-control 'public,max-age=31536000,immutable' \
|
||||||
|
--only-show-errors
|
||||||
|
done
|
||||||
|
|
||||||
|
aws_r2 s3 cp \
|
||||||
|
"$REPOSITORY_DIR/current/x86_64-repodata" \
|
||||||
|
"s3://${R2_BUCKET}/${R2_PREFIX}/current/x86_64-repodata" \
|
||||||
|
--cache-control 'no-cache' \
|
||||||
|
--only-show-errors
|
||||||
|
|
||||||
|
# The manifest is the publication marker and is always uploaded last.
|
||||||
|
aws_r2 s3 cp \
|
||||||
|
"$REPOSITORY_DIR/current/manifest.json" \
|
||||||
|
"s3://${R2_BUCKET}/${R2_PREFIX}/current/manifest.json" \
|
||||||
|
--cache-control 'no-cache' \
|
||||||
|
--only-show-errors
|
||||||
|
|
||||||
|
# Once the new index and marker are live, remove objects no longer referenced.
|
||||||
|
shopt -s nullglob
|
||||||
|
for old in "$REPOSITORY_DIR"/previous-current/*; do
|
||||||
|
filename="$(basename "$old")"
|
||||||
|
if [[ ! -e "$REPOSITORY_DIR/current/$filename" ]]; then
|
||||||
|
aws_r2 s3 rm \
|
||||||
|
"s3://${R2_BUCKET}/${R2_PREFIX}/current/${filename}" \
|
||||||
|
--only-show-errors
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
publish() {
|
||||||
|
require_env SOURCE_COMMIT
|
||||||
|
PUBLISHED_AT="${PUBLISHED_AT:-$(date -u +'%Y-%m-%dT%H:%M:%SZ')}"
|
||||||
|
export PUBLISHED_AT
|
||||||
|
|
||||||
|
shopt -s nullglob
|
||||||
|
local packages=("$REPOSITORY_DIR"/current/*.xbps)
|
||||||
|
if (( ${#packages[@]} == 0 )); then
|
||||||
|
echo "error: refusing to publish an empty XBPS repository" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
[[ -s "$REPOSITORY_DIR/current/x86_64-repodata" ]] || {
|
||||||
|
echo "error: x86_64-repodata is missing or empty" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
for file in "${packages[@]}"; do
|
||||||
|
[[ -s "${file}.sig2" ]] || {
|
||||||
|
echo "error: signature is missing for $(basename "$file")" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
done
|
||||||
|
|
||||||
|
verify_immutable_packages
|
||||||
|
build_manifest
|
||||||
|
archive_retired
|
||||||
|
upload_current
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
download) download ;;
|
||||||
|
publish) publish ;;
|
||||||
|
*) usage ;;
|
||||||
|
esac
|
||||||
@@ -7,14 +7,17 @@ on:
|
|||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
issues: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
update-stable:
|
update-stable:
|
||||||
|
# skip prerelease tags
|
||||||
|
if: ${{ !contains(github.ref_name, '-') }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Create GitHub App token
|
- name: Create GitHub App token
|
||||||
id: app_token
|
id: app_token
|
||||||
uses: actions/create-github-app-token@v1
|
uses: actions/create-github-app-token@v3
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.APP_ID }}
|
app-id: ${{ secrets.APP_ID }}
|
||||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||||
@@ -28,4 +31,54 @@ jobs:
|
|||||||
- name: Push to stable branch
|
- name: Push to stable branch
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||||
run: git push https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git HEAD:refs/heads/stable --force
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# don't roll stable backwards
|
||||||
|
if git fetch origin stable --quiet 2>/dev/null; then
|
||||||
|
stable_tag=$(git describe --tags --abbrev=0 FETCH_HEAD 2>/dev/null || echo "v0.0.0")
|
||||||
|
newest=$(printf '%s\n%s\n' "$stable_tag" "${GITHUB_REF_NAME}" | sort -V | tail -1)
|
||||||
|
if [ "$newest" != "${GITHUB_REF_NAME}" ]; then
|
||||||
|
echo "skipping: ${GITHUB_REF_NAME} is older than stable (${stable_tag})"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
git push "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" HEAD:refs/heads/stable --force
|
||||||
|
|
||||||
|
cut-release-branch:
|
||||||
|
# create stable-X.Y at each vX.Y.0 tag
|
||||||
|
if: ${{ !contains(github.ref_name, '-') }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Create GitHub App token
|
||||||
|
id: app_token
|
||||||
|
uses: actions/create-github-app-token@v3
|
||||||
|
with:
|
||||||
|
app-id: ${{ secrets.APP_ID }}
|
||||||
|
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
token: ${{ steps.app_token.outputs.token }}
|
||||||
|
|
||||||
|
- name: Create release branch
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [[ ! "${GITHUB_REF_NAME}" =~ ^v([0-9]+)\.([0-9]+)\.0$ ]]; then
|
||||||
|
echo "not a vX.Y.0 tag, no release branch to cut"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
branch="stable-${BASH_REMATCH[1]}.${BASH_REMATCH[2]}"
|
||||||
|
if git ls-remote --exit-code origin "refs/heads/${branch}" >/dev/null 2>&1; then
|
||||||
|
echo "${branch} already exists"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
git push "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" "HEAD:refs/heads/${branch}"
|
||||||
|
echo "created ${branch} at ${GITHUB_REF_NAME}"
|
||||||
|
GH_TOKEN="${{ secrets.GITHUB_TOKEN }}" gh label create "port ${branch}" \
|
||||||
|
--repo "${{ github.repository }}" \
|
||||||
|
--description "Auto-port merged PR to ${branch}" --color 0e8a16 ||
|
||||||
|
echo "label 'port ${branch}' may already exist"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ on:
|
|||||||
- "core/**"
|
- "core/**"
|
||||||
- ".github/workflows/go-ci.yml"
|
- ".github/workflows/go-ci.yml"
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [master, main]
|
branches: [master, main, "stable-*"]
|
||||||
paths:
|
paths:
|
||||||
- "core/**"
|
- "core/**"
|
||||||
- ".github/workflows/go-ci.yml"
|
- ".github/workflows/go-ci.yml"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: Nix flake and NixOS tests
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [master, main]
|
branches: [master, main, "stable-*"]
|
||||||
paths:
|
paths:
|
||||||
- "flake.*"
|
- "flake.*"
|
||||||
- "distro/nix/**"
|
- "distro/nix/**"
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
name: Point release
|
||||||
|
|
||||||
|
# Cuts vX.Y.Z from stable-X.Y: runs the port audit (warn-only), bumps
|
||||||
|
# quickshell/VERSION, tags, and dispatches the Release workflow. Distro
|
||||||
|
# builds are dispatched separately.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: "Point release version (e.g. 1.5.1)"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
notify_issues:
|
||||||
|
description: "Comment on open related/fixes issues asking reporters to retest"
|
||||||
|
required: false
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
actions: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: point-release
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
VERSION: ${{ inputs.version }}
|
||||||
|
steps:
|
||||||
|
- name: Validate version and derive branch
|
||||||
|
id: derive
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
echo "::error::version must be X.Y.Z (got '$VERSION')"; exit 1
|
||||||
|
fi
|
||||||
|
echo "branch=stable-${VERSION%.*}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Create GitHub App token
|
||||||
|
id: app_token
|
||||||
|
uses: actions/create-github-app-token@v3
|
||||||
|
with:
|
||||||
|
app-id: ${{ secrets.APP_ID }}
|
||||||
|
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||||
|
|
||||||
|
- name: Checkout release branch
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
ref: ${{ steps.derive.outputs.branch }}
|
||||||
|
fetch-depth: 0
|
||||||
|
token: ${{ steps.app_token.outputs.token }}
|
||||||
|
|
||||||
|
- name: Preflight — verify Release workflow is dispatchable
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
gh api "repos/${{ github.repository }}/actions/workflows/release.yml" \
|
||||||
|
--jq '.state' | grep -qx active ||
|
||||||
|
{ echo "::error::release.yml is not dispatchable; aborting before any push"; exit 1; }
|
||||||
|
|
||||||
|
- name: Port audit (informational)
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||||
|
run: |
|
||||||
|
bash scripts/port-audit.sh "${{ steps.derive.outputs.branch }}" ||
|
||||||
|
echo "::warning::port audit failed; continuing"
|
||||||
|
|
||||||
|
- name: Bump VERSION, tag, and push
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||||
|
TAG: ${{ steps.derive.outputs.tag }}
|
||||||
|
BRANCH: ${{ steps.derive.outputs.branch }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then
|
||||||
|
echo "::error::tag ${TAG} already exists"; exit 1
|
||||||
|
fi
|
||||||
|
git config user.name "dms-ci[bot]"
|
||||||
|
git config user.email "dms-ci[bot]@users.noreply.github.com"
|
||||||
|
|
||||||
|
echo "${TAG}" > quickshell/VERSION
|
||||||
|
git add quickshell/VERSION
|
||||||
|
git commit -m "bump VERSION to ${TAG}"
|
||||||
|
git tag "${TAG}"
|
||||||
|
git push "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" "HEAD:${BRANCH}" "refs/tags/${TAG}"
|
||||||
|
|
||||||
|
- name: Dispatch Release workflow
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
gh workflow run release.yml --ref "${{ steps.derive.outputs.tag }}" \
|
||||||
|
-f tag="${{ steps.derive.outputs.tag }}" \
|
||||||
|
-f notify_issues="${{ inputs.notify_issues }}"
|
||||||
|
|
||||||
|
- name: Next steps
|
||||||
|
run: |
|
||||||
|
{
|
||||||
|
echo "## ${{ steps.derive.outputs.tag }} tagged on ${{ steps.derive.outputs.branch }} — Release workflow dispatched"
|
||||||
|
echo ""
|
||||||
|
echo "Distro builds are manual: run the per-distro workflows (COPR/OBS/PPA/XBPS) once the release is published."
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
name: Port audit
|
||||||
|
|
||||||
|
# On-demand report of master commits not yet ported to a release branch.
|
||||||
|
# Updates the "Port status: <branch>" tracking issue and the step summary.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
target:
|
||||||
|
description: "Release branch to audit (default: newest release/*)"
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
audit:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Run audit
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: bash scripts/port-audit.sh ${{ inputs.target }} --issue
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
name: Port to release branch
|
||||||
|
|
||||||
|
# Ports flagged commits from master onto stable-X.Y branches:
|
||||||
|
# - "port 1.5" flag in a commit message pushed to master — own line or
|
||||||
|
# mid-line; separators : / - or space; "stable-1.5" also accepted;
|
||||||
|
# comma lists ("Port: 1.5, 1.4") work in the own-line form
|
||||||
|
# - "port stable-1.5" label on a merged PR
|
||||||
|
# Conflicts are reported to the "Port status: <branch>" tracking issue.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
pull_request_target:
|
||||||
|
types: [closed, labeled]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: port-engine
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
port:
|
||||||
|
name: Port flagged commits
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Create GitHub App token
|
||||||
|
id: app_token
|
||||||
|
uses: actions/create-github-app-token@v3
|
||||||
|
with:
|
||||||
|
app-id: ${{ secrets.APP_ID }}
|
||||||
|
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||||
|
|
||||||
|
# Base-repo code only; PR head code is never checked out or executed.
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
ref: master
|
||||||
|
fetch-depth: 0
|
||||||
|
token: ${{ steps.app_token.outputs.token }}
|
||||||
|
|
||||||
|
- name: Port trailer-flagged commits
|
||||||
|
if: github.event_name == 'push'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||||
|
COMMITS: ${{ toJSON(github.event.commits) }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
git config user.name "dms-ci[bot]"
|
||||||
|
git config user.email "dms-ci[bot]@users.noreply.github.com"
|
||||||
|
|
||||||
|
for sha in $(jq -r '.[].id' <<<"$COMMITS"); do
|
||||||
|
git cat-file -e "$sha" 2>/dev/null || continue
|
||||||
|
# skip merge commits (handled by the label path)
|
||||||
|
[ "$(git rev-list --no-walk --count --min-parents=2 "$sha")" -eq 0 ] || continue
|
||||||
|
|
||||||
|
# own-line form (supports comma lists), validated version-shaped
|
||||||
|
t1=$(git log -1 --format=%B "$sha" |
|
||||||
|
{ grep -iE '^Port[:/ -]' || true; } | sed -E 's|^port[-:/ ]+||I' | tr ',' '\n' |
|
||||||
|
sed 's/[[:space:]]//g; /^$/d' | sed -E 's,^stable-,,I' |
|
||||||
|
{ grep -E '^[0-9]+\.[0-9]+(\.[0-9]+)?$' || true; })
|
||||||
|
# mid-line form, e.g. "fix thing fixes #2802 port 1.5"
|
||||||
|
t2=$(git log -1 --format=%B "$sha" |
|
||||||
|
{ grep -oiE '\bport[-: /]+(stable-)?[0-9]+\.[0-9]+(\.[0-9]+)?\b' || true; } |
|
||||||
|
sed -E 's|^port[-:/ ]+||I' | sed -E 's,^stable-,,I')
|
||||||
|
targets=$(printf '%s\n%s\n' "$t1" "$t2" | sed '/^$/d' | sort -u)
|
||||||
|
for ver in $targets; do
|
||||||
|
echo "::group::port $sha -> stable-$ver"
|
||||||
|
bash scripts/port.sh "stable-$ver" "$sha"
|
||||||
|
echo "::endgroup::"
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Port label-flagged PR
|
||||||
|
if: >
|
||||||
|
github.event_name == 'pull_request_target' &&
|
||||||
|
github.event.pull_request.merged == true &&
|
||||||
|
(github.event.action == 'closed' ||
|
||||||
|
(github.event.action == 'labeled' && startsWith(github.event.label.name, 'port ')))
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||||
|
LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }}
|
||||||
|
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
|
||||||
|
PORT_SOURCE_PR: ${{ github.event.pull_request.number }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
git config user.name "dms-ci[bot]"
|
||||||
|
git config user.email "dms-ci[bot]@users.noreply.github.com"
|
||||||
|
|
||||||
|
targets=$(jq -r '.[] | select(startswith("port ")) | sub("^port +"; "")' <<<"$LABELS" |
|
||||||
|
sed -E 's,^stable-,,' | sort -u)
|
||||||
|
[ -n "$targets" ] || { echo "no port labels, nothing to do"; exit 0; }
|
||||||
|
[ -n "$MERGE_SHA" ] || { echo "::error::PR has no merge commit sha"; exit 1; }
|
||||||
|
|
||||||
|
for ver in $targets; do
|
||||||
|
echo "::group::port PR #${PORT_SOURCE_PR} ($MERGE_SHA) -> stable-$ver"
|
||||||
|
bash scripts/port.sh "stable-$ver" "$MERGE_SHA"
|
||||||
|
echo "::endgroup::"
|
||||||
|
done
|
||||||
@@ -3,7 +3,7 @@ name: Pre-commit Checks
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [master, main]
|
branches: [master, main, "stable-*"]
|
||||||
jobs:
|
jobs:
|
||||||
pre-commit-check:
|
pre-commit-check:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -7,10 +7,16 @@ on:
|
|||||||
description: "Tag to release (e.g., v1.0.1)"
|
description: "Tag to release (e.g., v1.0.1)"
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
|
notify_issues:
|
||||||
|
description: "Comment on open related/fixes issues asking reporters to retest"
|
||||||
|
required: false
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
actions: write
|
actions: write
|
||||||
|
issues: write
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: release-${{ inputs.tag }}
|
group: release-${{ inputs.tag }}
|
||||||
@@ -205,14 +211,24 @@ jobs:
|
|||||||
|
|
||||||
- name: Generate Changelog
|
- name: Generate Changelog
|
||||||
id: changelog
|
id: changelog
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
PREVIOUS_TAG=$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || echo "")
|
PREVIOUS_TAG=$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || echo "")
|
||||||
|
CHANGELOG=""
|
||||||
|
if [ -n "$PREVIOUS_TAG" ]; then
|
||||||
|
# PR-based notes with author credits; falls back to raw git log below
|
||||||
|
CHANGELOG=$(python3 scripts/release-notes.py "${PREVIOUS_TAG}..${TAG}" --format github --bare 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
if [ -z "$CHANGELOG" ]; then
|
||||||
|
echo "release-notes.py unavailable or empty, using git log fallback"
|
||||||
if [ -z "$PREVIOUS_TAG" ]; then
|
if [ -z "$PREVIOUS_TAG" ]; then
|
||||||
CHANGELOG=$(git log --oneline --pretty=format:"%an|%s (%h)" | grep -v "^github-actions\[bot\]|" | sed 's/^[^|]*|/- /' | head -50)
|
CHANGELOG=$(git log --oneline --pretty=format:"%an|%s (%h)" | grep -v "^github-actions\[bot\]|" | sed 's/^[^|]*|/- /' | head -50)
|
||||||
else
|
else
|
||||||
CHANGELOG=$(git log --oneline --pretty=format:"%an|%s (%h)" "${PREVIOUS_TAG}..${TAG}" | grep -v "^github-actions\[bot\]|" | sed 's/^[^|]*|/- /')
|
CHANGELOG=$(git log --oneline --pretty=format:"%an|%s (%h)" "${PREVIOUS_TAG}..${TAG}" | grep -v "^github-actions\[bot\]|" | sed 's/^[^|]*|/- /')
|
||||||
fi
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
cat > RELEASE_BODY.md << 'EOF'
|
cat > RELEASE_BODY.md << 'EOF'
|
||||||
## Installation
|
## Installation
|
||||||
@@ -417,3 +433,19 @@ jobs:
|
|||||||
prerelease: ${{ contains(env.TAG, '-') }}
|
prerelease: ${{ contains(env.TAG, '-') }}
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
# Ask reporters on still-open "related/fixes #N" issues to retest.
|
||||||
|
# Opt-in via notify_issues (Point release defaults it on). Skipped for
|
||||||
|
# prereleases. Preview: scripts/notify-issues.sh <prev> <tag> --dry-run
|
||||||
|
- name: Notify open issues to retest
|
||||||
|
if: ${{ inputs.notify_issues && !contains(env.TAG, '-') }}
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
PREVIOUS_TAG=$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || true)
|
||||||
|
if [ -z "$PREVIOUS_TAG" ]; then
|
||||||
|
echo "no previous tag; skipping issue notify"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
bash scripts/notify-issues.sh "$PREVIOUS_TAG" "$TAG"
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ jobs:
|
|||||||
check-updates:
|
check-updates:
|
||||||
name: Check for updates
|
name: Check for updates
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
if: github.repository == 'AvengeMedia/DankMaterialShell'
|
||||||
|
|
||||||
outputs:
|
outputs:
|
||||||
has_updates: ${{ steps.check.outputs.has_updates }}
|
has_updates: ${{ steps.check.outputs.has_updates }}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ jobs:
|
|||||||
check-updates:
|
check-updates:
|
||||||
name: Check package/series updates
|
name: Check package/series updates
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
if: github.repository == 'AvengeMedia/DankMaterialShell'
|
||||||
|
|
||||||
outputs:
|
outputs:
|
||||||
has_updates: ${{ steps.check.outputs.has_updates }}
|
has_updates: ${{ steps.check.outputs.has_updates }}
|
||||||
|
|||||||
@@ -0,0 +1,340 @@
|
|||||||
|
name: Void Linux XBPS Repository
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 2,5,14,17,20,23 * * *" # 9am, 12pm, 3pm, 6pm, 9pm, 12am EST (UTC times shown)
|
||||||
|
# release trigger disabled; dispatch manually after a release
|
||||||
|
# release:
|
||||||
|
# types: [published]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: 'Stable release tag/version to build (e.g. v1.5.0 or 1.5.0). Leave blank to use the selected ref/template version'
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
force_rebuild:
|
||||||
|
description: 'Force rebuilding packages even if they already exist in the repository'
|
||||||
|
type: boolean
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
build_git:
|
||||||
|
description: 'Build dms-git package'
|
||||||
|
type: boolean
|
||||||
|
required: false
|
||||||
|
default: true
|
||||||
|
build_dms:
|
||||||
|
description: 'Build stable dms package'
|
||||||
|
type: boolean
|
||||||
|
required: false
|
||||||
|
default: true
|
||||||
|
build_greeter:
|
||||||
|
description: 'Build stable dms-greeter package'
|
||||||
|
type: boolean
|
||||||
|
required: false
|
||||||
|
default: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: void-xbps-${{ github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
preflight:
|
||||||
|
name: Check for unpublished changes
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
should_build: ${{ steps.check.outputs.should_build }}
|
||||||
|
steps:
|
||||||
|
- name: Check published commit
|
||||||
|
id: check
|
||||||
|
env:
|
||||||
|
MANIFEST_URL: https://void.danklinux.com/dms/current/manifest.json
|
||||||
|
run: |
|
||||||
|
SHOULD_BUILD=true
|
||||||
|
if [ "${{ github.event_name }}" = "schedule" ]; then
|
||||||
|
PUBLISHED_COMMIT="$(curl -fsSL --retry 3 "$MANIFEST_URL" | jq -r '.source_commit // empty' 2>/dev/null || true)"
|
||||||
|
if [ "$PUBLISHED_COMMIT" = "${{ github.sha }}" ]; then
|
||||||
|
SHOULD_BUILD=false
|
||||||
|
echo "${{ github.sha }} is already published; skipping the scheduled build."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
echo "should_build=$SHOULD_BUILD" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
build-and-deploy:
|
||||||
|
name: Build & Deploy XBPS packages
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: preflight
|
||||||
|
if: github.repository == 'AvengeMedia/DankMaterialShell' && needs.preflight.outputs.should_build == 'true'
|
||||||
|
env:
|
||||||
|
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||||
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||||
|
AWS_DEFAULT_REGION: auto
|
||||||
|
AWS_EC2_METADATA_DISABLED: true
|
||||||
|
R2_BUCKET: danklinux-void
|
||||||
|
R2_ENDPOINT: https://${{ secrets.CLOUDFLARE_ACCOUNT_ID }}.r2.cloudflarestorage.com
|
||||||
|
R2_PREFIX: dms
|
||||||
|
REPOSITORY_DIR: ${{ github.workspace }}/r2-repo
|
||||||
|
SOURCE_COMMIT: ${{ github.sha }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.release.tag_name || (github.event.inputs.version && (startsWith(github.event.inputs.version, 'v') && github.event.inputs.version || format('v{0}', github.event.inputs.version))) || github.ref }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Record checked-out source commit
|
||||||
|
run: echo "SOURCE_COMMIT=$(git rev-parse HEAD)" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Download current R2 repository
|
||||||
|
run: |
|
||||||
|
aws --version
|
||||||
|
bash .github/scripts/publish-void-r2.sh download
|
||||||
|
|
||||||
|
- name: Install XBPS Static Utilities
|
||||||
|
run: |
|
||||||
|
mkdir -p ${{ github.workspace }}/xbps
|
||||||
|
curl -s -L https://repo-default.voidlinux.org/static/xbps-static-latest.x86_64-musl.tar.xz | tar -xJ -C ${{ github.workspace }}/xbps
|
||||||
|
echo "${{ github.workspace }}/xbps/usr/bin" >> $GITHUB_PATH
|
||||||
|
|
||||||
|
- name: Clone void-packages
|
||||||
|
run: |
|
||||||
|
git clone --depth=1 https://github.com/void-linux/void-packages.git
|
||||||
|
|
||||||
|
- name: Inject templates
|
||||||
|
run: |
|
||||||
|
cp -R distro/void/srcpkgs/dms void-packages/srcpkgs/
|
||||||
|
cp -R distro/void/srcpkgs/dms-greeter void-packages/srcpkgs/
|
||||||
|
cp -R distro/void/srcpkgs/dms-git void-packages/srcpkgs/
|
||||||
|
|
||||||
|
- name: Enable unprivileged user namespaces (Ubuntu 24.04)
|
||||||
|
run: |
|
||||||
|
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true
|
||||||
|
|
||||||
|
- name: Bootstrap xbps-src
|
||||||
|
run: |
|
||||||
|
cd void-packages
|
||||||
|
./xbps-src binary-bootstrap
|
||||||
|
|
||||||
|
- name: Configure signing keys and trust
|
||||||
|
run: |
|
||||||
|
# Write private key; extract and register the public key
|
||||||
|
echo "${{ secrets.XBPS_PRIVATE_KEY }}" > /tmp/xbps_privkey.pem
|
||||||
|
chmod 600 /tmp/xbps_privkey.pem
|
||||||
|
|
||||||
|
# Extract public key in PEM format
|
||||||
|
openssl rsa -in /tmp/xbps_privkey.pem -pubout -out /tmp/dms-key.pub
|
||||||
|
rm -f /tmp/xbps_privkey.pem
|
||||||
|
|
||||||
|
# Compute MD5 fingerprint in colon-separated hex format
|
||||||
|
FINGERPRINT=$(openssl rsa -pubin -in /tmp/dms-key.pub -outform DER 2>/dev/null | openssl dgst -md5 -c | tr '[:upper:]' '[:lower:]' | awk '{print $NF}')
|
||||||
|
|
||||||
|
# Format key in XML property list (plist) format as expected by xbps
|
||||||
|
mkdir -p /tmp/keys
|
||||||
|
cat <<EOF > "/tmp/keys/${FINGERPRINT}.plist"
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>public-key</key>
|
||||||
|
<data>$(base64 -w0 /tmp/dms-key.pub)</data>
|
||||||
|
<key>public-key-size</key>
|
||||||
|
<integer>4096</integer>
|
||||||
|
<key>signature-by</key>
|
||||||
|
<string>AvengeMedia</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Copy keys to all host and chroot trust stores
|
||||||
|
sudo mkdir -p /var/db/xbps/keys
|
||||||
|
sudo cp "/tmp/keys/${FINGERPRINT}.plist" "/var/db/xbps/keys/${FINGERPRINT}.plist"
|
||||||
|
|
||||||
|
mkdir -p void-packages/masterdir/var/db/xbps/keys
|
||||||
|
cp "/tmp/keys/${FINGERPRINT}.plist" "void-packages/masterdir/var/db/xbps/keys/${FINGERPRINT}.plist"
|
||||||
|
|
||||||
|
mkdir -p void-packages/etc/xbps.d/keys
|
||||||
|
cp "/tmp/keys/${FINGERPRINT}.plist" "void-packages/etc/xbps.d/keys/${FINGERPRINT}.plist"
|
||||||
|
|
||||||
|
mkdir -p void-packages/common/repo-keys
|
||||||
|
cp "/tmp/keys/${FINGERPRINT}.plist" "void-packages/common/repo-keys/${FINGERPRINT}.plist"
|
||||||
|
|
||||||
|
rm -rf /tmp/keys /tmp/dms-key.pub
|
||||||
|
|
||||||
|
- name: Configure repositories
|
||||||
|
run: |
|
||||||
|
# Append the repository to repos-remote templates so xbps-src translates it automatically
|
||||||
|
echo "repository=https://void.danklinux.com/danklinux/current" >> void-packages/etc/xbps.d/repos-remote.conf
|
||||||
|
echo "repository=https://void.danklinux.com/danklinux/current" >> void-packages/etc/xbps.d/repos-remote-x86_64-multilib.conf
|
||||||
|
|
||||||
|
# Add any existing compiled packages to the build cache directory to avoid rebuilds
|
||||||
|
if [ -d "r2-repo/current" ]; then
|
||||||
|
mkdir -p void-packages/hostdir/binpkgs
|
||||||
|
cp -L r2-repo/current/*.xbps void-packages/hostdir/binpkgs/ 2>/dev/null || true
|
||||||
|
xbps-rindex -a void-packages/hostdir/binpkgs/*.xbps 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build packages
|
||||||
|
run: |
|
||||||
|
mkdir -p r2-repo/current
|
||||||
|
|
||||||
|
BUILD_DMS="${{ github.event.inputs.build_dms || 'true' }}"
|
||||||
|
BUILD_GREETER="${{ github.event.inputs.build_greeter || 'true' }}"
|
||||||
|
BUILD_GIT="${{ github.event.inputs.build_git || 'true' }}"
|
||||||
|
FORCE_REBUILD="${{ github.event.inputs.force_rebuild || 'false' }}"
|
||||||
|
|
||||||
|
if [ "${{ github.event_name }}" = "schedule" ]; then
|
||||||
|
BUILD_DMS="false"
|
||||||
|
BUILD_GREETER="false"
|
||||||
|
BUILD_GIT="true"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${{ github.event_name }}" = "release" ]; then
|
||||||
|
BUILD_DMS="true"
|
||||||
|
BUILD_GREETER="true"
|
||||||
|
BUILD_GIT="false"
|
||||||
|
fi
|
||||||
|
|
||||||
|
RELEASE_TAG=""
|
||||||
|
if [ "${{ github.event_name }}" = "release" ]; then
|
||||||
|
RELEASE_TAG="${{ github.event.release.tag_name }}"
|
||||||
|
elif [ -n "${{ github.event.inputs.version || '' }}" ]; then
|
||||||
|
RELEASE_TAG="${{ github.event.inputs.version }}"
|
||||||
|
elif [ "${{ github.ref_type }}" = "tag" ]; then
|
||||||
|
RELEASE_TAG="${{ github.ref_name }}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$RELEASE_TAG" ]; then
|
||||||
|
RELEASE_VER="${RELEASE_TAG#v}"
|
||||||
|
ARCHIVE_TAG="v${RELEASE_VER}"
|
||||||
|
echo "Stable release tag: $ARCHIVE_TAG"
|
||||||
|
else
|
||||||
|
RELEASE_VER=""
|
||||||
|
ARCHIVE_TAG=""
|
||||||
|
echo "Stable release tag: (template version)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Starting Builds ==="
|
||||||
|
echo "DMS stable build enabled: $BUILD_DMS"
|
||||||
|
echo "Greeter stable build enabled: $BUILD_GREETER"
|
||||||
|
echo "Git build enabled: $BUILD_GIT"
|
||||||
|
echo "Force rebuild: $FORCE_REBUILD"
|
||||||
|
|
||||||
|
cd void-packages
|
||||||
|
|
||||||
|
if [ -n "$RELEASE_VER" ] && { [ "$BUILD_DMS" = "true" ] || [ "$BUILD_GREETER" = "true" ]; }; then
|
||||||
|
echo "🔧 Updating stable templates for $ARCHIVE_TAG"
|
||||||
|
TARBALL="$(mktemp)"
|
||||||
|
curl -fsSL -o "$TARBALL" "https://github.com/${{ github.repository }}/archive/refs/tags/${ARCHIVE_TAG}.tar.gz"
|
||||||
|
RELEASE_CHECKSUM="$(sha256sum "$TARBALL" | cut -d' ' -f1)"
|
||||||
|
rm -f "$TARBALL"
|
||||||
|
|
||||||
|
for pkg in dms dms-greeter; do
|
||||||
|
sed -i "s/^version=.*/version=${RELEASE_VER}/" "srcpkgs/${pkg}/template"
|
||||||
|
sed -i "s/^checksum=.*/checksum=${RELEASE_CHECKSUM}/" "srcpkgs/${pkg}/template"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 1. Build dms-git (development package)
|
||||||
|
if [ "$BUILD_GIT" = "true" ]; then
|
||||||
|
# Calculate dynamic git version (tag.commits.hash)
|
||||||
|
GIT_VER=$(git -C .. describe --tags --always | sed 's/^v//; s/-/./g')
|
||||||
|
echo "🔨 Preparing dms-git version $GIT_VER"
|
||||||
|
|
||||||
|
# Stage source tarball in the xbps-src source cache.
|
||||||
|
# --transform adds a top-level directory so xbps-src can extract
|
||||||
|
# into $wrksrc (create_wrksrc=yes handles the rest).
|
||||||
|
SRC_CACHE="hostdir/sources/dms-git-${GIT_VER}"
|
||||||
|
mkdir -p "$SRC_CACHE"
|
||||||
|
tar -czf "${SRC_CACHE}/dms-git-${GIT_VER}.tar.gz" \
|
||||||
|
--exclude=void-packages \
|
||||||
|
--exclude=r2-repo \
|
||||||
|
--exclude=.git \
|
||||||
|
--exclude=danklinux \
|
||||||
|
-C .. .
|
||||||
|
|
||||||
|
CHECKSUM=$(sha256sum "${SRC_CACHE}/dms-git-${GIT_VER}.tar.gz" | cut -d' ' -f1)
|
||||||
|
|
||||||
|
# Dynamically patch template version, checksum, and distfiles
|
||||||
|
sed -i "s/^version=.*/version=${GIT_VER}/" srcpkgs/dms-git/template
|
||||||
|
sed -i "s/^checksum=.*/checksum=${CHECKSUM}/" srcpkgs/dms-git/template
|
||||||
|
sed -i "s|^distfiles=.*|distfiles=\"dms-git-${GIT_VER}.tar.gz\"|" srcpkgs/dms-git/template
|
||||||
|
|
||||||
|
EXPECTED_GIT_FILE="dms-git-${GIT_VER}_1.x86_64.xbps"
|
||||||
|
|
||||||
|
if [ -f "../r2-repo/current/$EXPECTED_GIT_FILE" ] && [ "$FORCE_REBUILD" != "true" ]; then
|
||||||
|
echo "✅ $EXPECTED_GIT_FILE already exists, skipping build."
|
||||||
|
else
|
||||||
|
echo "🔨 Compiling dms-git..."
|
||||||
|
./xbps-src pkg dms-git
|
||||||
|
rm -f "../r2-repo/current/${EXPECTED_GIT_FILE}"
|
||||||
|
cp -L "hostdir/binpkgs/${EXPECTED_GIT_FILE}" ../r2-repo/current/
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Build stable dms package
|
||||||
|
if [ "$BUILD_DMS" = "true" ]; then
|
||||||
|
STABLE_VER=$(grep -E '^version=' srcpkgs/dms/template | cut -d= -f2 | tr -d '"')
|
||||||
|
STABLE_REV=$(grep -E '^revision=' srcpkgs/dms/template | cut -d= -f2 | tr -d '"')
|
||||||
|
|
||||||
|
EXPECTED_DMS_FILE="dms-${STABLE_VER}_${STABLE_REV}.x86_64.xbps"
|
||||||
|
if [ -f "../r2-repo/current/$EXPECTED_DMS_FILE" ] && [ "$FORCE_REBUILD" != "true" ]; then
|
||||||
|
echo "✅ $EXPECTED_DMS_FILE already exists, skipping build."
|
||||||
|
else
|
||||||
|
echo "🔨 Compiling dms ($STABLE_VER)..."
|
||||||
|
./xbps-src pkg dms
|
||||||
|
rm -f "../r2-repo/current/${EXPECTED_DMS_FILE}"
|
||||||
|
cp -L hostdir/binpkgs/dms-${STABLE_VER}_${STABLE_REV}.x86_64.xbps ../r2-repo/current/
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. Build stable dms-greeter package
|
||||||
|
if [ "$BUILD_GREETER" = "true" ]; then
|
||||||
|
GREETER_VER=$(grep -E '^version=' srcpkgs/dms-greeter/template | cut -d= -f2 | tr -d '"')
|
||||||
|
GREETER_REV=$(grep -E '^revision=' srcpkgs/dms-greeter/template | cut -d= -f2 | tr -d '"')
|
||||||
|
|
||||||
|
EXPECTED_GREETER_FILE="dms-greeter-${GREETER_VER}_${GREETER_REV}.x86_64.xbps"
|
||||||
|
if [ -f "../r2-repo/current/$EXPECTED_GREETER_FILE" ] && [ "$FORCE_REBUILD" != "true" ]; then
|
||||||
|
echo "✅ $EXPECTED_GREETER_FILE already exists, skipping build."
|
||||||
|
else
|
||||||
|
echo "🔨 Compiling dms-greeter ($GREETER_VER)..."
|
||||||
|
./xbps-src pkg dms-greeter
|
||||||
|
rm -f "../r2-repo/current/${EXPECTED_GREETER_FILE}"
|
||||||
|
cp -L hostdir/binpkgs/dms-greeter-${GREETER_VER}_${GREETER_REV}.x86_64.xbps ../r2-repo/current/
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Index and sign repository
|
||||||
|
run: |
|
||||||
|
cd r2-repo/current
|
||||||
|
|
||||||
|
rm -f *.sig
|
||||||
|
|
||||||
|
# Guard: nothing to index if no .xbps files exist
|
||||||
|
if ! ls *.xbps 1>/dev/null 2>&1; then
|
||||||
|
echo "⚠️ No .xbps files found to index, skipping."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Regenerate repo index
|
||||||
|
xbps-rindex -a $(pwd)/*.xbps
|
||||||
|
xbps-rindex --remove-obsoletes $(pwd)
|
||||||
|
|
||||||
|
# Remove signatures left behind by obsolete packages. Signatures for
|
||||||
|
# unchanged immutable packages are retained byte-for-byte.
|
||||||
|
for sig in *.sig2; do
|
||||||
|
[ -e "$sig" ] || break
|
||||||
|
[ -f "${sig%.sig2}" ] || rm -f "$sig"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Sign repository
|
||||||
|
echo "${{ secrets.XBPS_PRIVATE_KEY }}" > /tmp/xbps_privkey.pem
|
||||||
|
chmod 600 /tmp/xbps_privkey.pem
|
||||||
|
|
||||||
|
xbps-rindex --sign --signedby "AvengeMedia <AvengeMedia.US@gmail.com>" --privkey /tmp/xbps_privkey.pem $(pwd)
|
||||||
|
xbps-rindex --sign-pkg --privkey /tmp/xbps_privkey.pem $(pwd)/*.xbps
|
||||||
|
|
||||||
|
rm -f /tmp/xbps_privkey.pem
|
||||||
|
|
||||||
|
- name: Publish repository to R2
|
||||||
|
run: bash .github/scripts/publish-void-r2.sh publish
|
||||||
@@ -18,7 +18,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Create GitHub App token
|
- name: Create GitHub App token
|
||||||
id: app_token
|
id: app_token
|
||||||
uses: actions/create-github-app-token@v1
|
uses: actions/create-github-app-token@v3
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.APP_ID }}
|
app-id: ${{ secrets.APP_ID }}
|
||||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||||
|
|||||||
+12
@@ -110,6 +110,9 @@ bin/
|
|||||||
# Core dumps
|
# Core dumps
|
||||||
core.*
|
core.*
|
||||||
|
|
||||||
|
# prek-installed local git hooks (generated from .pre-commit-config.yaml)
|
||||||
|
.githooks/
|
||||||
|
|
||||||
# direnv
|
# direnv
|
||||||
.envrc
|
.envrc
|
||||||
.direnv/
|
.direnv/
|
||||||
@@ -117,3 +120,12 @@ quickshell/dms-plugins
|
|||||||
__pycache__
|
__pycache__
|
||||||
|
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
|
# Void (xbps) build artifacts
|
||||||
|
*.xbps
|
||||||
|
distro/void/temp/
|
||||||
|
distro/void/hostdir/
|
||||||
|
distro/void/masterdir*/
|
||||||
|
|
||||||
|
# Often gets built
|
||||||
|
core/dms
|
||||||
|
|||||||
@@ -20,6 +20,22 @@ 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: settings-search-index
|
||||||
|
name: settings search index is up to date
|
||||||
|
entry: bash -c 'python3 quickshell/translations/extract_settings_index.py >/dev/null || exit 1; if ! git diff --exit-code -- quickshell/translations/settings_search_index.json; then echo "settings_search_index.json is out of date; run quickshell/translations/extract_settings_index.py and stage the result" >&2; exit 1; fi'
|
||||||
|
language: system
|
||||||
|
files: ^quickshell/(Modules/Settings/.*\.qml|Modals/Settings/SettingsSidebar\.qml|translations/extract_settings_index\.py)$
|
||||||
|
pass_filenames: false
|
||||||
|
- repo: local
|
||||||
|
hooks:
|
||||||
|
- id: i18n-term-freeze
|
||||||
|
name: i18n term freeze (no new I18n.tr/qsTr terms)
|
||||||
|
entry: python3 quickshell/translations/check_term_freeze.py
|
||||||
|
language: system
|
||||||
|
files: ^quickshell/(.*\.qml|translations/(term_freeze\.json|check_term_freeze\.py|extract_translations\.py))$
|
||||||
|
pass_filenames: false
|
||||||
- repo: local
|
- repo: local
|
||||||
hooks:
|
hooks:
|
||||||
- id: no-console-in-qml
|
- id: no-console-in-qml
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ BINARY_NAME=dms
|
|||||||
CORE_DIR=core
|
CORE_DIR=core
|
||||||
BUILD_DIR=$(CORE_DIR)/bin
|
BUILD_DIR=$(CORE_DIR)/bin
|
||||||
PREFIX ?= /usr/local
|
PREFIX ?= /usr/local
|
||||||
INSTALL_DIR=$(PREFIX)/bin
|
INSTALL_DIR=$(DESTDIR)$(PREFIX)/bin
|
||||||
DATA_DIR=$(PREFIX)/share
|
DATA_DIR=$(DESTDIR)$(PREFIX)/share
|
||||||
ICON_DIR=$(DATA_DIR)/icons/hicolor/scalable/apps
|
ICON_DIR=$(DATA_DIR)/icons/hicolor/scalable/apps
|
||||||
|
|
||||||
USER_HOME := $(if $(SUDO_USER),$(shell getent passwd $(SUDO_USER) | cut -d: -f6),$(HOME))
|
USER_HOME := $(if $(SUDO_USER),$(shell getent passwd $(SUDO_USER) | cut -d: -f6),$(HOME))
|
||||||
@@ -62,7 +62,7 @@ install-systemd:
|
|||||||
@echo "Installing systemd user service..."
|
@echo "Installing systemd user service..."
|
||||||
@mkdir -p $(SYSTEMD_USER_DIR)
|
@mkdir -p $(SYSTEMD_USER_DIR)
|
||||||
@if [ -n "$(SUDO_USER)" ]; then chown -R $(SUDO_USER):"$(id -gn $SUDO_USER)" $(SYSTEMD_USER_DIR); fi
|
@if [ -n "$(SUDO_USER)" ]; then chown -R $(SUDO_USER):"$(id -gn $SUDO_USER)" $(SYSTEMD_USER_DIR); fi
|
||||||
@sed 's|/usr/bin/dms|$(INSTALL_DIR)/dms|g' $(ASSETS_DIR)/systemd/dms.service > $(SYSTEMD_USER_DIR)/dms.service
|
@sed 's|/usr/bin/dms|$(PREFIX)/bin/dms|g' $(ASSETS_DIR)/systemd/dms.service > $(SYSTEMD_USER_DIR)/dms.service
|
||||||
@chmod 644 $(SYSTEMD_USER_DIR)/dms.service
|
@chmod 644 $(SYSTEMD_USER_DIR)/dms.service
|
||||||
@if [ -n "$(SUDO_USER)" ]; then chown $(SUDO_USER):"$(id -gn $SUDO_USER)" $(SYSTEMD_USER_DIR)/dms.service; fi
|
@if [ -n "$(SUDO_USER)" ]; then chown $(SUDO_USER):"$(id -gn $SUDO_USER)" $(SYSTEMD_USER_DIR)/dms.service; fi
|
||||||
@echo "Systemd service installed to $(SYSTEMD_USER_DIR)/dms.service"
|
@echo "Systemd service installed to $(SYSTEMD_USER_DIR)/dms.service"
|
||||||
@@ -74,10 +74,12 @@ install-icon:
|
|||||||
@echo "Icon installed"
|
@echo "Icon installed"
|
||||||
|
|
||||||
install-desktop:
|
install-desktop:
|
||||||
@echo "Installing desktop entry..."
|
@echo "Installing desktop entries..."
|
||||||
@install -D -m 644 $(ASSETS_DIR)/dms-open.desktop $(APPLICATIONS_DIR)/dms-open.desktop
|
@install -D -m 644 $(ASSETS_DIR)/dms-open.desktop $(APPLICATIONS_DIR)/dms-open.desktop
|
||||||
|
@install -D -m 644 $(ASSETS_DIR)/com.danklinux.dms.desktop $(APPLICATIONS_DIR)/com.danklinux.dms.desktop
|
||||||
|
@install -D -m 644 $(ASSETS_DIR)/com.danklinux.dms.notepad.desktop $(APPLICATIONS_DIR)/com.danklinux.dms.notepad.desktop
|
||||||
@update-desktop-database -q $(APPLICATIONS_DIR) 2>/dev/null || true
|
@update-desktop-database -q $(APPLICATIONS_DIR) 2>/dev/null || true
|
||||||
@echo "Desktop entry installed"
|
@echo "Desktop entries installed"
|
||||||
|
|
||||||
install: install-bin install-shell install-completions install-systemd install-icon install-desktop
|
install: install-bin install-shell install-completions install-systemd install-icon install-desktop
|
||||||
@echo ""
|
@echo ""
|
||||||
@@ -116,10 +118,12 @@ uninstall-icon:
|
|||||||
@echo "Icon removed"
|
@echo "Icon removed"
|
||||||
|
|
||||||
uninstall-desktop:
|
uninstall-desktop:
|
||||||
@echo "Removing desktop entry..."
|
@echo "Removing desktop entries..."
|
||||||
@rm -f $(APPLICATIONS_DIR)/dms-open.desktop
|
@rm -f $(APPLICATIONS_DIR)/dms-open.desktop
|
||||||
|
@rm -f $(APPLICATIONS_DIR)/com.danklinux.dms.desktop
|
||||||
|
@rm -f $(APPLICATIONS_DIR)/com.danklinux.dms.notepad.desktop
|
||||||
@update-desktop-database -q $(APPLICATIONS_DIR) 2>/dev/null || true
|
@update-desktop-database -q $(APPLICATIONS_DIR) 2>/dev/null || true
|
||||||
@echo "Desktop entry removed"
|
@echo "Desktop entries removed"
|
||||||
|
|
||||||
uninstall: uninstall-systemd uninstall-desktop uninstall-icon uninstall-completions uninstall-shell uninstall-bin
|
uninstall: uninstall-systemd uninstall-desktop uninstall-icon uninstall-completions uninstall-shell uninstall-bin
|
||||||
@echo ""
|
@echo ""
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=DankMaterialShell
|
||||||
|
GenericName=Desktop Shell
|
||||||
|
Comment=Material 3 inspired shell for Wayland compositors
|
||||||
|
Exec=dms run
|
||||||
|
Icon=danklogo
|
||||||
|
Terminal=false
|
||||||
|
NoDisplay=true
|
||||||
|
StartupNotify=false
|
||||||
|
Categories=Utility;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=DMS Notepad
|
||||||
|
GenericName=Text Editor
|
||||||
|
Comment=Quick notes and text files in the dank desktop shell
|
||||||
|
Exec=dms ipc call notepad openFile %f
|
||||||
|
Icon=danklogo
|
||||||
|
Terminal=false
|
||||||
|
NoDisplay=true
|
||||||
|
Categories=Utility;TextEditor;
|
||||||
|
MimeType=text/plain;text/markdown;
|
||||||
|
StartupNotify=false
|
||||||
+65
-62
@@ -1,82 +1,85 @@
|
|||||||
with-expecter: true
|
dir: internal/mocks/{{.InterfaceDirRelative}}
|
||||||
dir: "internal/mocks/{{.InterfaceDirRelative}}"
|
structname: Mock{{.InterfaceName}}
|
||||||
mockname: "Mock{{.InterfaceName}}"
|
pkgname: '{{.SrcPackageName}}'
|
||||||
outpkg: "{{.PackageName}}"
|
filename: 'mock_{{.InterfaceName}}.go'
|
||||||
|
template: testify
|
||||||
|
template-data:
|
||||||
|
unroll-variadic: true
|
||||||
packages:
|
packages:
|
||||||
github.com/Wifx/gonetworkmanager/v2:
|
|
||||||
interfaces:
|
|
||||||
NetworkManager:
|
|
||||||
Device:
|
|
||||||
DeviceWireless:
|
|
||||||
AccessPoint:
|
|
||||||
Connection:
|
|
||||||
Settings:
|
|
||||||
ActiveConnection:
|
|
||||||
IP4Config:
|
|
||||||
net:
|
|
||||||
interfaces:
|
|
||||||
Conn:
|
|
||||||
github.com/AvengeMedia/danklinux/internal/plugins:
|
|
||||||
interfaces:
|
|
||||||
GitClient:
|
|
||||||
github.com/godbus/dbus/v5:
|
|
||||||
interfaces:
|
|
||||||
BusObject:
|
|
||||||
github.com/AvengeMedia/danklinux/internal/server/brightness:
|
|
||||||
config:
|
|
||||||
dir: "internal/mocks/brightness"
|
|
||||||
outpkg: mocks_brightness
|
|
||||||
interfaces:
|
|
||||||
DBusConn:
|
|
||||||
github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation:
|
github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation:
|
||||||
config:
|
config:
|
||||||
dir: "internal/mocks/geolocation"
|
dir: internal/mocks/geolocation
|
||||||
outpkg: mocks_geolocation
|
pkgname: mocks_geolocation
|
||||||
interfaces:
|
interfaces:
|
||||||
Client:
|
Client: {}
|
||||||
github.com/AvengeMedia/DankMaterialShell/core/internal/server/network:
|
|
||||||
config:
|
|
||||||
dir: "internal/mocks/network"
|
|
||||||
outpkg: mocks_network
|
|
||||||
interfaces:
|
|
||||||
Backend:
|
|
||||||
github.com/AvengeMedia/DankMaterialShell/core/internal/server/cups:
|
github.com/AvengeMedia/DankMaterialShell/core/internal/server/cups:
|
||||||
config:
|
config:
|
||||||
dir: "internal/mocks/cups"
|
dir: internal/mocks/cups
|
||||||
outpkg: mocks_cups
|
pkgname: mocks_cups
|
||||||
interfaces:
|
interfaces:
|
||||||
CUPSClientInterface:
|
CUPSClientInterface: {}
|
||||||
PkHelper:
|
PkHelper:
|
||||||
config:
|
config:
|
||||||
dir: "internal/mocks/cups_pkhelper"
|
dir: internal/mocks/cups_pkhelper
|
||||||
outpkg: mocks_cups_pkhelper
|
pkgname: mocks_cups_pkhelper
|
||||||
github.com/AvengeMedia/DankMaterialShell/core/internal/server/evdev:
|
github.com/AvengeMedia/DankMaterialShell/core/internal/server/evdev:
|
||||||
config:
|
config:
|
||||||
dir: "internal/mocks/evdev"
|
dir: internal/mocks/evdev
|
||||||
outpkg: mocks_evdev
|
pkgname: mocks_evdev
|
||||||
interfaces:
|
interfaces:
|
||||||
EvdevDevice:
|
EvdevDevice: {}
|
||||||
github.com/AvengeMedia/DankMaterialShell/core/internal/version:
|
github.com/AvengeMedia/DankMaterialShell/core/internal/server/network:
|
||||||
config:
|
config:
|
||||||
dir: "internal/mocks/version"
|
dir: internal/mocks/network
|
||||||
outpkg: mocks_version
|
pkgname: mocks_network
|
||||||
interfaces:
|
interfaces:
|
||||||
VersionFetcher:
|
Backend: {}
|
||||||
github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext:
|
github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext:
|
||||||
config:
|
config:
|
||||||
dir: "internal/mocks/wlcontext"
|
dir: internal/mocks/wlcontext
|
||||||
outpkg: mocks_wlcontext
|
pkgname: mocks_wlcontext
|
||||||
interfaces:
|
interfaces:
|
||||||
WaylandContext:
|
WaylandContext: {}
|
||||||
github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client:
|
|
||||||
config:
|
|
||||||
dir: "internal/mocks/wlclient"
|
|
||||||
outpkg: mocks_wlclient
|
|
||||||
interfaces:
|
|
||||||
WaylandDisplay:
|
|
||||||
github.com/AvengeMedia/DankMaterialShell/core/internal/utils:
|
github.com/AvengeMedia/DankMaterialShell/core/internal/utils:
|
||||||
config:
|
config:
|
||||||
dir: "internal/mocks/utils"
|
dir: internal/mocks/utils
|
||||||
outpkg: mocks_utils
|
pkgname: mocks_utils
|
||||||
interfaces:
|
interfaces:
|
||||||
AppChecker:
|
AppChecker: {}
|
||||||
|
github.com/AvengeMedia/DankMaterialShell/core/internal/version:
|
||||||
|
config:
|
||||||
|
dir: internal/mocks/version
|
||||||
|
pkgname: mocks_version
|
||||||
|
interfaces:
|
||||||
|
VersionFetcher: {}
|
||||||
|
github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client:
|
||||||
|
config:
|
||||||
|
dir: internal/mocks/wlclient
|
||||||
|
pkgname: mocks_wlclient
|
||||||
|
interfaces:
|
||||||
|
WaylandDisplay: {}
|
||||||
|
github.com/AvengeMedia/DankMaterialShell/core/internal/plugins:
|
||||||
|
interfaces:
|
||||||
|
GitClient: {}
|
||||||
|
github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness:
|
||||||
|
config:
|
||||||
|
dir: internal/mocks/brightness
|
||||||
|
pkgname: mocks_brightness
|
||||||
|
interfaces:
|
||||||
|
DBusConn: {}
|
||||||
|
github.com/Wifx/gonetworkmanager/v2:
|
||||||
|
interfaces:
|
||||||
|
AccessPoint: {}
|
||||||
|
ActiveConnection: {}
|
||||||
|
Connection: {}
|
||||||
|
Device: {}
|
||||||
|
DeviceWireless: {}
|
||||||
|
IP4Config: {}
|
||||||
|
NetworkManager: {}
|
||||||
|
Settings: {}
|
||||||
|
github.com/godbus/dbus/v5:
|
||||||
|
interfaces:
|
||||||
|
BusObject: {}
|
||||||
|
net:
|
||||||
|
interfaces:
|
||||||
|
Conn: {}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/golangci/golangci-lint
|
- repo: https://github.com/golangci/golangci-lint
|
||||||
rev: v2.10.1
|
rev: v2.12.2
|
||||||
hooks:
|
hooks:
|
||||||
- id: golangci-lint-fmt
|
- id: golangci-lint-fmt
|
||||||
require_serial: true
|
require_serial: true
|
||||||
|
|||||||
+6
-3
@@ -10,7 +10,7 @@ Go-based backend for DankMaterialShell providing system integration, IPC, and in
|
|||||||
Command-line interface and daemon for shell management and system control.
|
Command-line interface and daemon for shell management and system control.
|
||||||
|
|
||||||
**dankinstall**
|
**dankinstall**
|
||||||
Distribution-aware installer for deploying DMS and compositor configurations on Arch, Fedora, Debian, Ubuntu, openSUSE, and Gentoo. Supports both an interactive TUI and a headless (unattended) mode via CLI flags.
|
Distribution-aware installer for deploying DMS and compositor configurations on Arch, Fedora, Debian, Ubuntu, openSUSE, Gentoo, and Void. Supports both an interactive TUI and a headless (unattended) mode via CLI flags.
|
||||||
|
|
||||||
## System Integration
|
## System Integration
|
||||||
|
|
||||||
@@ -193,7 +193,7 @@ Set the `DANKINSTALL_LOG_DIR` environment variable to override the log directory
|
|||||||
|
|
||||||
## Supported Distributions
|
## Supported Distributions
|
||||||
|
|
||||||
Arch, Fedora, Debian, Ubuntu, openSUSE, Gentoo (and derivatives)
|
Arch, Fedora, Debian, Ubuntu, openSUSE, Gentoo, Void (and derivatives)
|
||||||
|
|
||||||
**Arch Linux**
|
**Arch Linux**
|
||||||
Uses `pacman` for system packages, builds AUR packages via `makepkg`, no AUR helper dependency.
|
Uses `pacman` for system packages, builds AUR packages via `makepkg`, no AUR helper dependency.
|
||||||
@@ -206,7 +206,7 @@ Uses COPR repositories (`avengemedia/danklinux`, `avengemedia/dms`).
|
|||||||
Requires PPA support. Most packages built from source (slow first install).
|
Requires PPA support. Most packages built from source (slow first install).
|
||||||
|
|
||||||
**Debian**
|
**Debian**
|
||||||
Debian 13+ (Trixie). niri only, no Hyprland support. Builds from source.
|
Debian 13+ (Trixie), testing, and sid. Stable quickshell from Debian (trixie-backports on 13). Other companions via OBS.
|
||||||
|
|
||||||
**openSUSE**
|
**openSUSE**
|
||||||
Most packages available in standard repos. Minimal building required.
|
Most packages available in standard repos. Minimal building required.
|
||||||
@@ -214,4 +214,7 @@ Most packages available in standard repos. Minimal building required.
|
|||||||
**Gentoo**
|
**Gentoo**
|
||||||
Uses Portage with GURU overlay. Automatically configures USE flags. Variable success depending on system configuration.
|
Uses Portage with GURU overlay. Automatically configures USE flags. Variable success depending on system configuration.
|
||||||
|
|
||||||
|
**Void Linux**
|
||||||
|
Uses XBPS with the DMS and DankLinux self-hosted repositories.
|
||||||
|
|
||||||
See installer output for distribution-specific details during installation.
|
See installer output for distribution-specific details during installation.
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ var (
|
|||||||
replaceConfigs []string
|
replaceConfigs []string
|
||||||
replaceConfigsAll bool
|
replaceConfigsAll bool
|
||||||
yes bool
|
yes bool
|
||||||
|
danksearch bool
|
||||||
|
dankcalendar bool
|
||||||
)
|
)
|
||||||
|
|
||||||
var rootCmd = &cobra.Command{
|
var rootCmd = &cobra.Command{
|
||||||
@@ -49,6 +51,8 @@ func init() {
|
|||||||
rootCmd.Flags().StringSliceVar(&replaceConfigs, "replace-configs", []string{}, "Deploy only named configs (e.g. niri,ghostty)")
|
rootCmd.Flags().StringSliceVar(&replaceConfigs, "replace-configs", []string{}, "Deploy only named configs (e.g. niri,ghostty)")
|
||||||
rootCmd.Flags().BoolVar(&replaceConfigsAll, "replace-configs-all", false, "Deploy and replace all configurations")
|
rootCmd.Flags().BoolVar(&replaceConfigsAll, "replace-configs-all", false, "Deploy and replace all configurations")
|
||||||
rootCmd.Flags().BoolVarP(&yes, "yes", "y", false, "Auto-confirm all prompts")
|
rootCmd.Flags().BoolVarP(&yes, "yes", "y", false, "Auto-confirm all prompts")
|
||||||
|
rootCmd.Flags().BoolVar(&danksearch, "danksearch", false, "Install danksearch and enable its user indexing service")
|
||||||
|
rootCmd.Flags().BoolVar(&dankcalendar, "dankcalendar", false, "Install dankcalendar")
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -74,6 +78,8 @@ func runDankinstall(cmd *cobra.Command, args []string) error {
|
|||||||
"replace-configs",
|
"replace-configs",
|
||||||
"replace-configs-all",
|
"replace-configs-all",
|
||||||
"yes",
|
"yes",
|
||||||
|
"danksearch",
|
||||||
|
"dankcalendar",
|
||||||
}
|
}
|
||||||
var set []string
|
var set []string
|
||||||
for _, name := range headlessOnly {
|
for _, name := range headlessOnly {
|
||||||
@@ -109,6 +115,8 @@ func runHeadless() error {
|
|||||||
ReplaceConfigs: replaceConfigs,
|
ReplaceConfigs: replaceConfigs,
|
||||||
ReplaceConfigsAll: replaceConfigsAll,
|
ReplaceConfigsAll: replaceConfigsAll,
|
||||||
Yes: yes,
|
Yes: yes,
|
||||||
|
DankSearch: danksearch,
|
||||||
|
DankCalendar: dankcalendar,
|
||||||
}
|
}
|
||||||
|
|
||||||
runner := headless.NewRunner(cfg)
|
runner := headless.NewRunner(cfg)
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
@@ -35,9 +38,142 @@ var authSyncCmd = &cobra.Command{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var authResolveLockCmd = &cobra.Command{
|
||||||
|
Use: "resolve-lock",
|
||||||
|
Short: "Generate the lock-screen PAM config from the system auth stack",
|
||||||
|
Long: "Resolve the distribution's PAM auth stack into a self-contained lock-screen config under the user state directory.\n" +
|
||||||
|
"Runs unprivileged (reads /etc/pam.d, writes to the user's state dir) and is used by the shell as a fallback when /etc/pam.d/dankshell is not managed.\n" +
|
||||||
|
"Prints the path of the generated file.",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
quiet, _ := cmd.Flags().GetBool("quiet")
|
||||||
|
logFunc := func(msg string) {
|
||||||
|
if !quiet {
|
||||||
|
fmt.Println(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
path, err := sharedpam.WriteUserLockscreenPamConfig(logFunc)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error resolving lock-screen PAM config: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Println(path)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var authListServicesCmd = &cobra.Command{
|
||||||
|
Use: "list-services",
|
||||||
|
Short: "List candidate lock-screen PAM services available on this system",
|
||||||
|
Long: "Enumerate the lock-screen PAM services that exist on this system and report their resolved auth stack (whether it has an auth directive and whether fingerprint/U2F modules appear inline).",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
asJSON, _ := cmd.Flags().GetBool("json")
|
||||||
|
services := sharedpam.ListLockscreenPamServices()
|
||||||
|
|
||||||
|
if asJSON {
|
||||||
|
payload := struct {
|
||||||
|
Services []sharedpam.LockscreenPamServiceInfo `json:"services"`
|
||||||
|
}{Services: services}
|
||||||
|
data, err := json.MarshalIndent(payload, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error encoding services: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Println(string(data))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(services) == 0 {
|
||||||
|
fmt.Println("No candidate lock-screen PAM services found.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, s := range services {
|
||||||
|
fmt.Printf("%-20s %-30s auth=%-5t fingerprint=%-5t u2f=%t\n", s.Name, s.Path, s.HasAuth, s.InlineFingerprint, s.InlineU2f)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var authValidateCmd = &cobra.Command{
|
||||||
|
Use: "validate",
|
||||||
|
Short: "Validate a PAM service file for use by the DMS lock screen",
|
||||||
|
Long: "Validate one PAM service (by --service NAME or --path /abs/file) for use as the DMS lock-screen password or dedicated U2F stack. Exits 1 when the file is not usable.",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
path, _ := cmd.Flags().GetString("path")
|
||||||
|
service, _ := cmd.Flags().GetString("service")
|
||||||
|
purpose, _ := cmd.Flags().GetString("purpose")
|
||||||
|
asJSON, _ := cmd.Flags().GetBool("json")
|
||||||
|
|
||||||
|
if (path == "") == (service == "") {
|
||||||
|
log.Fatalf("Error: exactly one of --path or --service is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if purpose != "password" && purpose != "u2f" {
|
||||||
|
log.Fatalf("Error: --purpose must be password or u2f")
|
||||||
|
}
|
||||||
|
|
||||||
|
var result sharedpam.LockscreenPamValidation
|
||||||
|
switch {
|
||||||
|
case service != "":
|
||||||
|
if purpose == "u2f" {
|
||||||
|
result = sharedpam.ValidateLockscreenU2fPamService(service)
|
||||||
|
} else {
|
||||||
|
result = sharedpam.ValidateLockscreenPamService(service)
|
||||||
|
}
|
||||||
|
case !filepath.IsAbs(path):
|
||||||
|
result = sharedpam.LockscreenPamValidation{
|
||||||
|
Path: path,
|
||||||
|
MissingModules: []string{},
|
||||||
|
Warnings: []string{},
|
||||||
|
Errors: []string{"--path must be an absolute file path"},
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if purpose == "u2f" {
|
||||||
|
result = sharedpam.ValidateLockscreenU2fPamPath(path)
|
||||||
|
} else {
|
||||||
|
result = sharedpam.ValidateLockscreenPamPath(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if asJSON {
|
||||||
|
data, err := json.MarshalIndent(result, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error encoding validation: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Println(string(data))
|
||||||
|
} else {
|
||||||
|
printLockscreenPamValidation(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !result.Valid {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func printLockscreenPamValidation(result sharedpam.LockscreenPamValidation) {
|
||||||
|
fmt.Printf("Path: %s\n", result.Path)
|
||||||
|
fmt.Printf("Valid: %t\n", result.Valid)
|
||||||
|
fmt.Printf("Has auth: %t\n", result.HasAuth)
|
||||||
|
fmt.Printf("Inline fingerprint: %t\n", result.InlineFingerprint)
|
||||||
|
fmt.Printf("Inline U2F: %t\n", result.InlineU2f)
|
||||||
|
if len(result.MissingModules) > 0 {
|
||||||
|
fmt.Printf("Missing modules: %s\n", strings.Join(result.MissingModules, ", "))
|
||||||
|
}
|
||||||
|
for _, w := range result.Warnings {
|
||||||
|
fmt.Println("⚠ " + w)
|
||||||
|
}
|
||||||
|
for _, e := range result.Errors {
|
||||||
|
fmt.Println("✗ " + e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
authSyncCmd.Flags().BoolP("yes", "y", false, "Non-interactive mode: skip prompts")
|
authSyncCmd.Flags().BoolP("yes", "y", false, "Non-interactive mode: skip prompts")
|
||||||
authSyncCmd.Flags().BoolP("terminal", "t", false, "Run auth sync in a new terminal (for entering sudo password)")
|
authSyncCmd.Flags().BoolP("terminal", "t", false, "Run auth sync in a new terminal (for entering sudo password)")
|
||||||
|
authResolveLockCmd.Flags().BoolP("quiet", "q", false, "Only print the resulting file path")
|
||||||
|
|
||||||
|
authListServicesCmd.Flags().Bool("json", false, "Output as JSON")
|
||||||
|
|
||||||
|
authValidateCmd.Flags().String("path", "", "Absolute path to a PAM service file to validate")
|
||||||
|
authValidateCmd.Flags().String("service", "", "Name of a PAM service to resolve across the system PAM dirs")
|
||||||
|
authValidateCmd.Flags().String("purpose", "password", "Validation purpose: password or u2f")
|
||||||
|
authValidateCmd.Flags().Bool("json", false, "Output as JSON")
|
||||||
}
|
}
|
||||||
|
|
||||||
func syncAuth(nonInteractive bool) error {
|
func syncAuth(nonInteractive bool) error {
|
||||||
|
|||||||
@@ -63,6 +63,15 @@ var clipPasteCmd = &cobra.Command{
|
|||||||
Run: runClipPaste,
|
Run: runClipPaste,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var clipSendPasteCmd = &cobra.Command{
|
||||||
|
Use: "send-paste",
|
||||||
|
Short: "Send a paste keystroke to the focused window",
|
||||||
|
Long: "Emulate ctrl+v (or ctrl+shift+v with --shift) via a virtual keyboard. Works without server.",
|
||||||
|
Run: runClipSendPaste,
|
||||||
|
}
|
||||||
|
|
||||||
|
var clipSendPasteShift bool
|
||||||
|
|
||||||
var clipWatchCmd = &cobra.Command{
|
var clipWatchCmd = &cobra.Command{
|
||||||
Use: "watch [command]",
|
Use: "watch [command]",
|
||||||
Short: "Watch clipboard for changes",
|
Short: "Watch clipboard for changes",
|
||||||
@@ -216,8 +225,10 @@ func init() {
|
|||||||
|
|
||||||
clipMigrateCmd.Flags().BoolVar(&clipMigrateDelete, "delete", false, "Delete cliphist db after successful migration")
|
clipMigrateCmd.Flags().BoolVar(&clipMigrateDelete, "delete", false, "Delete cliphist db after successful migration")
|
||||||
|
|
||||||
|
clipSendPasteCmd.Flags().BoolVarP(&clipSendPasteShift, "shift", "s", false, "Send ctrl+shift+v (terminal paste)")
|
||||||
|
|
||||||
clipConfigCmd.AddCommand(clipConfigGetCmd, clipConfigSetCmd)
|
clipConfigCmd.AddCommand(clipConfigGetCmd, clipConfigSetCmd)
|
||||||
clipboardCmd.AddCommand(clipCopyCmd, clipPasteCmd, clipWatchCmd, clipHistoryCmd, clipGetCmd, clipDeleteCmd, clipClearCmd, clipSearchCmd, clipConfigCmd, clipExportCmd, clipImportCmd, clipMigrateCmd)
|
clipboardCmd.AddCommand(clipCopyCmd, clipPasteCmd, clipSendPasteCmd, clipWatchCmd, clipHistoryCmd, clipGetCmd, clipDeleteCmd, clipClearCmd, clipSearchCmd, clipConfigCmd, clipExportCmd, clipImportCmd, clipMigrateCmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
func runClipCopy(cmd *cobra.Command, args []string) {
|
func runClipCopy(cmd *cobra.Command, args []string) {
|
||||||
@@ -314,6 +325,12 @@ func runClipPaste(cmd *cobra.Command, args []string) {
|
|||||||
os.Stdout.Write(data)
|
os.Stdout.Write(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runClipSendPaste(cmd *cobra.Command, args []string) {
|
||||||
|
if err := clipboard.SendPasteKeystroke(clipSendPasteShift); err != nil {
|
||||||
|
log.Fatalf("send-paste: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func runClipWatch(cmd *cobra.Command, args []string) {
|
func runClipWatch(cmd *cobra.Command, args []string) {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|||||||
@@ -19,7 +19,12 @@ var (
|
|||||||
var colorCmd = &cobra.Command{
|
var colorCmd = &cobra.Command{
|
||||||
Use: "color",
|
Use: "color",
|
||||||
Short: "Color utilities",
|
Short: "Color utilities",
|
||||||
Long: "Color utilities including picking colors from the screen",
|
Long: `Color utilities including picking colors from the screen.
|
||||||
|
|
||||||
|
This is the screen eyedropper CLI. To open the in-shell color modal, use:
|
||||||
|
dms ipc call color-picker toggle
|
||||||
|
|
||||||
|
See: https://danklinux.com/docs/dankmaterialshell/keybinds-ipc`,
|
||||||
}
|
}
|
||||||
|
|
||||||
var colorPickCmd = &cobra.Command{
|
var colorPickCmd = &cobra.Command{
|
||||||
@@ -29,6 +34,9 @@ var colorPickCmd = &cobra.Command{
|
|||||||
|
|
||||||
Click on any pixel to capture its color, or press Escape to cancel.
|
Click on any pixel to capture its color, or press Escape to cancel.
|
||||||
|
|
||||||
|
This is the screen eyedropper CLI. To open the in-shell color modal, use:
|
||||||
|
dms ipc call color-picker toggle
|
||||||
|
|
||||||
Output format flags (mutually exclusive, default: --hex):
|
Output format flags (mutually exclusive, default: --hex):
|
||||||
--hex - Hexadecimal (#RRGGBB)
|
--hex - Hexadecimal (#RRGGBB)
|
||||||
--rgb - RGB values (R G B)
|
--rgb - RGB values (R G B)
|
||||||
|
|||||||
@@ -77,10 +77,15 @@ var killCmd = &cobra.Command{
|
|||||||
}
|
}
|
||||||
|
|
||||||
var ipcCmd = &cobra.Command{
|
var ipcCmd = &cobra.Command{
|
||||||
Use: "ipc [target] [function] [args...]",
|
Use: "ipc",
|
||||||
Short: "Send IPC commands to running DMS shell",
|
Short: "Send IPC commands to running DMS shell",
|
||||||
|
Long: `Send IPC commands to the running DMS shell.
|
||||||
|
|
||||||
|
dms ipc call <target> <function> [args...] invoke a command
|
||||||
|
dms ipc list list all targets and functions
|
||||||
|
|
||||||
|
Full reference: https://danklinux.com/docs/dankmaterialshell/keybinds-ipc`,
|
||||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
_ = findConfig(cmd, args)
|
|
||||||
return getShellIPCCompletions(args, toComplete), cobra.ShellCompDirectiveNoFileComp
|
return getShellIPCCompletions(args, toComplete), cobra.ShellCompDirectiveNoFileComp
|
||||||
},
|
},
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
@@ -88,11 +93,21 @@ var ipcCmd = &cobra.Command{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var ipcListCmd = &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List all IPC targets and functions",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
printIPCHelp()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
ipcCmd.AddCommand(ipcListCmd)
|
||||||
ipcCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) {
|
ipcCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) {
|
||||||
_ = findConfig(cmd, args)
|
|
||||||
printIPCHelp()
|
printIPCHelp()
|
||||||
})
|
})
|
||||||
|
pluginsUpdateCmd.Flags().BoolP("all", "a", false, "Update all installed plugins")
|
||||||
|
pluginsUpdateCmd.Flags().Bool("check", false, "Check for available updates without applying them")
|
||||||
}
|
}
|
||||||
|
|
||||||
var debugSrvCmd = &cobra.Command{
|
var debugSrvCmd = &cobra.Command{
|
||||||
@@ -171,10 +186,22 @@ var pluginsUninstallCmd = &cobra.Command{
|
|||||||
}
|
}
|
||||||
|
|
||||||
var pluginsUpdateCmd = &cobra.Command{
|
var pluginsUpdateCmd = &cobra.Command{
|
||||||
Use: "update <plugin-id>",
|
Use: "update [plugin-id]",
|
||||||
Short: "Update a plugin by ID",
|
Short: "Update a plugin by ID, or all plugins",
|
||||||
Long: "Update an installed DMS plugin using its ID (e.g., 'myPlugin'). Plugin names are also supported.",
|
Long: "Update an installed DMS plugin using its ID (e.g., 'myPlugin'). If --all or -a is specified, all installed plugins will be updated.",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: func(cmd *cobra.Command, args []string) error {
|
||||||
|
updateAll, _ := cmd.Flags().GetBool("all")
|
||||||
|
if updateAll {
|
||||||
|
if len(args) > 0 {
|
||||||
|
return fmt.Errorf("cannot specify plugin ID when using --all/-a")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(args) != 1 {
|
||||||
|
return fmt.Errorf("requires exactly 1 arg (plugin ID) or use --all/-a")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
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 nil, cobra.ShellCompDirectiveNoFileComp
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
@@ -182,6 +209,26 @@ var pluginsUpdateCmd = &cobra.Command{
|
|||||||
return getInstalledPluginIDs(), cobra.ShellCompDirectiveNoFileComp
|
return getInstalledPluginIDs(), cobra.ShellCompDirectiveNoFileComp
|
||||||
},
|
},
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
checkOnly, _ := cmd.Flags().GetBool("check")
|
||||||
|
updateAll, _ := cmd.Flags().GetBool("all")
|
||||||
|
if checkOnly {
|
||||||
|
if updateAll {
|
||||||
|
if err := checkAllPluginsCLI(); err != nil {
|
||||||
|
log.Fatalf("Error checking updates: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := checkPluginCLI(args[0]); err != nil {
|
||||||
|
log.Fatalf("Error checking update: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if updateAll {
|
||||||
|
if err := updateAllPluginsCLI(); err != nil {
|
||||||
|
log.Fatalf("Error updating plugins: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := updatePluginCLI(args[0]); err != nil {
|
if err := updatePluginCLI(args[0]); err != nil {
|
||||||
log.Fatalf("Error updating plugin: %v", err)
|
log.Fatalf("Error updating plugin: %v", err)
|
||||||
}
|
}
|
||||||
@@ -267,6 +314,13 @@ func browsePlugins() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
feedback := plugins.FetchFeedback()
|
||||||
|
|
||||||
|
nameByID := make(map[string]string, len(pluginList))
|
||||||
|
for _, plugin := range pluginList {
|
||||||
|
nameByID[plugin.ID] = plugin.Name
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Printf("\nAvailable Plugins (%d):\n\n", len(pluginList))
|
fmt.Printf("\nAvailable Plugins (%d):\n\n", len(pluginList))
|
||||||
for _, plugin := range pluginList {
|
for _, plugin := range pluginList {
|
||||||
installed, _ := manager.IsInstalled(plugin)
|
installed, _ := manager.IsInstalled(plugin)
|
||||||
@@ -290,6 +344,26 @@ func browsePlugins() error {
|
|||||||
if len(plugin.Dependencies) > 0 {
|
if len(plugin.Dependencies) > 0 {
|
||||||
fmt.Printf(" Dependencies: %s\n", strings.Join(plugin.Dependencies, ", "))
|
fmt.Printf(" Dependencies: %s\n", strings.Join(plugin.Dependencies, ", "))
|
||||||
}
|
}
|
||||||
|
if fb, ok := feedback[plugin.ID]; ok {
|
||||||
|
fmt.Printf(" Upvotes: %d\n", fb.Upvotes)
|
||||||
|
if len(fb.Status) > 0 {
|
||||||
|
fmt.Printf(" Status: %s\n", strings.Join(fb.Status, ", "))
|
||||||
|
}
|
||||||
|
if fb.IssueURL != "" {
|
||||||
|
fmt.Printf(" Discuss: %s\n", fb.IssueURL)
|
||||||
|
}
|
||||||
|
if len(fb.Similar) > 0 {
|
||||||
|
names := make([]string, len(fb.Similar))
|
||||||
|
for i, id := range fb.Similar {
|
||||||
|
if name, ok := nameByID[id]; ok {
|
||||||
|
names[i] = name
|
||||||
|
} else {
|
||||||
|
names[i] = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Printf(" Related: %s\n", strings.Join(names, ", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,7 +404,11 @@ func listInstalledPlugins() error {
|
|||||||
fmt.Printf("\nInstalled Plugins (%d):\n\n", len(installedNames))
|
fmt.Printf("\nInstalled Plugins (%d):\n\n", len(installedNames))
|
||||||
for _, id := range installedNames {
|
for _, id := range installedNames {
|
||||||
if plugin, ok := pluginMap[id]; ok {
|
if plugin, ok := pluginMap[id]; ok {
|
||||||
fmt.Printf(" %s\n", plugin.Name)
|
hasUpdateStr := ""
|
||||||
|
if hasUpdates, _, err := manager.HasUpdates(id, plugin); err == nil && hasUpdates {
|
||||||
|
hasUpdateStr = " (update available)"
|
||||||
|
}
|
||||||
|
fmt.Printf(" %s%s\n", plugin.Name, hasUpdateStr)
|
||||||
fmt.Printf(" ID: %s\n", plugin.ID)
|
fmt.Printf(" ID: %s\n", plugin.ID)
|
||||||
fmt.Printf(" Category: %s\n", plugin.Category)
|
fmt.Printf(" Category: %s\n", plugin.Category)
|
||||||
fmt.Printf(" Author: %s\n", plugin.Author)
|
fmt.Printf(" Author: %s\n", plugin.Author)
|
||||||
@@ -510,6 +588,160 @@ func updatePluginCLI(idOrName string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func updateAllPluginsCLI() error {
|
||||||
|
manager, err := plugins.NewManager()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create manager: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
registry, err := plugins.NewRegistry()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create registry: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
installed, err := manager.ListInstalled()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to list installed plugins: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pluginList, _ := registry.List()
|
||||||
|
|
||||||
|
var errs []error
|
||||||
|
for _, pluginID := range installed {
|
||||||
|
plugin := plugins.FindByIDOrName(pluginID, pluginList)
|
||||||
|
if plugin != nil {
|
||||||
|
fmt.Printf("Updating plugin: %s (ID: %s)\n", plugin.Name, plugin.ID)
|
||||||
|
if err := manager.Update(*plugin); err != nil {
|
||||||
|
if strings.Contains(err.Error(), "cannot update system plugin") {
|
||||||
|
fmt.Printf("Skipping system plugin: %s\n", plugin.Name)
|
||||||
|
} else {
|
||||||
|
errs = append(errs, fmt.Errorf("failed to update %s: %w", plugin.Name, err))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Plugin updated successfully: %s\n", plugin.Name)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Updating plugin: %s\n", pluginID)
|
||||||
|
if err := manager.UpdateByIDOrName(pluginID); err != nil {
|
||||||
|
if strings.Contains(err.Error(), "cannot update system plugin") {
|
||||||
|
fmt.Printf("Skipping system plugin: %s\n", pluginID)
|
||||||
|
} else {
|
||||||
|
errs = append(errs, fmt.Errorf("failed to update %s: %w", pluginID, err))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Plugin updated successfully: %s\n", pluginID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(errs) > 0 {
|
||||||
|
for _, err := range errs {
|
||||||
|
fmt.Fprintf(os.Stderr, "%v\n", err)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("failed to update some plugins")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkPluginCLI(idOrName string) error {
|
||||||
|
manager, err := plugins.NewManager()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create manager: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
registry, err := plugins.NewRegistry()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create registry: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pluginList, _ := registry.List()
|
||||||
|
plugin := plugins.FindByIDOrName(idOrName, pluginList)
|
||||||
|
|
||||||
|
if plugin != nil {
|
||||||
|
installed, err := manager.IsInstalled(*plugin)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check install status: %w", err)
|
||||||
|
}
|
||||||
|
if !installed {
|
||||||
|
return fmt.Errorf("plugin not installed: %s", plugin.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
hasUpdates, _, err := manager.HasUpdates(plugin.ID, *plugin)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check updates: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasUpdates {
|
||||||
|
fmt.Printf("Update available for plugin: %s (ID: %s)\n", plugin.Name, plugin.ID)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Plugin is up to date: %s\n", plugin.Name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
dummyPlugin := plugins.Plugin{ID: idOrName}
|
||||||
|
hasUpdates, _, err := manager.HasUpdates(idOrName, dummyPlugin)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check updates: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasUpdates {
|
||||||
|
fmt.Printf("Update available for plugin: %s\n", idOrName)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Plugin is up to date: %s\n", idOrName)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkAllPluginsCLI() error {
|
||||||
|
manager, err := plugins.NewManager()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create manager: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
registry, err := plugins.NewRegistry()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create registry: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
installed, err := manager.ListInstalled()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to list installed plugins: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pluginList, _ := registry.List()
|
||||||
|
|
||||||
|
var count int
|
||||||
|
for _, pluginID := range installed {
|
||||||
|
plugin := plugins.FindByIDOrName(pluginID, pluginList)
|
||||||
|
var hasUpdates bool
|
||||||
|
var name string
|
||||||
|
|
||||||
|
if plugin != nil {
|
||||||
|
name = plugin.Name
|
||||||
|
hasUpdates, _, _ = manager.HasUpdates(pluginID, *plugin)
|
||||||
|
} else {
|
||||||
|
name = pluginID
|
||||||
|
dummyPlugin := plugins.Plugin{ID: pluginID}
|
||||||
|
hasUpdates, _, _ = manager.HasUpdates(pluginID, dummyPlugin)
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasUpdates {
|
||||||
|
fmt.Printf("Update available for plugin: %s (ID: %s)\n", name, pluginID)
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if count > 0 {
|
||||||
|
fmt.Printf("\nFound %d plugin(s) with available updates.\n", count)
|
||||||
|
} else {
|
||||||
|
fmt.Println("All plugins are up to date.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func getCommonCommands() []*cobra.Command {
|
func getCommonCommands() []*cobra.Command {
|
||||||
return []*cobra.Command{
|
return []*cobra.Command{
|
||||||
versionCmd,
|
versionCmd,
|
||||||
@@ -527,6 +759,7 @@ func getCommonCommands() []*cobra.Command {
|
|||||||
greeterCmd,
|
greeterCmd,
|
||||||
setupCmd,
|
setupCmd,
|
||||||
colorCmd,
|
colorCmd,
|
||||||
|
qrCmd,
|
||||||
screenshotCmd,
|
screenshotCmd,
|
||||||
notifyActionCmd,
|
notifyActionCmd,
|
||||||
notifyCmd,
|
notifyCmd,
|
||||||
|
|||||||
@@ -37,9 +37,11 @@ var resolveIncludeCmd = &cobra.Command{
|
|||||||
"cursor.lua",
|
"cursor.lua",
|
||||||
"windowrules.lua",
|
"windowrules.lua",
|
||||||
"cursor.kdl",
|
"cursor.kdl",
|
||||||
|
"layout.kdl",
|
||||||
"outputs.kdl",
|
"outputs.kdl",
|
||||||
"binds.kdl",
|
"binds.kdl",
|
||||||
"cursor.conf",
|
"cursor.conf",
|
||||||
|
"layout.conf",
|
||||||
"outputs.conf",
|
"outputs.conf",
|
||||||
"binds.conf",
|
"binds.conf",
|
||||||
}, cobra.ShellCompDirectiveNoFileComp
|
}, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
|||||||
@@ -320,6 +320,7 @@ func checkEnvironmentVars() []checkResult {
|
|||||||
var results []checkResult
|
var results []checkResult
|
||||||
results = append(results, checkEnvVar("QT_QPA_PLATFORMTHEME")...)
|
results = append(results, checkEnvVar("QT_QPA_PLATFORMTHEME")...)
|
||||||
results = append(results, checkEnvVar("QS_ICON_THEME")...)
|
results = append(results, checkEnvVar("QS_ICON_THEME")...)
|
||||||
|
results = append(results, checkXDGMenuPrefix()...)
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,6 +335,29 @@ func checkEnvVar(name string) []checkResult {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func checkXDGMenuPrefix() []checkResult {
|
||||||
|
menuPrefix := os.Getenv("XDG_MENU_PREFIX")
|
||||||
|
if menuPrefix != "" {
|
||||||
|
if checkXDGMenuFile(menuPrefix) {
|
||||||
|
return []checkResult{{catEnvironment, "XDG_MENU_PREFIX", statusInfo, menuPrefix, "", doctorDocsURL + "#xdg-menu-prefix"}}
|
||||||
|
}
|
||||||
|
return []checkResult{{catEnvironment, "XDG_MENU_PREFIX", statusWarn, fmt.Sprintf("%s (menu file not found)", menuPrefix), fmt.Sprintf("Dolphin 'Open with…' dialog may be empty. Ensure /etc/xdg/menus/%sapplications.menu exists.", menuPrefix), doctorDocsURL + "#xdg-menu-prefix"}}
|
||||||
|
}
|
||||||
|
if _, err := exec.LookPath("keditfiletype"); err == nil {
|
||||||
|
return []checkResult{{catEnvironment, "XDG_MENU_PREFIX", statusWarn, "Not set", "Dolphin file associations and 'Open with…' dialog may be empty. Set XDG_MENU_PREFIX=plasma- in your compositor's environment block.", doctorDocsURL + "#xdg-menu-prefix"}}
|
||||||
|
}
|
||||||
|
if doctorVerbose {
|
||||||
|
return []checkResult{{catEnvironment, "XDG_MENU_PREFIX", statusInfo, "Not set", "", doctorDocsURL + "#xdg-menu-prefix"}}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkXDGMenuFile(prefix string) bool {
|
||||||
|
menuPath := fmt.Sprintf("/etc/xdg/menus/%sapplications.menu", prefix)
|
||||||
|
_, err := os.Stat(menuPath)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
func readOSRelease() map[string]string {
|
func readOSRelease() map[string]string {
|
||||||
result := make(map[string]string)
|
result := make(map[string]string)
|
||||||
data, err := os.ReadFile("/etc/os-release")
|
data, err := os.ReadFile("/etc/os-release")
|
||||||
@@ -557,6 +581,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("MANGO_INSTANCE_SIGNATURE") != "":
|
||||||
|
return "MangoWC"
|
||||||
case os.Getenv("MIRACLESOCK") != "":
|
case os.Getenv("MIRACLESOCK") != "":
|
||||||
return "Miracle WM"
|
return "Miracle WM"
|
||||||
case os.Getenv("XDG_CURRENT_DESKTOP") != "":
|
case os.Getenv("XDG_CURRENT_DESKTOP") != "":
|
||||||
@@ -884,6 +910,7 @@ func checkOptionalDependencies() []checkResult {
|
|||||||
{"cava", "cava", "Audio visualizer", true},
|
{"cava", "cava", "Audio visualizer", true},
|
||||||
{"khal", "khal", "Calendar events", false},
|
{"khal", "khal", "Calendar events", false},
|
||||||
{"danksearch", "dsearch", "File search", false},
|
{"danksearch", "dsearch", "File search", false},
|
||||||
|
{"dankcalendar", "dcal", "Calendar app", false},
|
||||||
{"fprintd", "fprintd-list", "Fingerprint auth", false},
|
{"fprintd", "fprintd-list", "Fingerprint auth", false},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,13 +32,14 @@ var greeterCmd = &cobra.Command{
|
|||||||
var (
|
var (
|
||||||
greeterConfigSyncFn = greeter.SyncDMSConfigs
|
greeterConfigSyncFn = greeter.SyncDMSConfigs
|
||||||
sharedAuthSyncFn = sharedpam.SyncAuthConfig
|
sharedAuthSyncFn = sharedpam.SyncAuthConfig
|
||||||
|
greeterIsNixOSFn = greeter.IsNixOS
|
||||||
)
|
)
|
||||||
|
|
||||||
var greeterInstallCmd = &cobra.Command{
|
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: preRunPrivileged,
|
PreRunE: preRunGreeterMutation,
|
||||||
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")
|
||||||
@@ -64,6 +65,9 @@ var greeterSyncCmd = &cobra.Command{
|
|||||||
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. Also updates a per-user cache slot at users/<username>/ for multi-account greeter theme preview.\n\nUse --profile on secondary accounts to sync only your own users/<username>/ slot without sudo or greetd changes.",
|
Long: "Synchronize your current user's DMS theme, settings, and wallpaper configuration with the login greeter screen. Also updates a per-user cache slot at users/<username>/ for multi-account greeter theme preview.\n\nUse --profile on secondary accounts to sync only your own users/<username>/ slot without sudo or greetd changes.",
|
||||||
PreRunE: func(cmd *cobra.Command, args []string) error {
|
PreRunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if err := rejectNixOSGreeterMutation(cmd); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
profile, _ := cmd.Flags().GetBool("profile")
|
profile, _ := cmd.Flags().GetBool("profile")
|
||||||
if profile {
|
if profile {
|
||||||
return nil
|
return nil
|
||||||
@@ -95,6 +99,35 @@ var greeterSyncCmd = &cobra.Command{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var greeterLaunchSessionCmd = &cobra.Command{
|
||||||
|
Use: "launch-session",
|
||||||
|
Short: "Launch a remembered greeter session",
|
||||||
|
Hidden: true,
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
sessionID, _ := cmd.Flags().GetString("session-id")
|
||||||
|
fromMemory, _ := cmd.Flags().GetBool("from-memory")
|
||||||
|
cacheDir, _ := cmd.Flags().GetString("cache-dir")
|
||||||
|
|
||||||
|
if fromMemory {
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to get user home directory: %v", err)
|
||||||
|
}
|
||||||
|
if err := greeter.LaunchSessionFromMemory(cacheDir, homeDir); err != nil {
|
||||||
|
log.Fatalf("failed to launch remembered greeter session: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if sessionID == "" {
|
||||||
|
log.Fatal("missing --session-id or --from-memory")
|
||||||
|
}
|
||||||
|
if err := greeter.LaunchSessionByID(sessionID); err != nil {
|
||||||
|
log.Fatalf("failed to launch greeter session %q: %v", sessionID, err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
greeterSyncCmd.Flags().BoolP("yes", "y", false, "Non-interactive mode: skip prompts, use defaults (for UI)")
|
greeterSyncCmd.Flags().BoolP("yes", "y", false, "Non-interactive mode: skip prompts, use defaults (for UI)")
|
||||||
greeterSyncCmd.Flags().BoolP("terminal", "t", false, "Run sync in a new terminal (for entering sudo password); terminal auto-closes when done")
|
greeterSyncCmd.Flags().BoolP("terminal", "t", false, "Run sync in a new terminal (for entering sudo password); terminal auto-closes when done")
|
||||||
@@ -102,13 +135,16 @@ func init() {
|
|||||||
greeterSyncCmd.Flags().BoolP("local", "l", false, "Developer mode: force greetd config to use a local DMS checkout path")
|
greeterSyncCmd.Flags().BoolP("local", "l", false, "Developer mode: force greetd config to use a local DMS checkout path")
|
||||||
greeterSyncCmd.Flags().BoolP("profile", "p", false, "Sync only your per-user greeter slot (no sudo; for secondary accounts)")
|
greeterSyncCmd.Flags().BoolP("profile", "p", false, "Sync only your per-user greeter slot (no sudo; for secondary accounts)")
|
||||||
greeterSyncCmd.Flags().Bool("autologin", false, "Apply only greeter auto-login on startup settings to greetd (no theme or auth sync)")
|
greeterSyncCmd.Flags().Bool("autologin", false, "Apply only greeter auto-login on startup settings to greetd (no theme or auth sync)")
|
||||||
|
greeterLaunchSessionCmd.Flags().String("session-id", "", "Desktop session id to launch")
|
||||||
|
greeterLaunchSessionCmd.Flags().Bool("from-memory", false, "Resolve the session id from greeter memory")
|
||||||
|
greeterLaunchSessionCmd.Flags().String("cache-dir", greeter.GreeterCacheDir, "Greeter cache directory")
|
||||||
}
|
}
|
||||||
|
|
||||||
var greeterEnableCmd = &cobra.Command{
|
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: preRunPrivileged,
|
PreRunE: preRunGreeterMutation,
|
||||||
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")
|
||||||
@@ -144,7 +180,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: preRunPrivileged,
|
PreRunE: preRunGreeterMutation,
|
||||||
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")
|
||||||
@@ -174,6 +210,21 @@ func init() {
|
|||||||
greeterUninstallCmd.Flags().BoolP("terminal", "t", false, "Run in a new terminal (for entering sudo password)")
|
greeterUninstallCmd.Flags().BoolP("terminal", "t", false, "Run in a new terminal (for entering sudo password)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func rejectNixOSGreeterMutation(cmd *cobra.Command) error {
|
||||||
|
if !greeterIsNixOSFn() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("dms %s is disabled on NixOS because the greeter is managed declaratively\nConfigure the DMS greeter in your NixOS module, then apply the change with your normal nixos-rebuild workflow", normalizeCommandSpec(cmd.CommandPath()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func preRunGreeterMutation(cmd *cobra.Command, args []string) error {
|
||||||
|
if err := rejectNixOSGreeterMutation(cmd); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return preRunPrivileged(cmd, args)
|
||||||
|
}
|
||||||
|
|
||||||
func syncGreeterConfigsAndAuth(dmsPath, compositor string, logFunc func(string), options sharedpam.SyncAuthOptions, beforeAuth func()) error {
|
func syncGreeterConfigsAndAuth(dmsPath, compositor string, logFunc func(string), options sharedpam.SyncAuthOptions, beforeAuth func()) error {
|
||||||
if err := greeterConfigSyncFn(dmsPath, compositor, logFunc, ""); err != nil {
|
if err := greeterConfigSyncFn(dmsPath, compositor, logFunc, ""); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -299,7 +350,7 @@ func installGreeter(nonInteractive bool) error {
|
|||||||
|
|
||||||
fmt.Println("\n=== Installation Complete ===")
|
fmt.Println("\n=== Installation Complete ===")
|
||||||
fmt.Println("\nTo start the greeter now, run:")
|
fmt.Println("\nTo start the greeter now, run:")
|
||||||
fmt.Println(" sudo systemctl start greetd")
|
fmt.Println(startGreeterHint())
|
||||||
fmt.Println("\nOr reboot to see the greeter at next boot.")
|
fmt.Println("\nOr reboot to see the greeter at next boot.")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -326,7 +377,13 @@ func uninstallGreeter(nonInteractive bool) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("\nDisabling greetd...")
|
fmt.Println("\nDisabling greetd...")
|
||||||
if err := privesc.Run(context.Background(), "", "systemctl", "disable", "greetd"); err != nil {
|
if isRunit() {
|
||||||
|
if err := disableRunitService("greetd"); err != nil {
|
||||||
|
fmt.Printf(" ⚠ Could not disable greetd: %v\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Println(" ✓ greetd disabled")
|
||||||
|
}
|
||||||
|
} else if err := privesc.Run(context.Background(), "", "systemctl", "disable", "greetd"); 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")
|
||||||
@@ -449,6 +506,14 @@ func suggestDisplayManagerRestore(nonInteractive bool) {
|
|||||||
|
|
||||||
enableDM := func(dm string) {
|
enableDM := func(dm string) {
|
||||||
fmt.Printf(" Enabling %s...\n", dm)
|
fmt.Printf(" Enabling %s...\n", dm)
|
||||||
|
if isRunit() {
|
||||||
|
if err := enableRunitService(dm); err != nil {
|
||||||
|
fmt.Printf(" ⚠ Failed to enable %s: %v\n", dm, err)
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" ✓ %s enabled (linked into %s).\n", dm, runitServiceDir)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := privesc.Run(context.Background(), "", "systemctl", "enable", "--force", dm); err != nil {
|
if err := privesc.Run(context.Background(), "", "systemctl", "enable", "--force", dm); err != nil {
|
||||||
fmt.Printf(" ⚠ Failed to enable %s: %v\n", dm, err)
|
fmt.Printf(" ⚠ Failed to enable %s: %v\n", dm, err)
|
||||||
} else {
|
} else {
|
||||||
@@ -495,6 +560,9 @@ func suggestDisplayManagerRestore(nonInteractive bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func isSystemdUnitInstalled(unit string) bool {
|
func isSystemdUnitInstalled(unit string) bool {
|
||||||
|
if isRunit() {
|
||||||
|
return runitServiceInstalled(unit)
|
||||||
|
}
|
||||||
cmd := exec.Command("systemctl", "list-unit-files", unit+".service", "--no-legend", "--no-pager")
|
cmd := exec.Command("systemctl", "list-unit-files", unit+".service", "--no-legend", "--no-pager")
|
||||||
out, err := cmd.Output()
|
out, err := cmd.Output()
|
||||||
return err == nil && strings.Contains(string(out), unit)
|
return err == nil && strings.Contains(string(out), unit)
|
||||||
@@ -943,6 +1011,18 @@ func resolveLocalDMSPath() (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func disableDisplayManager(dmName string) (bool, error) {
|
func disableDisplayManager(dmName string) (bool, error) {
|
||||||
|
if isRunit() {
|
||||||
|
if !runitServiceEnabled(dmName) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
fmt.Printf("\nDisabling %s (runit)...\n", dmName)
|
||||||
|
if err := disableRunitService(dmName); err != nil {
|
||||||
|
return false, fmt.Errorf("failed to disable %s: %w", dmName, err)
|
||||||
|
}
|
||||||
|
fmt.Printf(" ✓ %s disabled (removed from %s)\n", dmName, runitServiceDir)
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
state, err := getSystemdServiceState(dmName)
|
state, err := getSystemdServiceState(dmName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("failed to check %s state: %w", dmName, err)
|
return false, fmt.Errorf("failed to check %s state: %w", dmName, err)
|
||||||
@@ -996,6 +1076,24 @@ func disableDisplayManager(dmName string) (bool, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ensureGreetdEnabled() error {
|
func ensureGreetdEnabled() error {
|
||||||
|
if isRunit() {
|
||||||
|
fmt.Println("\nEnabling greetd service (runit)...")
|
||||||
|
if !runitServiceInstalled("greetd") {
|
||||||
|
return fmt.Errorf("greetd service not found in %s. Please install greetd first", runitSvDir)
|
||||||
|
}
|
||||||
|
if greeter.IsVoidLinux() {
|
||||||
|
ensureVoidLogindGreeter("_greeter")
|
||||||
|
} else {
|
||||||
|
ensureRunitSeat("_greeter")
|
||||||
|
}
|
||||||
|
ensureGreetdPamRundir()
|
||||||
|
if err := enableRunitService("greetd"); err != nil {
|
||||||
|
return fmt.Errorf("failed to enable greetd: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Printf(" ✓ greetd enabled (%s)\n", runitServiceDir)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Println("\nChecking greetd service status...")
|
fmt.Println("\nChecking greetd service status...")
|
||||||
|
|
||||||
state, err := getSystemdServiceState("greetd")
|
state, err := getSystemdServiceState("greetd")
|
||||||
@@ -1043,6 +1141,12 @@ func ensureGreetdEnabled() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ensureGraphicalTarget() error {
|
func ensureGraphicalTarget() error {
|
||||||
|
if isRunit() {
|
||||||
|
// runit has no targets; a supervised greetd service is the graphical
|
||||||
|
// login, so there is nothing to set here.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
getDefaultCmd := exec.Command("systemctl", "get-default")
|
getDefaultCmd := exec.Command("systemctl", "get-default")
|
||||||
currentTarget, err := getDefaultCmd.Output()
|
currentTarget, err := getDefaultCmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1160,6 +1264,9 @@ func enableGreeter(nonInteractive bool) error {
|
|||||||
if err := greeter.EnsureGreeterCacheDir(logFunc, ""); err != nil {
|
if err := greeter.EnsureGreeterCacheDir(logFunc, ""); err != nil {
|
||||||
fmt.Printf("⚠ Could not ensure cache directory: %v\n Run: sudo mkdir -p %s && sudo chown root:%s %s && sudo chmod 2770 %s\n", err, greeter.GreeterCacheDir, greeterGroup, greeter.GreeterCacheDir, greeter.GreeterCacheDir)
|
fmt.Printf("⚠ Could not ensure cache directory: %v\n Run: sudo mkdir -p %s && sudo chown root:%s %s && sudo chmod 2770 %s\n", err, greeter.GreeterCacheDir, greeterGroup, greeter.GreeterCacheDir, greeter.GreeterCacheDir)
|
||||||
}
|
}
|
||||||
|
if err := greeter.EnsureVoidLogindGreetdCommand(logFunc, ""); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if err := ensureGraphicalTarget(); err != nil {
|
if err := ensureGraphicalTarget(); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -1176,7 +1283,7 @@ func enableGreeter(nonInteractive bool) error {
|
|||||||
fmt.Println("\n=== Enable Complete ===")
|
fmt.Println("\n=== Enable Complete ===")
|
||||||
fmt.Println("\nGreeter configuration verified and system state corrected.")
|
fmt.Println("\nGreeter configuration verified and system state corrected.")
|
||||||
fmt.Println("To start the greeter now, run:")
|
fmt.Println("To start the greeter now, run:")
|
||||||
fmt.Println(" sudo systemctl start greetd")
|
fmt.Println(startGreeterHint())
|
||||||
fmt.Println("\nOr reboot to see the greeter at boot time.")
|
fmt.Println("\nOr reboot to see the greeter at boot time.")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -1257,7 +1364,7 @@ func enableGreeter(nonInteractive bool) error {
|
|||||||
|
|
||||||
fmt.Println("\n=== Enable Complete ===")
|
fmt.Println("\n=== Enable Complete ===")
|
||||||
fmt.Println("\nTo start the greeter now, run:")
|
fmt.Println("\nTo start the greeter now, run:")
|
||||||
fmt.Println(" sudo systemctl start greetd")
|
fmt.Println(startGreeterHint())
|
||||||
fmt.Println("\nOr reboot to see the greeter at boot time.")
|
fmt.Println("\nOr reboot to see the greeter at boot time.")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -1333,24 +1440,36 @@ func readDefaultSessionCommand(configPath string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractGreeterCacheDirFromCommand(command string) string {
|
func explicitGreeterCacheDirFromCommand(command string) (string, bool) {
|
||||||
if command == "" {
|
|
||||||
return greeter.GreeterCacheDir
|
|
||||||
}
|
|
||||||
tokens := strings.Fields(command)
|
tokens := strings.Fields(command)
|
||||||
for i := 0; i < len(tokens); i++ {
|
for i := 0; i < len(tokens); i++ {
|
||||||
token := strings.Trim(tokens[i], "\"")
|
token := strings.Trim(tokens[i], "\"")
|
||||||
if token == "--cache-dir" && i+1 < len(tokens) {
|
if token == "--cache-dir" && i+1 < len(tokens) {
|
||||||
return strings.Trim(tokens[i+1], "\"")
|
value := strings.Trim(tokens[i+1], "\"")
|
||||||
|
if value != "" {
|
||||||
|
return value, true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(token, "--cache-dir=") {
|
if strings.HasPrefix(token, "--cache-dir=") {
|
||||||
value := strings.TrimPrefix(token, "--cache-dir=")
|
value := strings.TrimPrefix(token, "--cache-dir=")
|
||||||
value = strings.Trim(value, "\"")
|
value = strings.Trim(value, "\"")
|
||||||
if value != "" {
|
if value != "" {
|
||||||
return value
|
return value, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
const nixOSGreeterStateDir = "/var/lib/dms-greeter"
|
||||||
|
|
||||||
|
func greeterStatusStateDir(command string, isNixOS bool) string {
|
||||||
|
if cacheDir, ok := explicitGreeterCacheDirFromCommand(command); ok {
|
||||||
|
return cacheDir
|
||||||
|
}
|
||||||
|
if isNixOS {
|
||||||
|
return nixOSGreeterStateDir
|
||||||
|
}
|
||||||
return greeter.GreeterCacheDir
|
return greeter.GreeterCacheDir
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1362,12 +1481,23 @@ func extractGreeterWrapperFromCommand(command string) string {
|
|||||||
if len(tokens) == 0 {
|
if len(tokens) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
wrapper := strings.Trim(tokens[0], "\"")
|
wrapperIndex := 0
|
||||||
|
if filepath.Base(strings.Trim(tokens[0], "\"")) == "env" {
|
||||||
|
wrapperIndex = 1
|
||||||
|
for wrapperIndex < len(tokens) && strings.Contains(tokens[wrapperIndex], "=") {
|
||||||
|
wrapperIndex++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if wrapperIndex >= len(tokens) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
wrapper := strings.Trim(tokens[wrapperIndex], "\"")
|
||||||
if wrapper == "" {
|
if wrapper == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
if len(tokens) > 1 {
|
if wrapperIndex+1 < len(tokens) {
|
||||||
next := strings.Trim(tokens[1], "\"")
|
next := strings.Trim(tokens[wrapperIndex+1], "\"")
|
||||||
if next != "" && (filepath.Base(wrapper) == "bash" || filepath.Base(wrapper) == "sh") && strings.Contains(filepath.Base(next), "dms-greeter") {
|
if next != "" && (filepath.Base(wrapper) == "bash" || filepath.Base(wrapper) == "sh") && strings.Contains(filepath.Base(next), "dms-greeter") {
|
||||||
return fmt.Sprintf("%s (script: %s)", wrapper, next)
|
return fmt.Sprintf("%s (script: %s)", wrapper, next)
|
||||||
}
|
}
|
||||||
@@ -1421,6 +1551,8 @@ func packageInstallHint() string {
|
|||||||
return "Install with 'sudo dnf install dms-greeter' (requires COPR: sudo dnf copr enable avengemedia/danklinux)"
|
return "Install with 'sudo dnf install dms-greeter' (requires COPR: sudo dnf copr enable avengemedia/danklinux)"
|
||||||
case distros.FamilyArch:
|
case distros.FamilyArch:
|
||||||
return "Install from AUR with 'paru -S greetd-dms-greeter-git' or 'yay -S greetd-dms-greeter-git'"
|
return "Install from AUR with 'paru -S greetd-dms-greeter-git' or 'yay -S greetd-dms-greeter-git'"
|
||||||
|
case distros.FamilyVoid:
|
||||||
|
return "Install with 'sudo xbps-install -S dms-greeter' (requires DMS XBPS repo: echo 'repository=https://void.danklinux.com/dms/current' | sudo tee /etc/xbps.d/dms.conf)"
|
||||||
default:
|
default:
|
||||||
return "Run 'dms greeter install' to install greeter"
|
return "Run 'dms greeter install' to install greeter"
|
||||||
}
|
}
|
||||||
@@ -1459,7 +1591,8 @@ func isPackageOnlyGreeterDistro() bool {
|
|||||||
config.Family == distros.FamilySUSE ||
|
config.Family == distros.FamilySUSE ||
|
||||||
config.Family == distros.FamilyUbuntu ||
|
config.Family == distros.FamilyUbuntu ||
|
||||||
config.Family == distros.FamilyFedora ||
|
config.Family == distros.FamilyFedora ||
|
||||||
config.Family == distros.FamilyArch
|
config.Family == distros.FamilyArch ||
|
||||||
|
config.Family == distros.FamilyVoid
|
||||||
}
|
}
|
||||||
|
|
||||||
func promptCompositorChoice(compositors []string) (string, error) {
|
func promptCompositorChoice(compositors []string) (string, error) {
|
||||||
@@ -1487,6 +1620,10 @@ func checkGreeterStatus() error {
|
|||||||
fmt.Println("=== DMS Greeter Status ===")
|
fmt.Println("=== DMS Greeter Status ===")
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
||||||
|
if greeterIsNixOSFn() {
|
||||||
|
return checkNixOSGreeterStatus()
|
||||||
|
}
|
||||||
|
|
||||||
homeDir, err := os.UserHomeDir()
|
homeDir, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get user home directory: %w", err)
|
return fmt.Errorf("failed to get user home directory: %w", err)
|
||||||
@@ -1550,7 +1687,7 @@ func checkGreeterStatus() error {
|
|||||||
fmt.Println(" Run 'dms greeter sync' to set up group membership and permissions")
|
fmt.Println(" Run 'dms greeter sync' to set up group membership and permissions")
|
||||||
}
|
}
|
||||||
|
|
||||||
cacheDir := extractGreeterCacheDirFromCommand(configuredCommand)
|
cacheDir := greeterStatusStateDir(configuredCommand, false)
|
||||||
fmt.Println("\nGreeter Cache Directory:")
|
fmt.Println("\nGreeter Cache Directory:")
|
||||||
fmt.Printf(" Effective cache dir: %s\n", cacheDir)
|
fmt.Printf(" Effective cache dir: %s\n", cacheDir)
|
||||||
if cacheDir != greeter.GreeterCacheDir {
|
if cacheDir != greeter.GreeterCacheDir {
|
||||||
@@ -1813,6 +1950,85 @@ func checkGreeterStatus() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func checkNixOSGreeterStatus() error {
|
||||||
|
const configPath = "/etc/greetd/config.toml"
|
||||||
|
|
||||||
|
configuredCommand := readDefaultSessionCommand(configPath)
|
||||||
|
allGood := true
|
||||||
|
|
||||||
|
fmt.Println("Greeter Configuration:")
|
||||||
|
switch {
|
||||||
|
case strings.Contains(configuredCommand, "dms-greeter"):
|
||||||
|
fmt.Println(" ✓ DMS greeter command found")
|
||||||
|
if wrapper := extractGreeterWrapperFromCommand(configuredCommand); wrapper != "" {
|
||||||
|
fmt.Printf(" Wrapper: %s\n", wrapper)
|
||||||
|
}
|
||||||
|
case configuredCommand != "":
|
||||||
|
fmt.Println(" ⚠ greetd default session does not reference dms-greeter")
|
||||||
|
allGood = false
|
||||||
|
default:
|
||||||
|
fmt.Printf(" ℹ No readable DMS command found in %s\n", configPath)
|
||||||
|
}
|
||||||
|
fmt.Println(" ℹ NixOS manages greeter configuration declaratively; apply changes through your NixOS module.")
|
||||||
|
|
||||||
|
stateDir := greeterStatusStateDir(configuredCommand, true)
|
||||||
|
fmt.Println("\nGreeter State Directory:")
|
||||||
|
fmt.Printf(" Effective state dir: %s\n", stateDir)
|
||||||
|
if stateDir == nixOSGreeterStateDir {
|
||||||
|
fmt.Println(" ✓ Using the NixOS module state path")
|
||||||
|
}
|
||||||
|
if stat, err := os.Stat(stateDir); err == nil && stat.IsDir() {
|
||||||
|
fmt.Printf(" ✓ %s exists\n", stateDir)
|
||||||
|
} else if os.IsNotExist(err) {
|
||||||
|
fmt.Printf(" ✗ %s not found\n", stateDir)
|
||||||
|
fmt.Println(" Rebuild your NixOS configuration after enabling the DMS greeter module.")
|
||||||
|
allGood = false
|
||||||
|
} else if err != nil {
|
||||||
|
fmt.Printf(" ✗ Could not inspect %s: %v\n", stateDir, err)
|
||||||
|
allGood = false
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" ✗ %s is not a directory\n", stateDir)
|
||||||
|
allGood = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("\nDeclarative Configuration Files:")
|
||||||
|
configFiles := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
}{
|
||||||
|
{name: "Settings", path: filepath.Join(stateDir, "settings.json")},
|
||||||
|
{name: "Session state", path: filepath.Join(stateDir, "session.json")},
|
||||||
|
{name: "Color theme", path: filepath.Join(stateDir, "colors.json")},
|
||||||
|
}
|
||||||
|
for _, configFile := range configFiles {
|
||||||
|
if stat, err := os.Stat(configFile.path); err == nil && !stat.IsDir() {
|
||||||
|
fmt.Printf(" ✓ %s: %s\n", configFile.name, configFile.path)
|
||||||
|
} else if os.IsNotExist(err) {
|
||||||
|
fmt.Printf(" ℹ %s not present (optional; configure configHome/configFiles in the NixOS module)\n", configFile.name)
|
||||||
|
} else if err != nil {
|
||||||
|
fmt.Printf(" ⚠ %s could not be inspected: %v\n", configFile.name, err)
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" ⚠ %s path is not a regular file: %s\n", configFile.name, configFile.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("\nGroup Membership:")
|
||||||
|
fmt.Println(" ℹ User group membership is managed by NixOS and is not required for declarative theme copies.")
|
||||||
|
|
||||||
|
fmt.Println("\nGreeter PAM Authentication:")
|
||||||
|
fmt.Println(" ℹ PAM is managed by NixOS modules.")
|
||||||
|
fmt.Println(" Configure fingerprint/U2F through security.pam.services.greetd.")
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
if allGood {
|
||||||
|
fmt.Println("✓ NixOS greeter state looks healthy and is managed declaratively.")
|
||||||
|
} else {
|
||||||
|
fmt.Println("⚠ Some issues detected. Update the DMS greeter module and rebuild NixOS; do not run 'dms greeter sync'.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func recentAppArmorGreeterDenials(sampleLimit int) (int, []string, error) {
|
func recentAppArmorGreeterDenials(sampleLimit int) (int, []string, error) {
|
||||||
if sampleLimit <= 0 {
|
if sampleLimit <= 0 {
|
||||||
sampleLimit = 3
|
sampleLimit = 3
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ package main
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam"
|
sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSyncGreeterConfigsAndAuthDelegatesSharedAuth(t *testing.T) {
|
func TestSyncGreeterConfigsAndAuthDelegatesSharedAuth(t *testing.T) {
|
||||||
@@ -85,3 +87,64 @@ func TestSyncGreeterConfigsAndAuthStopsOnConfigError(t *testing.T) {
|
|||||||
t.Fatal("expected auth sync not to run after config sync failure")
|
t.Fatal("expected auth sync not to run after config sync failure")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGreeterStatusStateDirUsesNixOSDefault(t *testing.T) {
|
||||||
|
if got := greeterStatusStateDir("", true); got != nixOSGreeterStateDir {
|
||||||
|
t.Fatalf("greeterStatusStateDir() = %q, want %q", got, nixOSGreeterStateDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGreeterStatusStateDirHonorsExplicitOverrideOnNixOS(t *testing.T) {
|
||||||
|
command := "dms-greeter --cache-dir /srv/dms-greeter --command niri"
|
||||||
|
if got := greeterStatusStateDir(command, true); got != "/srv/dms-greeter" {
|
||||||
|
t.Fatalf("greeterStatusStateDir() = %q, want %q", got, "/srv/dms-greeter")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractGreeterWrapperFromEnvCommand(t *testing.T) {
|
||||||
|
command := "env LIBSEAT_BACKEND=logind DMS_VOID=1 /usr/bin/dms-greeter --command niri"
|
||||||
|
if got := extractGreeterWrapperFromCommand(command); got != "/usr/bin/dms-greeter" {
|
||||||
|
t.Fatalf("extractGreeterWrapperFromCommand() = %q, want %q", got, "/usr/bin/dms-greeter")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRejectNixOSGreeterMutationBlocksImperativeCommands(t *testing.T) {
|
||||||
|
origGreeterIsNixOSFn := greeterIsNixOSFn
|
||||||
|
greeterIsNixOSFn = func() bool { return true }
|
||||||
|
t.Cleanup(func() {
|
||||||
|
greeterIsNixOSFn = origGreeterIsNixOSFn
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, commandName := range []string{"install", "enable", "sync", "uninstall"} {
|
||||||
|
t.Run(commandName, func(t *testing.T) {
|
||||||
|
root := &cobra.Command{Use: "dms"}
|
||||||
|
greeterCommand := &cobra.Command{Use: "greeter"}
|
||||||
|
mutationCommand := &cobra.Command{Use: commandName}
|
||||||
|
root.AddCommand(greeterCommand)
|
||||||
|
greeterCommand.AddCommand(mutationCommand)
|
||||||
|
|
||||||
|
err := rejectNixOSGreeterMutation(mutationCommand)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected NixOS greeter %s to be rejected", commandName)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "dms greeter "+commandName+" is disabled on NixOS") {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "/var/cache/dms-greeter") {
|
||||||
|
t.Fatalf("NixOS remediation should not recommend the non-NixOS cache path: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRejectNixOSGreeterMutationAllowsOtherDistros(t *testing.T) {
|
||||||
|
origGreeterIsNixOSFn := greeterIsNixOSFn
|
||||||
|
greeterIsNixOSFn = func() bool { return false }
|
||||||
|
t.Cleanup(func() {
|
||||||
|
greeterIsNixOSFn = origGreeterIsNixOSFn
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := rejectNixOSGreeterMutation(&cobra.Command{Use: "sync"}); err != nil {
|
||||||
|
t.Fatalf("expected non-NixOS greeter command to be allowed, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,10 +38,17 @@ var matugenCheckCmd = &cobra.Command{
|
|||||||
Run: runMatugenCheck,
|
Run: runMatugenCheck,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var matugenPreviewCmd = &cobra.Command{
|
||||||
|
Use: "preview",
|
||||||
|
Short: "Preview Matugen scheme colors without applying them",
|
||||||
|
Run: runMatugenPreview,
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
matugenCmd.AddCommand(matugenGenerateCmd)
|
matugenCmd.AddCommand(matugenGenerateCmd)
|
||||||
matugenCmd.AddCommand(matugenQueueCmd)
|
matugenCmd.AddCommand(matugenQueueCmd)
|
||||||
matugenCmd.AddCommand(matugenCheckCmd)
|
matugenCmd.AddCommand(matugenCheckCmd)
|
||||||
|
matugenCmd.AddCommand(matugenPreviewCmd)
|
||||||
|
|
||||||
for _, cmd := range []*cobra.Command{matugenGenerateCmd, matugenQueueCmd} {
|
for _, cmd := range []*cobra.Command{matugenGenerateCmd, matugenQueueCmd} {
|
||||||
cmd.Flags().String("state-dir", "", "State directory for cache files")
|
cmd.Flags().String("state-dir", "", "State directory for cache files")
|
||||||
@@ -62,6 +69,8 @@ func init() {
|
|||||||
|
|
||||||
matugenQueueCmd.Flags().Bool("wait", true, "Wait for completion")
|
matugenQueueCmd.Flags().Bool("wait", true, "Wait for completion")
|
||||||
matugenQueueCmd.Flags().Duration("timeout", 90*time.Second, "Timeout for waiting")
|
matugenQueueCmd.Flags().Duration("timeout", 90*time.Second, "Timeout for waiting")
|
||||||
|
matugenPreviewCmd.Flags().String("source-color", "", "Source color used to generate previews")
|
||||||
|
matugenPreviewCmd.Flags().Float64("contrast", 0, "Contrast value from -1 to 1 (0 = standard)")
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildMatugenOptions(cmd *cobra.Command) matugen.Options {
|
func buildMatugenOptions(cmd *cobra.Command) matugen.Options {
|
||||||
@@ -200,3 +209,17 @@ func runMatugenCheck(cmd *cobra.Command, args []string) {
|
|||||||
}
|
}
|
||||||
fmt.Println(string(data))
|
fmt.Println(string(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runMatugenPreview(cmd *cobra.Command, args []string) {
|
||||||
|
sourceColor, _ := cmd.Flags().GetString("source-color")
|
||||||
|
contrast, _ := cmd.Flags().GetFloat64("contrast")
|
||||||
|
previews, err := matugen.PreviewSchemes(sourceColor, contrast)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to generate Matugen previews: %v", err)
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(previews)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to marshal Matugen previews: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Println(string(data))
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/qrcode"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||||
|
"github.com/mattn/go-isatty"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
qrEcc string
|
||||||
|
qrVersion int
|
||||||
|
qrOutput string
|
||||||
|
qrStdout bool
|
||||||
|
qrClipboard bool
|
||||||
|
qrCopyText bool
|
||||||
|
qrRender bool
|
||||||
|
qrNoRender bool
|
||||||
|
qrInvert bool
|
||||||
|
qrQuietZone int
|
||||||
|
qrModuleSize int
|
||||||
|
qrFg string
|
||||||
|
qrBg string
|
||||||
|
qrTransparent bool
|
||||||
|
qrBorder int
|
||||||
|
qrShape string
|
||||||
|
qrLogo string
|
||||||
|
qrLogoScale int
|
||||||
|
|
||||||
|
qrWifiPassword string
|
||||||
|
qrWifiSecurity string
|
||||||
|
qrWifiHidden bool
|
||||||
|
)
|
||||||
|
|
||||||
|
var qrCmd = &cobra.Command{
|
||||||
|
Use: "qr [text]",
|
||||||
|
Short: "Generate QR codes",
|
||||||
|
Long: `Generate a QR code from text, stdin, or a WiFi network.
|
||||||
|
|
||||||
|
By default the code is rendered to the terminal when stdout is a TTY, or
|
||||||
|
written as PNG bytes to stdout when piped. Use flags to also copy to the
|
||||||
|
clipboard, save a PNG, or tune encoding and colors.
|
||||||
|
|
||||||
|
Input:
|
||||||
|
dms qr "https://example.com" # encode an argument
|
||||||
|
echo -n "data" | dms qr # encode stdin
|
||||||
|
dms qr - # encode stdin explicitly
|
||||||
|
|
||||||
|
Output (combine freely):
|
||||||
|
dms qr "text" --clipboard # copy PNG image to clipboard
|
||||||
|
dms qr "text" --copy-text # copy the source text to clipboard
|
||||||
|
dms qr "text" -o code.png # save a PNG file
|
||||||
|
dms qr "text" > code.png # PNG to stdout (piped)
|
||||||
|
dms qr "text" --render # force terminal render
|
||||||
|
|
||||||
|
Encoding & style:
|
||||||
|
--ecc L|M|Q|H # error correction (default M)
|
||||||
|
--qr-version 10 # force symbol version (1-40)
|
||||||
|
--module-size 12 --fg '#000' ... # PNG sizing and colors
|
||||||
|
--shape circle # round modules
|
||||||
|
--logo icon.png # center logo (bumps --ecc to H)
|
||||||
|
--invert # flip colors for light terminals
|
||||||
|
|
||||||
|
WiFi:
|
||||||
|
dms qr wifi MySSID -p secret # build from an explicit password
|
||||||
|
dms qr wifi MySSID # pull the saved secret from the shell`,
|
||||||
|
Args: cobra.ArbitraryArgs,
|
||||||
|
Run: runQR,
|
||||||
|
}
|
||||||
|
|
||||||
|
var qrWifiCmd = &cobra.Command{
|
||||||
|
Use: "wifi <ssid>",
|
||||||
|
Short: "Generate a WiFi QR code",
|
||||||
|
Long: `Generate a QR code that joins a WiFi network when scanned.
|
||||||
|
|
||||||
|
With --password the code is built entirely offline. Without it, the saved
|
||||||
|
credentials are fetched from the running DMS shell (like the network panel).`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
Run: runQRWifi,
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
qrCmd.PersistentFlags().StringVar(&qrEcc, "ecc", "", "Error correction level: L, M, Q, H (default M, or H with --logo)")
|
||||||
|
qrCmd.PersistentFlags().IntVar(&qrVersion, "qr-version", 0, "Force QR symbol version 1-40 (0 = auto)")
|
||||||
|
qrCmd.PersistentFlags().StringVarP(&qrOutput, "output", "o", "", "Write a PNG to this file")
|
||||||
|
qrCmd.PersistentFlags().BoolVar(&qrStdout, "stdout", false, "Write PNG bytes to stdout")
|
||||||
|
qrCmd.PersistentFlags().BoolVar(&qrClipboard, "clipboard", false, "Copy the PNG image to the clipboard")
|
||||||
|
qrCmd.PersistentFlags().BoolVar(&qrCopyText, "copy-text", false, "Copy the source text to the clipboard")
|
||||||
|
qrCmd.PersistentFlags().BoolVar(&qrRender, "render", false, "Force terminal rendering")
|
||||||
|
qrCmd.PersistentFlags().BoolVar(&qrNoRender, "no-render", false, "Never render to the terminal")
|
||||||
|
qrCmd.PersistentFlags().BoolVar(&qrInvert, "invert", false, "Swap colors (for light terminals)")
|
||||||
|
qrCmd.PersistentFlags().IntVar(&qrQuietZone, "quiet-zone", 2, "Terminal margin in modules")
|
||||||
|
qrCmd.PersistentFlags().IntVar(&qrModuleSize, "module-size", 0, "PNG pixels per module (0 = auto)")
|
||||||
|
qrCmd.PersistentFlags().StringVar(&qrFg, "fg", "", "Dark module color (#RGB or #RRGGBB)")
|
||||||
|
qrCmd.PersistentFlags().StringVar(&qrBg, "bg", "", "Light module color (#RGB or #RRGGBB)")
|
||||||
|
qrCmd.PersistentFlags().BoolVar(&qrTransparent, "transparent", false, "Transparent PNG background")
|
||||||
|
qrCmd.PersistentFlags().IntVar(&qrBorder, "border", -1, "PNG border in pixels (-1 = auto)")
|
||||||
|
qrCmd.PersistentFlags().StringVar(&qrShape, "shape", "square", "PNG module shape (square, circle)")
|
||||||
|
qrCmd.PersistentFlags().StringVar(&qrLogo, "logo", "", "Center a PNG/JPEG logo on the PNG output")
|
||||||
|
qrCmd.PersistentFlags().IntVar(&qrLogoScale, "logo-scale", 0, "Max logo size as 1/N of the code (0 = library default of 5)")
|
||||||
|
|
||||||
|
qrWifiCmd.Flags().StringVarP(&qrWifiPassword, "password", "p", "", "WiFi password (offline build)")
|
||||||
|
qrWifiCmd.Flags().StringVar(&qrWifiSecurity, "security", "WPA", "Security type (WPA, WEP, nopass)")
|
||||||
|
qrWifiCmd.Flags().BoolVar(&qrWifiHidden, "hidden", false, "Mark the network as hidden")
|
||||||
|
|
||||||
|
qrCmd.AddCommand(qrWifiCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runQR(cmd *cobra.Command, args []string) {
|
||||||
|
text := strings.Join(args, " ")
|
||||||
|
if text == "" || text == "-" {
|
||||||
|
data, err := io.ReadAll(os.Stdin)
|
||||||
|
if err != nil {
|
||||||
|
fatalf("Error reading stdin: %v", err)
|
||||||
|
}
|
||||||
|
text = strings.TrimRight(string(data), "\n")
|
||||||
|
}
|
||||||
|
if text == "" {
|
||||||
|
fatalf("Error: no input (provide text, pipe stdin, or use a subcommand)")
|
||||||
|
}
|
||||||
|
emitQR(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runQRWifi(cmd *cobra.Command, args []string) {
|
||||||
|
ssid := args[0]
|
||||||
|
if qrWifiPassword != "" || strings.EqualFold(qrWifiSecurity, "nopass") {
|
||||||
|
emitQR(qrcode.WiFiString(qrWifiSecurity, ssid, qrWifiPassword, qrWifiHidden))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := fetchWifiQRContent(ssid)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
fmt.Fprintln(os.Stderr, "Hint: pass --password to build the code without the shell.")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if qrWifiHidden {
|
||||||
|
content = strings.TrimSuffix(content, ";") + "H:true;;"
|
||||||
|
}
|
||||||
|
emitQR(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func emitQR(text string) {
|
||||||
|
renderTerm := shouldRenderTerminal()
|
||||||
|
pngToStdout := qrStdout || (!renderTerm && qrOutput == "" && !qrClipboard && !qrCopyText)
|
||||||
|
|
||||||
|
if pngToStdout || qrOutput != "" || qrClipboard {
|
||||||
|
png, err := qrcode.RenderPNG(text, qrcode.ImageOptions{
|
||||||
|
ECC: effectiveEcc(),
|
||||||
|
Version: qrVersion,
|
||||||
|
ModuleSize: qrModuleSize,
|
||||||
|
Fg: qrFg,
|
||||||
|
Bg: qrBg,
|
||||||
|
Transparent: qrTransparent,
|
||||||
|
Border: qrBorder,
|
||||||
|
Shape: qrShape,
|
||||||
|
Logo: qrLogo,
|
||||||
|
LogoScale: qrLogoScale,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fatalf("Error encoding QR: %v", err)
|
||||||
|
}
|
||||||
|
emitPNG(png, pngToStdout)
|
||||||
|
}
|
||||||
|
|
||||||
|
if qrCopyText {
|
||||||
|
if err := clipboard.CopyText(text); err != nil {
|
||||||
|
fatalf("Error copying text: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !renderTerm {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := qrcode.RenderTerminal(text, qrcode.TermOptions{
|
||||||
|
ECC: effectiveEcc(),
|
||||||
|
Version: qrVersion,
|
||||||
|
QuietZone: qrQuietZone,
|
||||||
|
Invert: qrInvert,
|
||||||
|
Fg: qrFg,
|
||||||
|
Bg: qrBg,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fatalf("Error rendering QR: %v", err)
|
||||||
|
}
|
||||||
|
dst := os.Stdout
|
||||||
|
if pngToStdout {
|
||||||
|
dst = os.Stderr
|
||||||
|
}
|
||||||
|
fmt.Fprint(dst, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func emitPNG(png []byte, toStdout bool) {
|
||||||
|
if qrOutput != "" {
|
||||||
|
if err := os.WriteFile(qrOutput, png, 0o644); err != nil {
|
||||||
|
fatalf("Error writing file: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Fprintln(os.Stderr, qrOutput)
|
||||||
|
}
|
||||||
|
if toStdout {
|
||||||
|
os.Stdout.Write(png)
|
||||||
|
}
|
||||||
|
if qrClipboard {
|
||||||
|
if err := clipboard.Copy(png, "image/png"); err != nil {
|
||||||
|
fatalf("Error copying image: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldRenderTerminal() bool {
|
||||||
|
switch {
|
||||||
|
case qrNoRender:
|
||||||
|
return false
|
||||||
|
case qrRender:
|
||||||
|
return true
|
||||||
|
case qrStdout, qrOutput != "", qrClipboard, qrCopyText:
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return isatty.IsTerminal(os.Stdout.Fd())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func effectiveEcc() string {
|
||||||
|
switch {
|
||||||
|
case qrEcc != "":
|
||||||
|
return qrEcc
|
||||||
|
case qrLogo != "":
|
||||||
|
return "H"
|
||||||
|
default:
|
||||||
|
return "M"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fatalf(format string, args ...any) {
|
||||||
|
fmt.Fprintf(os.Stderr, format+"\n", args...)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchWifiQRContent(ssid string) (string, error) {
|
||||||
|
resp, err := sendServerRequest(models.Request{
|
||||||
|
ID: 1,
|
||||||
|
Method: "network.qrcode-content",
|
||||||
|
Params: map[string]any{"ssid": ssid},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if resp.Error != "" {
|
||||||
|
return "", fmt.Errorf("%s", resp.Error)
|
||||||
|
}
|
||||||
|
if resp.Result == nil {
|
||||||
|
return "", fmt.Errorf("empty response")
|
||||||
|
}
|
||||||
|
content, ok := (*resp.Result).(string)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("unexpected response format")
|
||||||
|
}
|
||||||
|
return content, nil
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -27,8 +28,19 @@ var (
|
|||||||
ssNoConfirm bool
|
ssNoConfirm bool
|
||||||
ssReset bool
|
ssReset bool
|
||||||
ssStdout bool
|
ssStdout bool
|
||||||
|
ssJSON bool
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type screenshotMetadata struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Path string `json:"path,omitempty"`
|
||||||
|
Width int `json:"width,omitempty"`
|
||||||
|
Height int `json:"height,omitempty"`
|
||||||
|
Scale float64 `json:"scale,omitempty"`
|
||||||
|
Mime string `json:"mime,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
var screenshotCmd = &cobra.Command{
|
var screenshotCmd = &cobra.Command{
|
||||||
Use: "screenshot",
|
Use: "screenshot",
|
||||||
Short: "Capture screenshots",
|
Short: "Capture screenshots",
|
||||||
@@ -39,8 +51,9 @@ Modes:
|
|||||||
full - Capture the focused output
|
full - Capture the focused output
|
||||||
all - Capture all outputs combined
|
all - Capture all outputs combined
|
||||||
output - Capture a specific output by name
|
output - Capture a specific output by name
|
||||||
window - Capture the focused window (Hyprland/Mango)
|
window - Capture the focused window (Hyprland/Mango/niri)
|
||||||
last - Capture the last selected region
|
last - Capture the last selected region
|
||||||
|
scroll - Select a region, then scroll to capture a stitched tall image
|
||||||
|
|
||||||
Output format (--format):
|
Output format (--format):
|
||||||
png - PNG format (default)
|
png - PNG format (default)
|
||||||
@@ -59,7 +72,10 @@ Examples:
|
|||||||
dms screenshot --no-file # Clipboard only
|
dms screenshot --no-file # Clipboard only
|
||||||
dms screenshot --no-confirm # Region capture on mouse release
|
dms screenshot --no-confirm # Region capture on mouse release
|
||||||
dms screenshot --cursor=on # Include cursor
|
dms screenshot --cursor=on # Include cursor
|
||||||
dms screenshot -f jpg -q 85 # JPEG with quality 85`,
|
dms screenshot -f jpg -q 85 # JPEG with quality 85
|
||||||
|
dms screenshot --json # Print capture metadata as JSON
|
||||||
|
dms screenshot scroll # Scroll capture, Enter finishes / Esc cancels
|
||||||
|
dms screenshot scroll --interval 250`,
|
||||||
}
|
}
|
||||||
|
|
||||||
var ssRegionCmd = &cobra.Command{
|
var ssRegionCmd = &cobra.Command{
|
||||||
@@ -97,10 +113,33 @@ If no previous region exists, falls back to interactive selection.`,
|
|||||||
var ssWindowCmd = &cobra.Command{
|
var ssWindowCmd = &cobra.Command{
|
||||||
Use: "window",
|
Use: "window",
|
||||||
Short: "Capture the focused window",
|
Short: "Capture the focused window",
|
||||||
Long: `Capture the currently focused window. Supported on Hyprland and Mango.`,
|
Long: `Capture the currently focused window. Supported on Hyprland, Mango, and niri.`,
|
||||||
Run: runScreenshotWindow,
|
Run: runScreenshotWindow,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var ssScrollInterval int
|
||||||
|
|
||||||
|
var ssScrollCmd = &cobra.Command{
|
||||||
|
Use: "scroll",
|
||||||
|
Short: "Capture a scrolling region stitched into one tall image",
|
||||||
|
Long: `Select a region, then scroll the content beneath with the mouse wheel or
|
||||||
|
touchpad while frames are captured and stitched vertically. Finish with the
|
||||||
|
on-screen done button; cancel with the cancel button. Enter and Esc work
|
||||||
|
everywhere: most compositors hold the keyboard on the overlay (keyboard
|
||||||
|
scrolling does not reach the app there), while Hyprland leaves the keyboard
|
||||||
|
with the application — keyboard scrolling works, and Enter/Esc act through
|
||||||
|
temporary global binds for the session. The cursor is never included in
|
||||||
|
frames.
|
||||||
|
|
||||||
|
Frames are stitched continuously while scrolling, and revisited content is
|
||||||
|
never duplicated — scrolling up past the starting point extends the image
|
||||||
|
upward. Content jumped past faster than capture can follow is skipped rather
|
||||||
|
than stitched incorrectly.
|
||||||
|
|
||||||
|
Rotated outputs are not supported.`,
|
||||||
|
Run: runScreenshotScroll,
|
||||||
|
}
|
||||||
|
|
||||||
var ssListCmd = &cobra.Command{
|
var ssListCmd = &cobra.Command{
|
||||||
Use: "list",
|
Use: "list",
|
||||||
Short: "List available outputs",
|
Short: "List available outputs",
|
||||||
@@ -128,8 +167,12 @@ func init() {
|
|||||||
screenshotCmd.PersistentFlags().BoolVar(&ssNoConfirm, "no-confirm", false, "Region mode: capture on mouse release without Enter/Space confirmation")
|
screenshotCmd.PersistentFlags().BoolVar(&ssNoConfirm, "no-confirm", false, "Region mode: capture on mouse release without Enter/Space confirmation")
|
||||||
screenshotCmd.PersistentFlags().BoolVar(&ssReset, "reset", false, "Reset saved last-region preselection before capturing")
|
screenshotCmd.PersistentFlags().BoolVar(&ssReset, "reset", false, "Reset saved last-region preselection before capturing")
|
||||||
screenshotCmd.PersistentFlags().BoolVar(&ssStdout, "stdout", false, "Output image to stdout (for piping to swappy, etc.)")
|
screenshotCmd.PersistentFlags().BoolVar(&ssStdout, "stdout", false, "Output image to stdout (for piping to swappy, etc.)")
|
||||||
|
screenshotCmd.PersistentFlags().BoolVar(&ssJSON, "json", false, "Print capture metadata as JSON")
|
||||||
|
|
||||||
|
ssScrollCmd.Flags().IntVar(&ssScrollInterval, "interval", 45, "Capture interval in milliseconds (30-1000)")
|
||||||
|
|
||||||
screenshotCmd.AddCommand(ssRegionCmd)
|
screenshotCmd.AddCommand(ssRegionCmd)
|
||||||
|
screenshotCmd.AddCommand(ssScrollCmd)
|
||||||
screenshotCmd.AddCommand(ssFullCmd)
|
screenshotCmd.AddCommand(ssFullCmd)
|
||||||
screenshotCmd.AddCommand(ssAllCmd)
|
screenshotCmd.AddCommand(ssAllCmd)
|
||||||
screenshotCmd.AddCommand(ssOutputCmd)
|
screenshotCmd.AddCommand(ssOutputCmd)
|
||||||
@@ -203,10 +246,39 @@ func setPopoutScreenshotMode(begin bool) {
|
|||||||
_ = exec.Command("qs", cmdArgs...).Run()
|
_ = exec.Command("qs", cmdArgs...).Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func writeScreenshotJSON(meta screenshotMetadata) {
|
||||||
|
_ = json.NewEncoder(os.Stdout).Encode(meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
func exitScreenshotError(context string, err error) {
|
||||||
|
if ssJSON {
|
||||||
|
writeScreenshotJSON(screenshotMetadata{Status: "error", Error: err.Error()})
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "Error%s: %v\n", context, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatMime(format screenshot.Format) string {
|
||||||
|
switch format {
|
||||||
|
case screenshot.FormatJPEG:
|
||||||
|
return "image/jpeg"
|
||||||
|
case screenshot.FormatPPM:
|
||||||
|
return "image/x-portable-pixmap"
|
||||||
|
default:
|
||||||
|
return "image/png"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func runScreenshot(config screenshot.Config) {
|
func runScreenshot(config screenshot.Config) {
|
||||||
|
if ssJSON && config.Stdout {
|
||||||
|
fmt.Fprintln(os.Stderr, "Error: --json cannot be combined with --stdout")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
// Region select needs the keyboard; drop popout grabs for its duration.
|
// Region select needs the keyboard; drop popout grabs for its duration.
|
||||||
result, err := func() (*screenshot.CaptureResult, error) {
|
result, err := func() (*screenshot.CaptureResult, error) {
|
||||||
interactive := config.Mode == screenshot.ModeRegion || config.Mode == screenshot.ModeLastRegion
|
interactive := config.Mode == screenshot.ModeRegion || config.Mode == screenshot.ModeLastRegion || config.Mode == screenshot.ModeScroll
|
||||||
if interactive {
|
if interactive {
|
||||||
setPopoutScreenshotMode(true)
|
setPopoutScreenshotMode(true)
|
||||||
defer setPopoutScreenshotMode(false)
|
defer setPopoutScreenshotMode(false)
|
||||||
@@ -215,11 +287,13 @@ func runScreenshot(config screenshot.Config) {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
exitScreenshotError("", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if result == nil {
|
if result == nil {
|
||||||
|
if ssJSON {
|
||||||
|
writeScreenshotJSON(screenshotMetadata{Status: "aborted", Error: "User cancelled selection"})
|
||||||
|
}
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,8 +305,7 @@ func runScreenshot(config screenshot.Config) {
|
|||||||
|
|
||||||
if config.Stdout {
|
if config.Stdout {
|
||||||
if err := writeImageToStdout(result.Buffer, config.Format, config.Quality, result.Format); err != nil {
|
if err := writeImageToStdout(result.Buffer, config.Format, config.Quality, result.Format); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Error writing to stdout: %v\n", err)
|
exitScreenshotError(" writing to stdout", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -252,22 +325,37 @@ func runScreenshot(config screenshot.Config) {
|
|||||||
|
|
||||||
filePath = filepath.Join(outputDir, filename)
|
filePath = filepath.Join(outputDir, filename)
|
||||||
if err := screenshot.WriteToFileWithFormat(result.Buffer, filePath, config.Format, config.Quality, result.Format); err != nil {
|
if err := screenshot.WriteToFileWithFormat(result.Buffer, filePath, config.Format, config.Quality, result.Format); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Error writing file: %v\n", err)
|
exitScreenshotError(" writing file", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
if !ssJSON {
|
||||||
fmt.Println(filePath)
|
fmt.Println(filePath)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if config.Clipboard {
|
if config.Clipboard {
|
||||||
if err := copyImageToClipboard(result.Buffer, config.Format, config.Quality, result.Format); err != nil {
|
if err := copyImageToClipboard(result.Buffer, config.Format, config.Quality, result.Format); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Error copying to clipboard: %v\n", err)
|
exitScreenshotError(" copying to clipboard", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
if !config.SaveFile {
|
if !ssJSON && !config.SaveFile {
|
||||||
fmt.Println("Copied to clipboard")
|
fmt.Println("Copied to clipboard")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ssJSON {
|
||||||
|
scale := result.Scale
|
||||||
|
if scale <= 0 {
|
||||||
|
scale = 1.0
|
||||||
|
}
|
||||||
|
writeScreenshotJSON(screenshotMetadata{
|
||||||
|
Status: "success",
|
||||||
|
Path: filePath,
|
||||||
|
Width: result.Buffer.Width,
|
||||||
|
Height: result.Buffer.Height,
|
||||||
|
Scale: scale,
|
||||||
|
Mime: formatMime(config.Format),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if config.Notify {
|
if config.Notify {
|
||||||
thumbData, thumbW, thumbH := bufferToRGBThumbnail(result.Buffer, 256, result.Format)
|
thumbData, thumbW, thumbH := bufferToRGBThumbnail(result.Buffer, 256, result.Format)
|
||||||
screenshot.SendNotification(screenshot.NotifyResult{
|
screenshot.SendNotification(screenshot.NotifyResult{
|
||||||
@@ -378,6 +466,12 @@ func runScreenshotRegion(cmd *cobra.Command, args []string) {
|
|||||||
runScreenshot(config)
|
runScreenshot(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runScreenshotScroll(cmd *cobra.Command, args []string) {
|
||||||
|
config := getScreenshotConfig(screenshot.ModeScroll)
|
||||||
|
config.IntervalMs = min(max(ssScrollInterval, 30), 1000)
|
||||||
|
runScreenshot(config)
|
||||||
|
}
|
||||||
|
|
||||||
func runScreenshotFull(cmd *cobra.Command, args []string) {
|
func runScreenshotFull(cmd *cobra.Command, args []string) {
|
||||||
config := getScreenshotConfig(screenshot.ModeFullScreen)
|
config := getScreenshotConfig(screenshot.ModeFullScreen)
|
||||||
runScreenshot(config)
|
runScreenshot(config)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"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/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/privesc"
|
||||||
@@ -298,6 +299,9 @@ func runSetup() error {
|
|||||||
if wmSelected {
|
if wmSelected {
|
||||||
if wm == deps.WindowManagerMango {
|
if wm == deps.WindowManagerMango {
|
||||||
useSystemd = false
|
useSystemd = false
|
||||||
|
} else if isVoidSetup() {
|
||||||
|
useSystemd = false
|
||||||
|
fmt.Println("\nVoid Linux detected; deploying non-systemd session config.")
|
||||||
} else {
|
} else {
|
||||||
useSystemd = promptSystemd()
|
useSystemd = promptSystemd()
|
||||||
}
|
}
|
||||||
@@ -372,6 +376,15 @@ func runSetup() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isVoidSetup() bool {
|
||||||
|
osInfo, err := distros.GetOSInfo()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
config, exists := distros.Registry[osInfo.Distribution.ID]
|
||||||
|
return exists && config.Family == distros.FamilyVoid
|
||||||
|
}
|
||||||
|
|
||||||
// Add user to the input group for the evdev manager for inut state tracking.
|
// Add user to the input group for the evdev manager for inut state tracking.
|
||||||
// Caps Lock OSD and the Caps Lock bar indicator.
|
// Caps Lock OSD and the Caps Lock bar indicator.
|
||||||
func ensureInputGroup() {
|
func ensureInputGroup() {
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ var (
|
|||||||
sysUpdateJSON bool
|
sysUpdateJSON bool
|
||||||
sysUpdateNoFlatpak bool
|
sysUpdateNoFlatpak bool
|
||||||
sysUpdateNoAUR bool
|
sysUpdateNoAUR bool
|
||||||
|
sysUpdateIgnore []string
|
||||||
sysUpdateIntervalS int
|
sysUpdateIntervalS int
|
||||||
sysUpdateListPmTime = 5 * time.Minute
|
sysUpdateListPmTime = 5 * time.Minute
|
||||||
)
|
)
|
||||||
@@ -58,6 +59,7 @@ func init() {
|
|||||||
systemUpdateCmd.Flags().BoolVar(&sysUpdateJSON, "json", false, "Output as JSON (with --check)")
|
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(&sysUpdateNoFlatpak, "no-flatpak", false, "Skip the Flatpak overlay")
|
||||||
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoAUR, "no-aur", false, "Skip the AUR (paru/yay only)")
|
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoAUR, "no-aur", false, "Skip the AUR (paru/yay only)")
|
||||||
|
systemUpdateCmd.Flags().StringSliceVar(&sysUpdateIgnore, "ignore", nil, "Skip specific packages (repeatable or comma-separated)")
|
||||||
systemUpdateCmd.Flags().IntVar(&sysUpdateIntervalS, "interval", -1, "Set the DMS server poll interval in seconds and exit (requires running server)")
|
systemUpdateCmd.Flags().IntVar(&sysUpdateIntervalS, "interval", -1, "Set the DMS server poll interval in seconds and exit (requires running server)")
|
||||||
|
|
||||||
systemCmd.AddCommand(systemUpdateCmd)
|
systemCmd.AddCommand(systemUpdateCmd)
|
||||||
@@ -192,6 +194,7 @@ func runSystemUpdateApply() {
|
|||||||
Targets: pkgs,
|
Targets: pkgs,
|
||||||
IncludeFlatpak: !sysUpdateNoFlatpak,
|
IncludeFlatpak: !sysUpdateNoFlatpak,
|
||||||
IncludeAUR: !sysUpdateNoAUR,
|
IncludeAUR: !sysUpdateNoAUR,
|
||||||
|
Ignored: sysUpdateIgnore,
|
||||||
DryRun: sysUpdateDry,
|
DryRun: sysUpdateDry,
|
||||||
UseSudo: true,
|
UseSudo: true,
|
||||||
}
|
}
|
||||||
@@ -234,12 +237,19 @@ func collectUpdates(ctx context.Context, backends []sysupdate.Backend) ([]sysupd
|
|||||||
}
|
}
|
||||||
|
|
||||||
func filterUpdateTargets(pkgs []sysupdate.Package) []sysupdate.Package {
|
func filterUpdateTargets(pkgs []sysupdate.Package) []sysupdate.Package {
|
||||||
if !sysUpdateNoAUR {
|
if !sysUpdateNoAUR && len(sysUpdateIgnore) == 0 {
|
||||||
return pkgs
|
return pkgs
|
||||||
}
|
}
|
||||||
|
ignored := make(map[string]bool, len(sysUpdateIgnore))
|
||||||
|
for _, name := range sysUpdateIgnore {
|
||||||
|
ignored[name] = true
|
||||||
|
}
|
||||||
out := pkgs[:0]
|
out := pkgs[:0]
|
||||||
for _, p := range pkgs {
|
for _, p := range pkgs {
|
||||||
if p.Repo == sysupdate.RepoAUR {
|
if sysUpdateNoAUR && p.Repo == sysupdate.RepoAUR {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ignored[p.Name] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, p)
|
out = append(out, p)
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDefaultImmutablePolicyAllowsSyncButBlocksEnable(t *testing.T) {
|
||||||
|
var policyFile cliPolicyFile
|
||||||
|
if err := json.Unmarshal(defaultCLIPolicyJSON, &policyFile); err != nil {
|
||||||
|
t.Fatalf("failed to parse embedded CLI policy: %v", err)
|
||||||
|
}
|
||||||
|
if policyFile.BlockedCommands == nil {
|
||||||
|
t.Fatal("embedded CLI policy has no blocked_commands")
|
||||||
|
}
|
||||||
|
|
||||||
|
blocked := normalizeBlockedCommands(*policyFile.BlockedCommands)
|
||||||
|
if !commandBlockedByPolicy("greeter enable", blocked) {
|
||||||
|
t.Fatal("expected greeter enable to remain blocked on immutable/image-based systems")
|
||||||
|
}
|
||||||
|
if commandBlockedByPolicy("greeter sync", blocked) {
|
||||||
|
t.Fatal("expected greeter sync to remain available on immutable/image-based systems")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/greeter"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// runit (Void Linux) service helpers. Services live in /etc/sv and are "enabled"
|
||||||
|
// by symlinking them into the /var/service supervision dir, so the greeter
|
||||||
|
// commands branch on isRunit() instead of shelling systemctl.
|
||||||
|
|
||||||
|
const (
|
||||||
|
runitSvDir = "/etc/sv"
|
||||||
|
runitServiceDir = "/var/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// isRunit reports whether this system is supervised by runit (Void Linux).
|
||||||
|
func isRunit() bool {
|
||||||
|
if fi, err := os.Stat("/run/runit"); err == nil && fi.IsDir() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if _, err := os.Stat("/run/systemd/system"); err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if fi, err := os.Stat(runitServiceDir); err == nil && fi.IsDir() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func runitServiceInstalled(name string) bool {
|
||||||
|
fi, err := os.Stat(runitSvDir + "/" + name)
|
||||||
|
return err == nil && fi.IsDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
func runitServiceEnabled(name string) bool {
|
||||||
|
_, err := os.Lstat(runitServiceDir + "/" + name)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// enableRunitService links a service into /var/service (idempotent).
|
||||||
|
func enableRunitService(name string) error {
|
||||||
|
if !runitServiceInstalled(name) {
|
||||||
|
return fmt.Errorf("runit service %q not found in %s", name, runitSvDir)
|
||||||
|
}
|
||||||
|
if runitServiceEnabled(name) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return privesc.Run(context.Background(), "", "ln", "-sf",
|
||||||
|
runitSvDir+"/"+name, runitServiceDir+"/"+name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// disableRunitService removes a service's supervision symlink.
|
||||||
|
func disableRunitService(name string) error {
|
||||||
|
if !runitServiceEnabled(name) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return privesc.Run(context.Background(), "", "rm", "-f",
|
||||||
|
runitServiceDir+"/"+name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureRunitSeat sets up the seat access a Wayland greeter needs on runit (the
|
||||||
|
// equivalent of logind on systemd): enables seatd and adds the greeter user to
|
||||||
|
// the seat/video/input groups. Failures are reported but non-fatal.
|
||||||
|
func ensureRunitSeat(greeterUser string) {
|
||||||
|
if runitServiceInstalled("seatd") {
|
||||||
|
if err := enableRunitService("seatd"); err != nil {
|
||||||
|
fmt.Printf(" ⚠ could not enable seatd: %v\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Println(" ✓ seatd enabled")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Println(" ⚠ seatd not installed — the greeter compositor needs it for GPU/seat access")
|
||||||
|
}
|
||||||
|
if err := privesc.Run(context.Background(), "", "usermod", "-aG", "_seatd,video,input", greeterUser); err != nil {
|
||||||
|
fmt.Printf(" ⚠ could not add %s to seat groups: %v\n", greeterUser, err)
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" ✓ %s added to seat groups (_seatd, video, input)\n", greeterUser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureVoidLogindGreeter configures the elogind-backed greeter on Void.
|
||||||
|
func ensureVoidLogindGreeter(greeterUser string) {
|
||||||
|
for _, service := range []string{"dbus", "elogind"} {
|
||||||
|
if err := enableRunitService(service); err != nil {
|
||||||
|
fmt.Printf(" ⚠ could not enable %s: %v\n", service, err)
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" ✓ %s enabled\n", service)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
greeter.EnsureVoidGreetdRunScript(func(msg string) { fmt.Println(" " + msg) }, "")
|
||||||
|
if runitServiceEnabled("seatd") {
|
||||||
|
if err := disableRunitService("seatd"); err != nil {
|
||||||
|
fmt.Printf(" ⚠ could not disable seatd: %v\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Println(" ✓ seatd disabled (elogind manages the seat)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := privesc.Run(context.Background(), "", "usermod", "-aG", "video,input", greeterUser); err != nil {
|
||||||
|
fmt.Printf(" ⚠ could not add %s to video/input groups: %v\n", greeterUser, err)
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" ✓ %s added to video/input groups (elogind manages the seat)\n", greeterUser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureGreetdPamRundir provides XDG_RUNTIME_DIR to runit greeter sessions.
|
||||||
|
func ensureGreetdPamRundir() {
|
||||||
|
const pamPath = "/etc/pam.d/greetd"
|
||||||
|
data, err := os.ReadFile(pamPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" ⚠ could not read %s: %v\n", pamPath, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.Contains(string(data), "pam_rundir") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
line := "session optional pam_rundir.so"
|
||||||
|
if err := privesc.Run(context.Background(), "", "sh", "-c",
|
||||||
|
fmt.Sprintf("printf '%%s\\n' %q >> %s", line, pamPath)); err != nil {
|
||||||
|
fmt.Printf(" ⚠ could not add pam_rundir to %s: %v\n", pamPath, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println(" ✓ pam_rundir added to greetd PAM (provides XDG_RUNTIME_DIR for the session)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// startGreeterHint returns the init-appropriate "start greetd now" command.
|
||||||
|
func startGreeterHint() string {
|
||||||
|
if isRunit() {
|
||||||
|
return " sudo sv up greetd"
|
||||||
|
}
|
||||||
|
return " sudo systemctl start greetd"
|
||||||
|
}
|
||||||
@@ -19,8 +19,8 @@ func init() {
|
|||||||
runCmd.Flags().String("log-file", "", "Append logs to this file in addition to stderr (overrides DMS_LOG_FILE)")
|
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, greeterLaunchSessionCmd)
|
||||||
authCmd.AddCommand(authSyncCmd)
|
authCmd.AddCommand(authSyncCmd, authResolveLockCmd, authListServicesCmd, authValidateCmd)
|
||||||
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
|
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
|
||||||
updateCmd.AddCommand(updateCheckCmd)
|
updateCmd.AddCommand(updateCheckCmd)
|
||||||
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
|
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ func init() {
|
|||||||
runCmd.Flags().String("log-file", "", "Append logs to this file in addition to stderr (overrides DMS_LOG_FILE)")
|
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, greeterLaunchSessionCmd)
|
||||||
authCmd.AddCommand(authSyncCmd)
|
authCmd.AddCommand(authSyncCmd, authResolveLockCmd, authListServicesCmd, authValidateCmd)
|
||||||
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
|
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
|
||||||
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
|
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
|
||||||
rootCmd.AddCommand(getCommonCommands()...)
|
rootCmd.AddCommand(getCommonCommands()...)
|
||||||
|
|||||||
@@ -101,6 +101,13 @@ func getServerSocketPath() string {
|
|||||||
runtimeDir = os.TempDir()
|
runtimeDir = os.TempDir()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if parentPID, ok := sessionParentPID(os.Getenv("WAYLAND_DISPLAY")); ok {
|
||||||
|
sessionSock := filepath.Join(runtimeDir, fmt.Sprintf("danklinux-%d.sock", parentPID))
|
||||||
|
if _, err := os.Stat(sessionSock); err == nil {
|
||||||
|
return sessionSock
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
entries, err := os.ReadDir(runtimeDir)
|
entries, err := os.ReadDir(runtimeDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return filepath.Join(runtimeDir, "danklinux.sock")
|
return filepath.Join(runtimeDir, "danklinux.sock")
|
||||||
|
|||||||
+140
-33
@@ -92,6 +92,14 @@ func appendLogEnv(env []string) []string {
|
|||||||
return env
|
return env
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func withDMSExecutable(env []string) []string {
|
||||||
|
selfPath, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
return append(env, "DMS_EXECUTABLE="+selfPath)
|
||||||
|
}
|
||||||
|
|
||||||
func hasSystemdRun() bool {
|
func hasSystemdRun() bool {
|
||||||
_, err := exec.LookPath("systemd-run")
|
_, err := exec.LookPath("systemd-run")
|
||||||
return err == nil
|
return err == nil
|
||||||
@@ -101,14 +109,23 @@ func getPIDFilePath() string {
|
|||||||
return filepath.Join(getRuntimeDir(), fmt.Sprintf("danklinux-%d.pid", os.Getpid()))
|
return filepath.Join(getRuntimeDir(), fmt.Sprintf("danklinux-%d.pid", os.Getpid()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getSessionFilePath() string {
|
||||||
|
return filepath.Join(getRuntimeDir(), fmt.Sprintf("danklinux-%d.session", os.Getpid()))
|
||||||
|
}
|
||||||
|
|
||||||
func writePIDFile(childPID int) error {
|
func writePIDFile(childPID int) error {
|
||||||
pidFile := getPIDFilePath()
|
pidFile := getPIDFilePath()
|
||||||
|
if display := os.Getenv("WAYLAND_DISPLAY"); display != "" {
|
||||||
|
if err := os.WriteFile(getSessionFilePath(), []byte(display), 0o644); err != nil {
|
||||||
|
log.Warnf("Failed to write session file: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
return os.WriteFile(pidFile, []byte(strconv.Itoa(childPID)), 0o644)
|
return os.WriteFile(pidFile, []byte(strconv.Itoa(childPID)), 0o644)
|
||||||
}
|
}
|
||||||
|
|
||||||
func removePIDFile() {
|
func removePIDFile() {
|
||||||
pidFile := getPIDFilePath()
|
os.Remove(getPIDFilePath())
|
||||||
os.Remove(pidFile)
|
os.Remove(getSessionFilePath())
|
||||||
}
|
}
|
||||||
|
|
||||||
func getAllDMSPIDs() []int {
|
func getAllDMSPIDs() []int {
|
||||||
@@ -198,7 +215,7 @@ func runShellInteractive(session bool) {
|
|||||||
log.Infof("Spawning quickshell with -p %s", configPath)
|
log.Infof("Spawning quickshell with -p %s", configPath)
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, "qs", "-p", configPath)
|
cmd := exec.CommandContext(ctx, "qs", "-p", configPath)
|
||||||
cmd.Env = append(os.Environ(), "DMS_SOCKET="+socketPath)
|
cmd.Env = withDMSExecutable(append(os.Environ(), "DMS_SOCKET="+socketPath))
|
||||||
if os.Getenv("QT_LOGGING_RULES") == "" {
|
if os.Getenv("QT_LOGGING_RULES") == "" {
|
||||||
if qtRules := log.GetQtLoggingRules(); qtRules != "" {
|
if qtRules := log.GetQtLoggingRules(); qtRules != "" {
|
||||||
cmd.Env = append(cmd.Env, "QT_LOGGING_RULES="+qtRules)
|
cmd.Env = append(cmd.Env, "QT_LOGGING_RULES="+qtRules)
|
||||||
@@ -225,6 +242,9 @@ func runShellInteractive(session bool) {
|
|||||||
if os.Getenv("QT_QPA_PLATFORM") == "" {
|
if os.Getenv("QT_QPA_PLATFORM") == "" {
|
||||||
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland;xcb")
|
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland;xcb")
|
||||||
}
|
}
|
||||||
|
if os.Getenv("QSG_USE_SIMPLE_ANIMATION_DRIVER") == "" {
|
||||||
|
cmd.Env = append(cmd.Env, "QSG_USE_SIMPLE_ANIMATION_DRIVER=1")
|
||||||
|
}
|
||||||
|
|
||||||
cmd.Env = appendLogEnv(cmd.Env)
|
cmd.Env = appendLogEnv(cmd.Env)
|
||||||
|
|
||||||
@@ -387,9 +407,11 @@ func killShell() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if strings.HasPrefix(entry.Name(), "danklinux-") && strings.HasSuffix(entry.Name(), ".pid") {
|
if !strings.HasPrefix(entry.Name(), "danklinux-") {
|
||||||
pidFile := filepath.Join(dir, entry.Name())
|
continue
|
||||||
os.Remove(pidFile)
|
}
|
||||||
|
if strings.HasSuffix(entry.Name(), ".pid") || strings.HasSuffix(entry.Name(), ".session") {
|
||||||
|
os.Remove(filepath.Join(dir, entry.Name()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -447,7 +469,7 @@ func runShellDaemon(session bool) {
|
|||||||
log.Infof("Spawning quickshell with -p %s", configPath)
|
log.Infof("Spawning quickshell with -p %s", configPath)
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, "qs", "-p", configPath)
|
cmd := exec.CommandContext(ctx, "qs", "-p", configPath)
|
||||||
cmd.Env = append(os.Environ(), "DMS_SOCKET="+socketPath)
|
cmd.Env = withDMSExecutable(append(os.Environ(), "DMS_SOCKET="+socketPath))
|
||||||
if os.Getenv("QT_LOGGING_RULES") == "" {
|
if os.Getenv("QT_LOGGING_RULES") == "" {
|
||||||
if qtRules := log.GetQtLoggingRules(); qtRules != "" {
|
if qtRules := log.GetQtLoggingRules(); qtRules != "" {
|
||||||
cmd.Env = append(cmd.Env, "QT_LOGGING_RULES="+qtRules)
|
cmd.Env = append(cmd.Env, "QT_LOGGING_RULES="+qtRules)
|
||||||
@@ -477,6 +499,9 @@ func runShellDaemon(session bool) {
|
|||||||
if os.Getenv("QT_QPA_PLATFORM") == "" {
|
if os.Getenv("QT_QPA_PLATFORM") == "" {
|
||||||
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland;xcb")
|
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland;xcb")
|
||||||
}
|
}
|
||||||
|
if os.Getenv("QSG_USE_SIMPLE_ANIMATION_DRIVER") == "" {
|
||||||
|
cmd.Env = append(cmd.Env, "QSG_USE_SIMPLE_ANIMATION_DRIVER=1")
|
||||||
|
}
|
||||||
|
|
||||||
cmd.Env = appendLogEnv(cmd.Env)
|
cmd.Env = appendLogEnv(cmd.Env)
|
||||||
|
|
||||||
@@ -565,7 +590,11 @@ func runShellDaemon(session bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var qsHasAnyDisplay = sync.OnceValue(func() bool {
|
var qsHasAnyDisplay = sync.OnceValue(func() bool {
|
||||||
out, err := exec.Command("qs", "ipc", "--help").Output()
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(ctx, "qs", "ipc", "--help")
|
||||||
|
cmd.WaitDelay = 500 * time.Millisecond
|
||||||
|
out, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -601,13 +630,34 @@ func parseTargetsFromIPCShowOutput(output string) ipcTargets {
|
|||||||
return targets
|
return targets
|
||||||
}
|
}
|
||||||
|
|
||||||
func getShellIPCCompletions(args []string, _ string) []string {
|
func buildQsIPCBaseArgs() ([]string, error) {
|
||||||
cmdArgs := []string{"ipc"}
|
cmdArgs := []string{"ipc"}
|
||||||
|
switch pid, ok := getSessionDMSPID(); {
|
||||||
|
case ok:
|
||||||
|
cmdArgs = append(cmdArgs, "--pid", strconv.Itoa(pid))
|
||||||
|
default:
|
||||||
|
if err := findConfig(nil, nil); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if qsHasAnyDisplay() {
|
if qsHasAnyDisplay() {
|
||||||
cmdArgs = append(cmdArgs, "--any-display")
|
cmdArgs = append(cmdArgs, "--any-display")
|
||||||
}
|
}
|
||||||
cmdArgs = append(cmdArgs, "-p", configPath, "show")
|
cmdArgs = append(cmdArgs, "-p", configPath)
|
||||||
cmd := exec.Command("qs", cmdArgs...)
|
}
|
||||||
|
return cmdArgs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getShellIPCCompletions(args []string, _ string) []string {
|
||||||
|
baseArgs, err := buildQsIPCBaseArgs()
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf("Error building IPC args for completions: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cmdArgs := append(baseArgs, "show")
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(ctx, "qs", cmdArgs...)
|
||||||
|
cmd.WaitDelay = 500 * time.Millisecond
|
||||||
var targets ipcTargets
|
var targets ipcTargets
|
||||||
|
|
||||||
if output, err := cmd.Output(); err == nil {
|
if output, err := cmd.Output(); err == nil {
|
||||||
@@ -623,7 +673,7 @@ func getShellIPCCompletions(args []string, _ string) []string {
|
|||||||
|
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
targetNames := make([]string, 0)
|
targetNames := make([]string, 0)
|
||||||
targetNames = append(targetNames, "call")
|
targetNames = append(targetNames, "call", "list")
|
||||||
for k := range targets {
|
for k := range targets {
|
||||||
targetNames = append(targetNames, k)
|
targetNames = append(targetNames, k)
|
||||||
}
|
}
|
||||||
@@ -686,6 +736,64 @@ func getFirstDMSPID() (int, bool) {
|
|||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sessionParentPID(display string) (int, bool) {
|
||||||
|
if display == "" {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := getRuntimeDir()
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
name := entry.Name()
|
||||||
|
if !strings.HasPrefix(name, "danklinux-") || !strings.HasSuffix(name, ".session") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(filepath.Join(dir, name))
|
||||||
|
if err != nil || strings.TrimSpace(string(data)) != display {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
parentStr := strings.TrimSuffix(strings.TrimPrefix(name, "danklinux-"), ".session")
|
||||||
|
parentPID, err := strconv.Atoi(parentStr)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return parentPID, true
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSessionDMSPID() (int, bool) {
|
||||||
|
parentPID, ok := sessionParentPID(os.Getenv("WAYLAND_DISPLAY"))
|
||||||
|
if !ok {
|
||||||
|
return getFirstDMSPID()
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(filepath.Join(getRuntimeDir(), fmt.Sprintf("danklinux-%d.pid", parentPID)))
|
||||||
|
if err != nil {
|
||||||
|
return getFirstDMSPID()
|
||||||
|
}
|
||||||
|
|
||||||
|
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
|
||||||
|
if err != nil {
|
||||||
|
return getFirstDMSPID()
|
||||||
|
}
|
||||||
|
|
||||||
|
proc, err := os.FindProcess(pid)
|
||||||
|
if err != nil || proc.Signal(syscall.Signal(0)) != nil {
|
||||||
|
return getFirstDMSPID()
|
||||||
|
}
|
||||||
|
|
||||||
|
return pid, true
|
||||||
|
}
|
||||||
|
|
||||||
func runShellIPCCommand(args []string) {
|
func runShellIPCCommand(args []string) {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
printIPCHelp()
|
printIPCHelp()
|
||||||
@@ -696,23 +804,11 @@ func runShellIPCCommand(args []string) {
|
|||||||
args = append([]string{"call"}, args...)
|
args = append([]string{"call"}, args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmdArgs := []string{"ipc"}
|
baseArgs, err := buildQsIPCBaseArgs()
|
||||||
|
if err != nil {
|
||||||
switch pid, ok := getFirstDMSPID(); {
|
|
||||||
case ok:
|
|
||||||
cmdArgs = append(cmdArgs, "--pid", strconv.Itoa(pid))
|
|
||||||
default:
|
|
||||||
if err := findConfig(nil, nil); err != nil {
|
|
||||||
log.Fatalf("Error finding config: %v", err)
|
log.Fatalf("Error finding config: %v", err)
|
||||||
}
|
}
|
||||||
// ! TODO - remove check when QS 0.3 is released
|
cmdArgs := append(baseArgs, args...)
|
||||||
if qsHasAnyDisplay() {
|
|
||||||
cmdArgs = append(cmdArgs, "--any-display")
|
|
||||||
}
|
|
||||||
cmdArgs = append(cmdArgs, "-p", configPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
cmdArgs = append(cmdArgs, args...)
|
|
||||||
cmd := exec.Command("qs", cmdArgs...)
|
cmd := exec.Command("qs", cmdArgs...)
|
||||||
cmd.Stdin = os.Stdin
|
cmd.Stdin = os.Stdin
|
||||||
cmd.Stdout = os.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
@@ -724,19 +820,20 @@ func runShellIPCCommand(args []string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func printIPCHelp() {
|
func printIPCHelp() {
|
||||||
fmt.Println("Usage: dms ipc <target> <function> [args...]")
|
fmt.Println("Usage: dms ipc call <target> <function> [args...]")
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
||||||
cmdArgs := []string{"ipc"}
|
baseArgs, err := buildQsIPCBaseArgs()
|
||||||
if qsHasAnyDisplay() {
|
if err != nil {
|
||||||
cmdArgs = append(cmdArgs, "--any-display")
|
printIPCHelpFailure(err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
cmdArgs = append(cmdArgs, "-p", configPath, "show")
|
cmdArgs := append(baseArgs, "show")
|
||||||
cmd := exec.Command("qs", cmdArgs...)
|
cmd := exec.Command("qs", cmdArgs...)
|
||||||
|
|
||||||
output, err := cmd.Output()
|
output, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println("Could not retrieve available IPC targets (is DMS running?)")
|
printIPCHelpFailure(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -765,6 +862,16 @@ func printIPCHelp() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func printIPCHelpFailure(err error) {
|
||||||
|
fmt.Println("Could not retrieve IPC targets.")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" %v\n", err)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println(" Full docs: https://danklinux.com/docs/dankmaterialshell/keybinds-ipc")
|
||||||
|
fmt.Println(" Try: dms ipc call <target> <function>")
|
||||||
|
}
|
||||||
|
|
||||||
// ensureFontCache rebuilds the fontconfig cache if user-configured fonts are missing while skipping defaults
|
// ensureFontCache rebuilds the fontconfig cache if user-configured fonts are missing while skipping defaults
|
||||||
func ensureFontCache() {
|
func ensureFontCache() {
|
||||||
if _, err := exec.LookPath("fc-list"); err != nil {
|
if _, err := exec.LookPath("fc-list"); err != nil {
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,6 @@ require (
|
|||||||
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
|
||||||
go4.org/mem v0.0.0-20240501181205-ae6ca9944745
|
go4.org/mem v0.0.0-20240501181205-ae6ca9944745
|
||||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f
|
|
||||||
golang.org/x/image v0.39.0
|
golang.org/x/image v0.39.0
|
||||||
tailscale.com v1.96.5
|
tailscale.com v1.96.5
|
||||||
)
|
)
|
||||||
@@ -64,6 +63,7 @@ require (
|
|||||||
github.com/yeqown/reedsolomon v1.0.0 // indirect
|
github.com/yeqown/reedsolomon v1.0.0 // indirect
|
||||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
|
||||||
golang.org/x/crypto v0.50.0 // indirect
|
golang.org/x/crypto v0.50.0 // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
|
||||||
golang.org/x/net v0.53.0 // indirect
|
golang.org/x/net v0.53.0 // indirect
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.zx2c4.com/wireguard/windows v1.0.1 // indirect
|
golang.zx2c4.com/wireguard/windows v1.0.1 // indirect
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control"
|
||||||
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const envServe = "_DMS_CLIPBOARD_SERVE"
|
const envServe = "_DMS_CLIPBOARD_SERVE"
|
||||||
@@ -17,6 +16,37 @@ const envMime = "_DMS_CLIPBOARD_MIME"
|
|||||||
const envPasteOnce = "_DMS_CLIPBOARD_PASTE_ONCE"
|
const envPasteOnce = "_DMS_CLIPBOARD_PASTE_ONCE"
|
||||||
const envCacheFile = "_DMS_CLIPBOARD_CACHE"
|
const envCacheFile = "_DMS_CLIPBOARD_CACHE"
|
||||||
|
|
||||||
|
type Offer struct {
|
||||||
|
MimeType string
|
||||||
|
Data []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// textMimeAliases are offered alongside plain-text content so legacy X11
|
||||||
|
// clients bridged through XWayland find a target they can convert.
|
||||||
|
var textMimeAliases = []string{
|
||||||
|
"text/plain",
|
||||||
|
"text/plain;charset=utf-8",
|
||||||
|
"UTF8_STRING",
|
||||||
|
"STRING",
|
||||||
|
"TEXT",
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExpandOffers turns raw clipboard data into the full offer list to serve,
|
||||||
|
// adding the standard alias set for text content.
|
||||||
|
func ExpandOffers(data []byte, mimeType string) []Offer {
|
||||||
|
offers := []Offer{{MimeType: mimeType, Data: data}}
|
||||||
|
if mimeType != "text/plain" && mimeType != "text/plain;charset=utf-8" {
|
||||||
|
return offers
|
||||||
|
}
|
||||||
|
for _, alias := range textMimeAliases {
|
||||||
|
if alias == mimeType {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
offers = append(offers, Offer{MimeType: alias, Data: data})
|
||||||
|
}
|
||||||
|
return offers
|
||||||
|
}
|
||||||
|
|
||||||
// MaybeServeAndExit intercepts before cobra when re-exec'd as a clipboard
|
// MaybeServeAndExit intercepts before cobra when re-exec'd as a clipboard
|
||||||
// child. Reads source data into memory, deletes any cache file, then serves.
|
// child. Reads source data into memory, deletes any cache file, then serves.
|
||||||
func MaybeServeAndExit() {
|
func MaybeServeAndExit() {
|
||||||
@@ -44,7 +74,7 @@ func MaybeServeAndExit() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := serveClipboard(data, mimeType, pasteOnce); err != nil {
|
if err := serveOffers(ExpandOffers(data, mimeType), pasteOnce); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "clipboard: serve: %v\n", err)
|
fmt.Fprintf(os.Stderr, "clipboard: serve: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
@@ -55,22 +85,33 @@ func Copy(data []byte, mimeType string) error {
|
|||||||
return copyForkCached(data, mimeType, false)
|
return copyForkCached(data, mimeType, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func CopyText(text string) error {
|
||||||
|
return Copy([]byte(text), "text/plain;charset=utf-8")
|
||||||
|
}
|
||||||
|
|
||||||
func CopyOpts(data []byte, mimeType string, foreground, pasteOnce bool) error {
|
func CopyOpts(data []byte, mimeType string, foreground, pasteOnce bool) error {
|
||||||
if foreground {
|
if foreground {
|
||||||
return serveClipboard(data, mimeType, pasteOnce)
|
return serveOffers(ExpandOffers(data, mimeType), pasteOnce)
|
||||||
}
|
}
|
||||||
return copyForkCached(data, mimeType, pasteOnce)
|
return copyForkCached(data, mimeType, pasteOnce)
|
||||||
}
|
}
|
||||||
|
|
||||||
func CopyReader(data io.Reader, mimeType string, foreground, pasteOnce bool) error {
|
func CopyReader(data io.Reader, mimeType string, foreground, pasteOnce bool) error {
|
||||||
if foreground {
|
if !foreground {
|
||||||
|
return copyFork(data, mimeType, pasteOnce)
|
||||||
|
}
|
||||||
buf, err := io.ReadAll(data)
|
buf, err := io.ReadAll(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read source: %w", err)
|
return fmt.Errorf("read source: %w", err)
|
||||||
}
|
}
|
||||||
return serveClipboard(buf, mimeType, pasteOnce)
|
return serveOffers(ExpandOffers(buf, mimeType), pasteOnce)
|
||||||
}
|
}
|
||||||
return copyFork(data, mimeType, pasteOnce)
|
|
||||||
|
func CopyMulti(offers []Offer, foreground, pasteOnce bool) error {
|
||||||
|
if foreground {
|
||||||
|
return serveOffers(offers, pasteOnce)
|
||||||
|
}
|
||||||
|
return copyMultiFork(offers, pasteOnce)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newForkCmd(mimeType string, pasteOnce bool, extra ...string) *exec.Cmd {
|
func newForkCmd(mimeType string, pasteOnce bool, extra ...string) *exec.Cmd {
|
||||||
@@ -132,12 +173,11 @@ func copyForkCached(data []byte, mimeType string, pasteOnce bool) error {
|
|||||||
func copyFork(data io.Reader, mimeType string, pasteOnce bool) error {
|
func copyFork(data io.Reader, mimeType string, pasteOnce bool) error {
|
||||||
cmd := newForkCmd(mimeType, pasteOnce)
|
cmd := newForkCmd(mimeType, pasteOnce)
|
||||||
|
|
||||||
switch src := data.(type) {
|
if src, ok := data.(*os.File); ok {
|
||||||
case *os.File:
|
|
||||||
cmd.Stdin = src
|
cmd.Stdin = src
|
||||||
return waitReady(cmd)
|
return waitReady(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
|
||||||
stdin, err := cmd.StdinPipe()
|
stdin, err := cmd.StdinPipe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("stdin pipe: %w", err)
|
return fmt.Errorf("stdin pipe: %w", err)
|
||||||
@@ -165,6 +205,38 @@ func copyFork(data io.Reader, mimeType string, pasteOnce bool) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func copyMultiFork(offers []Offer, pasteOnce bool) error {
|
||||||
|
args := []string{os.Args[0], "cl", "copy", "--foreground", "--type", "__multi__"}
|
||||||
|
if pasteOnce {
|
||||||
|
args = append(args, "--paste-once")
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(args[0], args[1:]...)
|
||||||
|
cmd.Stdin = nil
|
||||||
|
cmd.Stdout = nil
|
||||||
|
cmd.Stderr = nil
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||||
|
|
||||||
|
stdin, err := cmd.StdinPipe()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("stdin pipe: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return fmt.Errorf("start: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, offer := range offers {
|
||||||
|
fmt.Fprintf(stdin, "%s\x00%d\x00", offer.MimeType, len(offer.Data))
|
||||||
|
if _, err := stdin.Write(offer.Data); err != nil {
|
||||||
|
stdin.Close()
|
||||||
|
return fmt.Errorf("write offer data: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stdin.Close()
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func signalReady() {
|
func signalReady() {
|
||||||
@@ -194,57 +266,25 @@ func createClipboardCacheFile() (*os.File, error) {
|
|||||||
return os.CreateTemp("", "dms-clipboard-*")
|
return os.CreateTemp("", "dms-clipboard-*")
|
||||||
}
|
}
|
||||||
|
|
||||||
func serveClipboard(data []byte, mimeType string, pasteOnce bool) error {
|
// serveOffers owns the Wayland selection until cancelled (or first paste when
|
||||||
display, err := wlclient.Connect("")
|
// pasteOnce is set), answering every offered mime type with its data.
|
||||||
|
func serveOffers(offers []Offer, pasteOnce bool) error {
|
||||||
|
if len(offers) == 0 {
|
||||||
|
return fmt.Errorf("no offers to serve")
|
||||||
|
}
|
||||||
|
|
||||||
|
s, err := connectSession()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("wayland connect: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
defer display.Destroy()
|
defer s.Close()
|
||||||
|
|
||||||
ctx := display.Context()
|
dataControlMgr, err := s.requireDataControl()
|
||||||
registry, err := display.GetRegistry()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("get registry: %w", err)
|
return err
|
||||||
}
|
|
||||||
defer registry.Destroy()
|
|
||||||
|
|
||||||
var dataControlMgr *ext_data_control.ExtDataControlManagerV1
|
|
||||||
var seat *wlclient.Seat
|
|
||||||
var bindErr error
|
|
||||||
|
|
||||||
registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
|
|
||||||
switch e.Interface {
|
|
||||||
case "ext_data_control_manager_v1":
|
|
||||||
dataControlMgr = ext_data_control.NewExtDataControlManagerV1(ctx)
|
|
||||||
if err := registry.Bind(e.Name, e.Interface, e.Version, dataControlMgr); err != nil {
|
|
||||||
bindErr = err
|
|
||||||
}
|
|
||||||
case "wl_seat":
|
|
||||||
if seat != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
seat = wlclient.NewSeat(ctx)
|
|
||||||
if err := registry.Bind(e.Name, e.Interface, e.Version, seat); err != nil {
|
|
||||||
bindErr = err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
display.Roundtrip()
|
|
||||||
display.Roundtrip()
|
|
||||||
|
|
||||||
if bindErr != nil {
|
|
||||||
return fmt.Errorf("registry bind: %w", bindErr)
|
|
||||||
}
|
|
||||||
if dataControlMgr == nil {
|
|
||||||
return fmt.Errorf("compositor does not support ext_data_control_manager_v1")
|
|
||||||
}
|
|
||||||
defer dataControlMgr.Destroy()
|
|
||||||
if seat == nil {
|
|
||||||
return fmt.Errorf("no seat available")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
device, err := dataControlMgr.GetDataDevice(seat)
|
device, err := dataControlMgr.GetDataDevice(s.seat)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("get data device: %w", err)
|
return fmt.Errorf("get data device: %w", err)
|
||||||
}
|
}
|
||||||
@@ -255,25 +295,12 @@ func serveClipboard(data []byte, mimeType string, pasteOnce bool) error {
|
|||||||
return fmt.Errorf("create data source: %w", err)
|
return fmt.Errorf("create data source: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := source.Offer(mimeType); err != nil {
|
offerData := make(map[string][]byte, len(offers))
|
||||||
return fmt.Errorf("offer mime type: %w", err)
|
for _, offer := range offers {
|
||||||
}
|
if err := source.Offer(offer.MimeType); err != nil {
|
||||||
if mimeType == "text/plain;charset=utf-8" || mimeType == "text/plain" {
|
return fmt.Errorf("offer %s: %w", offer.MimeType, err)
|
||||||
if err := source.Offer("text/plain"); err != nil {
|
|
||||||
return fmt.Errorf("offer text/plain: %w", err)
|
|
||||||
}
|
|
||||||
if err := source.Offer("text/plain;charset=utf-8"); err != nil {
|
|
||||||
return fmt.Errorf("offer text/plain;charset=utf-8: %w", err)
|
|
||||||
}
|
|
||||||
if err := source.Offer("UTF8_STRING"); err != nil {
|
|
||||||
return fmt.Errorf("offer UTF8_STRING: %w", err)
|
|
||||||
}
|
|
||||||
if err := source.Offer("STRING"); err != nil {
|
|
||||||
return fmt.Errorf("offer STRING: %w", err)
|
|
||||||
}
|
|
||||||
if err := source.Offer("TEXT"); err != nil {
|
|
||||||
return fmt.Errorf("offer TEXT: %w", err)
|
|
||||||
}
|
}
|
||||||
|
offerData[offer.MimeType] = offer.Data
|
||||||
}
|
}
|
||||||
|
|
||||||
cancelled := make(chan struct{})
|
cancelled := make(chan struct{})
|
||||||
@@ -283,7 +310,11 @@ func serveClipboard(data []byte, mimeType string, pasteOnce bool) error {
|
|||||||
_ = syscall.SetNonblock(e.Fd, false)
|
_ = syscall.SetNonblock(e.Fd, false)
|
||||||
file := os.NewFile(uintptr(e.Fd), "pipe")
|
file := os.NewFile(uintptr(e.Fd), "pipe")
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
|
if data, ok := offerData[e.MimeType]; ok {
|
||||||
_, _ = file.Write(data)
|
_, _ = file.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case pasted <- struct{}{}:
|
case pasted <- struct{}{}:
|
||||||
default:
|
default:
|
||||||
@@ -298,7 +329,7 @@ func serveClipboard(data []byte, mimeType string, pasteOnce bool) error {
|
|||||||
return fmt.Errorf("set selection: %w", err)
|
return fmt.Errorf("set selection: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
display.Roundtrip()
|
s.display.Roundtrip()
|
||||||
signalReady()
|
signalReady()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
@@ -310,70 +341,26 @@ func serveClipboard(data []byte, mimeType string, pasteOnce bool) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
if err := ctx.Dispatch(); err != nil {
|
if err := s.ctx.Dispatch(); err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func CopyText(text string) error {
|
|
||||||
return Copy([]byte(text), "text/plain;charset=utf-8")
|
|
||||||
}
|
|
||||||
|
|
||||||
func Paste() ([]byte, string, error) {
|
func Paste() ([]byte, string, error) {
|
||||||
display, err := wlclient.Connect("")
|
s, err := connectSession()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("wayland connect: %w", err)
|
return nil, "", err
|
||||||
}
|
}
|
||||||
defer display.Destroy()
|
defer s.Close()
|
||||||
|
|
||||||
ctx := display.Context()
|
dataControlMgr, err := s.requireDataControl()
|
||||||
registry, err := display.GetRegistry()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("get registry: %w", err)
|
return nil, "", err
|
||||||
}
|
|
||||||
defer registry.Destroy()
|
|
||||||
|
|
||||||
var dataControlMgr *ext_data_control.ExtDataControlManagerV1
|
|
||||||
var seat *wlclient.Seat
|
|
||||||
var bindErr error
|
|
||||||
|
|
||||||
registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
|
|
||||||
switch e.Interface {
|
|
||||||
case "ext_data_control_manager_v1":
|
|
||||||
dataControlMgr = ext_data_control.NewExtDataControlManagerV1(ctx)
|
|
||||||
if err := registry.Bind(e.Name, e.Interface, e.Version, dataControlMgr); err != nil {
|
|
||||||
bindErr = err
|
|
||||||
}
|
|
||||||
case "wl_seat":
|
|
||||||
if seat != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
seat = wlclient.NewSeat(ctx)
|
|
||||||
if err := registry.Bind(e.Name, e.Interface, e.Version, seat); err != nil {
|
|
||||||
bindErr = err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
display.Roundtrip()
|
|
||||||
display.Roundtrip()
|
|
||||||
|
|
||||||
if bindErr != nil {
|
|
||||||
return nil, "", fmt.Errorf("registry bind: %w", bindErr)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if dataControlMgr == nil {
|
device, err := dataControlMgr.GetDataDevice(s.seat)
|
||||||
return nil, "", fmt.Errorf("compositor does not support ext_data_control_manager_v1")
|
|
||||||
}
|
|
||||||
defer dataControlMgr.Destroy()
|
|
||||||
|
|
||||||
if seat == nil {
|
|
||||||
return nil, "", fmt.Errorf("no seat available")
|
|
||||||
}
|
|
||||||
|
|
||||||
device, err := dataControlMgr.GetDataDevice(seat)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("get data device: %w", err)
|
return nil, "", fmt.Errorf("get data device: %w", err)
|
||||||
}
|
}
|
||||||
@@ -399,15 +386,14 @@ func Paste() ([]byte, string, error) {
|
|||||||
gotSelection = true
|
gotSelection = true
|
||||||
})
|
})
|
||||||
|
|
||||||
display.Roundtrip()
|
s.display.Roundtrip()
|
||||||
display.Roundtrip()
|
s.display.Roundtrip()
|
||||||
|
|
||||||
if !gotSelection || selectionOffer == nil {
|
if !gotSelection || selectionOffer == nil {
|
||||||
return nil, "", fmt.Errorf("no clipboard data")
|
return nil, "", fmt.Errorf("no clipboard data")
|
||||||
}
|
}
|
||||||
|
|
||||||
mimeTypes := offerMimeTypes[selectionOffer]
|
selectedMime := selectPreferredMimeType(offerMimeTypes[selectionOffer])
|
||||||
selectedMime := selectPreferredMimeType(mimeTypes)
|
|
||||||
if selectedMime == "" {
|
if selectedMime == "" {
|
||||||
return nil, "", fmt.Errorf("no supported mime type")
|
return nil, "", fmt.Errorf("no supported mime type")
|
||||||
}
|
}
|
||||||
@@ -424,7 +410,7 @@ func Paste() ([]byte, string, error) {
|
|||||||
}
|
}
|
||||||
w.Close()
|
w.Close()
|
||||||
|
|
||||||
display.Roundtrip()
|
s.display.Roundtrip()
|
||||||
|
|
||||||
data, err := io.ReadAll(r)
|
data, err := io.ReadAll(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -470,161 +456,3 @@ func selectPreferredMimeType(mimes []string) string {
|
|||||||
func IsImageMimeType(mime string) bool {
|
func IsImageMimeType(mime string) bool {
|
||||||
return len(mime) > 6 && mime[:6] == "image/"
|
return len(mime) > 6 && mime[:6] == "image/"
|
||||||
}
|
}
|
||||||
|
|
||||||
type Offer struct {
|
|
||||||
MimeType string
|
|
||||||
Data []byte
|
|
||||||
}
|
|
||||||
|
|
||||||
func CopyMulti(offers []Offer, foreground, pasteOnce bool) error {
|
|
||||||
if !foreground {
|
|
||||||
return copyMultiFork(offers, pasteOnce)
|
|
||||||
}
|
|
||||||
return copyMultiServe(offers, pasteOnce)
|
|
||||||
}
|
|
||||||
|
|
||||||
func copyMultiFork(offers []Offer, pasteOnce bool) error {
|
|
||||||
args := []string{os.Args[0], "cl", "copy", "--foreground", "--type", "__multi__"}
|
|
||||||
if pasteOnce {
|
|
||||||
args = append(args, "--paste-once")
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := exec.Command(args[0], args[1:]...)
|
|
||||||
cmd.Stdin = nil
|
|
||||||
cmd.Stdout = nil
|
|
||||||
cmd.Stderr = nil
|
|
||||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
|
||||||
|
|
||||||
stdin, err := cmd.StdinPipe()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("stdin pipe: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := cmd.Start(); err != nil {
|
|
||||||
return fmt.Errorf("start: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, offer := range offers {
|
|
||||||
fmt.Fprintf(stdin, "%s\x00%d\x00", offer.MimeType, len(offer.Data))
|
|
||||||
if _, err := stdin.Write(offer.Data); err != nil {
|
|
||||||
stdin.Close()
|
|
||||||
return fmt.Errorf("write offer data: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
stdin.Close()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func copyMultiServe(offers []Offer, pasteOnce bool) error {
|
|
||||||
display, err := wlclient.Connect("")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("wayland connect: %w", err)
|
|
||||||
}
|
|
||||||
defer display.Destroy()
|
|
||||||
|
|
||||||
ctx := display.Context()
|
|
||||||
registry, err := display.GetRegistry()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("get registry: %w", err)
|
|
||||||
}
|
|
||||||
defer registry.Destroy()
|
|
||||||
|
|
||||||
var dataControlMgr *ext_data_control.ExtDataControlManagerV1
|
|
||||||
var seat *wlclient.Seat
|
|
||||||
var bindErr error
|
|
||||||
|
|
||||||
registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
|
|
||||||
switch e.Interface {
|
|
||||||
case "ext_data_control_manager_v1":
|
|
||||||
dataControlMgr = ext_data_control.NewExtDataControlManagerV1(ctx)
|
|
||||||
if err := registry.Bind(e.Name, e.Interface, e.Version, dataControlMgr); err != nil {
|
|
||||||
bindErr = err
|
|
||||||
}
|
|
||||||
case "wl_seat":
|
|
||||||
if seat != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
seat = wlclient.NewSeat(ctx)
|
|
||||||
if err := registry.Bind(e.Name, e.Interface, e.Version, seat); err != nil {
|
|
||||||
bindErr = err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
display.Roundtrip()
|
|
||||||
display.Roundtrip()
|
|
||||||
|
|
||||||
if bindErr != nil {
|
|
||||||
return fmt.Errorf("registry bind: %w", bindErr)
|
|
||||||
}
|
|
||||||
if dataControlMgr == nil {
|
|
||||||
return fmt.Errorf("compositor does not support ext_data_control_manager_v1")
|
|
||||||
}
|
|
||||||
defer dataControlMgr.Destroy()
|
|
||||||
if seat == nil {
|
|
||||||
return fmt.Errorf("no seat available")
|
|
||||||
}
|
|
||||||
|
|
||||||
device, err := dataControlMgr.GetDataDevice(seat)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("get data device: %w", err)
|
|
||||||
}
|
|
||||||
defer device.Destroy()
|
|
||||||
|
|
||||||
source, err := dataControlMgr.CreateDataSource()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("create data source: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
offerMap := make(map[string][]byte)
|
|
||||||
for _, offer := range offers {
|
|
||||||
if err := source.Offer(offer.MimeType); err != nil {
|
|
||||||
return fmt.Errorf("offer %s: %w", offer.MimeType, err)
|
|
||||||
}
|
|
||||||
offerMap[offer.MimeType] = offer.Data
|
|
||||||
}
|
|
||||||
|
|
||||||
cancelled := make(chan struct{})
|
|
||||||
pasted := make(chan struct{}, 1)
|
|
||||||
|
|
||||||
source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) {
|
|
||||||
_ = syscall.SetNonblock(e.Fd, false)
|
|
||||||
file := os.NewFile(uintptr(e.Fd), "pipe")
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
if data, ok := offerMap[e.MimeType]; ok {
|
|
||||||
_, _ = file.Write(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
case pasted <- struct{}{}:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
source.SetCancelledHandler(func(e ext_data_control.ExtDataControlSourceV1CancelledEvent) {
|
|
||||||
close(cancelled)
|
|
||||||
})
|
|
||||||
|
|
||||||
if err := device.SetSelection(source); err != nil {
|
|
||||||
return fmt.Errorf("set selection: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
display.Roundtrip()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-cancelled:
|
|
||||||
return nil
|
|
||||||
case <-pasted:
|
|
||||||
if pasteOnce {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
if err := ctx.Dispatch(); err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package clipboard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
xkbKeymapFormatV1 = 1
|
||||||
|
|
||||||
|
keyStateReleased = 0
|
||||||
|
keyStatePressed = 1
|
||||||
|
|
||||||
|
// xkb real modifier bit positions are fixed: Shift=0, Lock=1, Control=2
|
||||||
|
shiftModMask = 1 << 0
|
||||||
|
ctrlModMask = 1 << 2
|
||||||
|
|
||||||
|
// evdev fallbacks for a standard pc105 map
|
||||||
|
fallbackCtrlKey = 29 // KEY_LEFTCTRL
|
||||||
|
fallbackShiftKey = 42 // KEY_LEFTSHIFT
|
||||||
|
fallbackVKey = 47 // KEY_V
|
||||||
|
)
|
||||||
|
|
||||||
|
// SendPasteKeystroke emulates a paste shortcut via zwp_virtual_keyboard_v1
|
||||||
|
// using the seat's own keymap, so keycodes stay valid for XWayland clients
|
||||||
|
// (a synthetic wtype-style keymap breaks X11 apps like Steam). withShift
|
||||||
|
// selects ctrl+shift+v for terminal targets.
|
||||||
|
func SendPasteKeystroke(withShift bool) error {
|
||||||
|
s, err := connectSession()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
if s.virtualKeyboardMgr == nil {
|
||||||
|
return fmt.Errorf("compositor does not support zwp_virtual_keyboard_manager_v1")
|
||||||
|
}
|
||||||
|
if s.seat == nil {
|
||||||
|
return fmt.Errorf("no seat available")
|
||||||
|
}
|
||||||
|
|
||||||
|
keyboard, err := s.seat.GetKeyboard()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get keyboard: %w", err)
|
||||||
|
}
|
||||||
|
defer keyboard.Release()
|
||||||
|
|
||||||
|
var keymap *wlclient.KeyboardKeymapEvent
|
||||||
|
keyboard.SetKeymapHandler(func(e wlclient.KeyboardKeymapEvent) {
|
||||||
|
if keymap == nil {
|
||||||
|
keymap = &e
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
s.display.Roundtrip()
|
||||||
|
|
||||||
|
if keymap == nil || keymap.Format != xkbKeymapFormatV1 {
|
||||||
|
return fmt.Errorf("no xkb keymap from seat")
|
||||||
|
}
|
||||||
|
defer unix.Close(keymap.Fd)
|
||||||
|
|
||||||
|
keymapText, err := readKeymap(keymap.Fd, keymap.Size)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read keymap: %w", err)
|
||||||
|
}
|
||||||
|
keys := resolveKeycodes(keymapText)
|
||||||
|
|
||||||
|
vk, err := s.virtualKeyboardMgr.CreateVirtualKeyboard(s.seat)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create virtual keyboard: %w", err)
|
||||||
|
}
|
||||||
|
defer vk.Destroy()
|
||||||
|
|
||||||
|
if err := vk.Keymap(xkbKeymapFormatV1, keymap.Fd, keymap.Size); err != nil {
|
||||||
|
return fmt.Errorf("set keymap: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mods := uint32(ctrlModMask)
|
||||||
|
held := []uint32{keys.ctrl}
|
||||||
|
if withShift {
|
||||||
|
mods |= shiftModMask
|
||||||
|
held = append(held, keys.shift)
|
||||||
|
}
|
||||||
|
|
||||||
|
t := uint32(0)
|
||||||
|
press := func(key, state uint32) error {
|
||||||
|
t++
|
||||||
|
return vk.Key(t, key, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range held {
|
||||||
|
if err := press(key, keyStatePressed); err != nil {
|
||||||
|
return fmt.Errorf("key press: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := vk.Modifiers(mods, 0, 0, 0); err != nil {
|
||||||
|
return fmt.Errorf("set modifiers: %w", err)
|
||||||
|
}
|
||||||
|
if err := press(keys.v, keyStatePressed); err != nil {
|
||||||
|
return fmt.Errorf("key press: %w", err)
|
||||||
|
}
|
||||||
|
if err := press(keys.v, keyStateReleased); err != nil {
|
||||||
|
return fmt.Errorf("key release: %w", err)
|
||||||
|
}
|
||||||
|
for i := len(held) - 1; i >= 0; i-- {
|
||||||
|
if err := press(held[i], keyStateReleased); err != nil {
|
||||||
|
return fmt.Errorf("key release: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := vk.Modifiers(0, 0, 0, 0); err != nil {
|
||||||
|
return fmt.Errorf("clear modifiers: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.display.Roundtrip()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readKeymap(fd int, size uint32) (string, error) {
|
||||||
|
data, err := unix.Mmap(fd, 0, int(size), unix.PROT_READ, unix.MAP_PRIVATE)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
text := strings.TrimRight(string(data), "\x00")
|
||||||
|
return text, unix.Munmap(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
type pasteKeycodes struct {
|
||||||
|
ctrl uint32
|
||||||
|
shift uint32
|
||||||
|
v uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
keycodeDefRe = regexp.MustCompile(`<([A-Za-z0-9+_-]+)>\s*=\s*(\d+)`)
|
||||||
|
keySymbolsRe = regexp.MustCompile(`key\s*<([A-Za-z0-9+_-]+)>\s*\{([^}]*)\}`)
|
||||||
|
groupIndexRe = regexp.MustCompile(`\w+\[\d+\]\s*=`)
|
||||||
|
symbolListRe = regexp.MustCompile(`\[([^\]]*)\]`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// xkbcommon may serialize keysyms as hex escapes instead of names
|
||||||
|
// (e.g. "0x76" for v, "0xffe3" for Control_L).
|
||||||
|
var keysymNames = map[uint32]string{
|
||||||
|
0x76: "v",
|
||||||
|
0xffe3: "Control_L",
|
||||||
|
0xffe1: "Shift_L",
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalKeysym(sym string) string {
|
||||||
|
if !strings.HasPrefix(sym, "0x") && !strings.HasPrefix(sym, "0X") {
|
||||||
|
return sym
|
||||||
|
}
|
||||||
|
value, err := strconv.ParseUint(sym[2:], 16, 32)
|
||||||
|
if err != nil {
|
||||||
|
return sym
|
||||||
|
}
|
||||||
|
if name, ok := keysymNames[uint32(value)]; ok {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
return sym
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveKeycodes finds the evdev keycodes producing the keysyms we need in
|
||||||
|
// the seat keymap's first group, falling back to pc105 positions.
|
||||||
|
func resolveKeycodes(keymap string) pasteKeycodes {
|
||||||
|
codes := map[string]uint32{}
|
||||||
|
for _, m := range keycodeDefRe.FindAllStringSubmatch(keymap, -1) {
|
||||||
|
if code, err := strconv.Atoi(m[2]); err == nil {
|
||||||
|
codes[m[1]] = uint32(code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keys := pasteKeycodes{ctrl: fallbackCtrlKey, shift: fallbackShiftKey, v: fallbackVKey}
|
||||||
|
want := map[string]*uint32{
|
||||||
|
"Control_L": &keys.ctrl,
|
||||||
|
"Shift_L": &keys.shift,
|
||||||
|
"v": &keys.v,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range keySymbolsRe.FindAllStringSubmatch(keymap, -1) {
|
||||||
|
group := symbolListRe.FindStringSubmatch(groupIndexRe.ReplaceAllString(m[2], ""))
|
||||||
|
if group == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
xkbCode, ok := codes[m[1]]
|
||||||
|
if !ok || xkbCode < 8 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
level1 := canonicalKeysym(strings.TrimSpace(strings.Split(group[1], ",")[0]))
|
||||||
|
target, wanted := want[level1]
|
||||||
|
if !wanted {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
*target = xkbCode - 8
|
||||||
|
delete(want, level1)
|
||||||
|
if len(want) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return keys
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package clipboard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLiveSeatKeymapResolution(t *testing.T) {
|
||||||
|
if os.Getenv("DMS_LIVE_TEST") == "" {
|
||||||
|
t.Skip("set DMS_LIVE_TEST=1 to run against the live compositor")
|
||||||
|
}
|
||||||
|
|
||||||
|
s, err := connectSession()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connect: %v", err)
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
if s.virtualKeyboardMgr == nil {
|
||||||
|
t.Fatal("compositor does not advertise zwp_virtual_keyboard_manager_v1")
|
||||||
|
}
|
||||||
|
if s.seat == nil {
|
||||||
|
t.Fatal("no seat")
|
||||||
|
}
|
||||||
|
|
||||||
|
keyboard, err := s.seat.GetKeyboard()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get keyboard: %v", err)
|
||||||
|
}
|
||||||
|
defer keyboard.Release()
|
||||||
|
|
||||||
|
var keymap *wlclient.KeyboardKeymapEvent
|
||||||
|
keyboard.SetKeymapHandler(func(e wlclient.KeyboardKeymapEvent) {
|
||||||
|
if keymap == nil {
|
||||||
|
keymap = &e
|
||||||
|
}
|
||||||
|
})
|
||||||
|
s.display.Roundtrip()
|
||||||
|
|
||||||
|
if keymap == nil {
|
||||||
|
t.Fatal("no keymap event")
|
||||||
|
}
|
||||||
|
defer unix.Close(keymap.Fd)
|
||||||
|
|
||||||
|
text, err := readKeymap(keymap.Fd, keymap.Size)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read keymap: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if dump := os.Getenv("DMS_LIVE_DUMP"); dump != "" {
|
||||||
|
if err := os.WriteFile(dump, []byte(text), 0o644); err != nil {
|
||||||
|
t.Fatalf("dump keymap: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keys := resolveKeycodes(text)
|
||||||
|
t.Logf("keymap size=%d resolved ctrl=%d shift=%d v=%d", keymap.Size, keys.ctrl, keys.shift, keys.v)
|
||||||
|
|
||||||
|
if keys.ctrl == fallbackCtrlKey && keys.shift == fallbackShiftKey && keys.v == fallbackVKey {
|
||||||
|
t.Log("all keycodes are fallbacks - parsing may not have matched the live keymap")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package clipboard
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
const qwertyKeymap = `xkb_keymap {
|
||||||
|
xkb_keycodes "(unnamed)" {
|
||||||
|
minimum = 8;
|
||||||
|
maximum = 708;
|
||||||
|
<ESC> = 9;
|
||||||
|
<AB04> = 55;
|
||||||
|
<LCTL> = 37;
|
||||||
|
<LFSH> = 50;
|
||||||
|
alias <AL01> = <AC01>;
|
||||||
|
indicator 1 = "Caps Lock";
|
||||||
|
};
|
||||||
|
xkb_types "(unnamed)" {
|
||||||
|
type "ALPHABETIC" {
|
||||||
|
modifiers = Shift+Lock;
|
||||||
|
map[Shift] = Level2;
|
||||||
|
level_name[Level1] = "Base";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
xkb_symbols "(unnamed)" {
|
||||||
|
key <ESC> { [ Escape ] };
|
||||||
|
key <AB04> { type= "ALPHABETIC", [ v, V ] };
|
||||||
|
key <LCTL> { [ Control_L ] };
|
||||||
|
key <LFSH> { [ Shift_L ] };
|
||||||
|
};
|
||||||
|
};`
|
||||||
|
|
||||||
|
const hexKeymap = `xkb_keymap {
|
||||||
|
xkb_keycodes "(unnamed)" {
|
||||||
|
<AB04> = 56;
|
||||||
|
<LCTL> = 38;
|
||||||
|
<LFSH> = 51;
|
||||||
|
};
|
||||||
|
xkb_symbols "(unnamed)" {
|
||||||
|
key <LCTL> { [ 0xffe3 ] };
|
||||||
|
key <LFSH> {
|
||||||
|
type= "PC_ALT_LEVEL2",
|
||||||
|
symbols[1]= [ 0xffe1, 0xfe08 ]
|
||||||
|
};
|
||||||
|
key <AB04> { [ 0x76, 0x56 ] };
|
||||||
|
};
|
||||||
|
};`
|
||||||
|
|
||||||
|
const dvorakKeymap = `xkb_keymap {
|
||||||
|
xkb_keycodes "(unnamed)" {
|
||||||
|
<AB09> = 60;
|
||||||
|
<LCTL> = 37;
|
||||||
|
<LFSH> = 50;
|
||||||
|
};
|
||||||
|
xkb_symbols "(unnamed)" {
|
||||||
|
key <AB09> { [ v, V ] };
|
||||||
|
key <LCTL> { [ Control_L ] };
|
||||||
|
key <LFSH> { [ Shift_L ] };
|
||||||
|
};
|
||||||
|
};`
|
||||||
|
|
||||||
|
func TestResolveKeycodes(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
keymap string
|
||||||
|
want pasteKeycodes
|
||||||
|
}{
|
||||||
|
{"qwerty", qwertyKeymap, pasteKeycodes{ctrl: 29, shift: 42, v: 47}},
|
||||||
|
{"dvorak", dvorakKeymap, pasteKeycodes{ctrl: 29, shift: 42, v: 52}},
|
||||||
|
{"hex keysyms", hexKeymap, pasteKeycodes{ctrl: 30, shift: 43, v: 48}},
|
||||||
|
{"empty falls back", "", pasteKeycodes{ctrl: fallbackCtrlKey, shift: fallbackShiftKey, v: fallbackVKey}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := resolveKeycodes(tt.keymap)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("resolveKeycodes() = %+v, want %+v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpandOffers(t *testing.T) {
|
||||||
|
text := ExpandOffers([]byte("hi"), "text/plain;charset=utf-8")
|
||||||
|
if len(text) != 5 {
|
||||||
|
t.Fatalf("expected 5 text offers, got %d", len(text))
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, o := range text {
|
||||||
|
if string(o.Data) != "hi" {
|
||||||
|
t.Errorf("offer %s has wrong data", o.MimeType)
|
||||||
|
}
|
||||||
|
if seen[o.MimeType] {
|
||||||
|
t.Errorf("duplicate offer %s", o.MimeType)
|
||||||
|
}
|
||||||
|
seen[o.MimeType] = true
|
||||||
|
}
|
||||||
|
if !seen["UTF8_STRING"] || !seen["STRING"] || !seen["TEXT"] || !seen["text/plain"] {
|
||||||
|
t.Errorf("missing X11 alias offers: %v", seen)
|
||||||
|
}
|
||||||
|
|
||||||
|
img := ExpandOffers([]byte{1}, "image/png")
|
||||||
|
if len(img) != 1 || img[0].MimeType != "image/png" {
|
||||||
|
t.Errorf("non-text mime should not expand, got %+v", img)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package clipboard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/virtual_keyboard"
|
||||||
|
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||||
|
)
|
||||||
|
|
||||||
|
type session struct {
|
||||||
|
display *wlclient.Display
|
||||||
|
ctx *wlclient.Context
|
||||||
|
registry *wlclient.Registry
|
||||||
|
seat *wlclient.Seat
|
||||||
|
dataControlMgr *ext_data_control.ExtDataControlManagerV1
|
||||||
|
virtualKeyboardMgr *virtual_keyboard.ZwpVirtualKeyboardManagerV1
|
||||||
|
}
|
||||||
|
|
||||||
|
// connectSession opens a short-lived Wayland connection and binds the seat
|
||||||
|
// plus whichever clipboard-related globals the compositor advertises.
|
||||||
|
func connectSession() (*session, error) {
|
||||||
|
display, err := wlclient.Connect("")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("wayland connect: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s := &session{display: display, ctx: display.Context()}
|
||||||
|
|
||||||
|
registry, err := display.GetRegistry()
|
||||||
|
if err != nil {
|
||||||
|
display.Destroy()
|
||||||
|
return nil, fmt.Errorf("get registry: %w", err)
|
||||||
|
}
|
||||||
|
s.registry = registry
|
||||||
|
|
||||||
|
var bindErr error
|
||||||
|
bind := func(name uint32, iface string, version uint32, proxy wlclient.Proxy) {
|
||||||
|
if err := registry.Bind(name, iface, version, proxy); err != nil {
|
||||||
|
bindErr = fmt.Errorf("bind %s: %w", iface, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
|
||||||
|
switch e.Interface {
|
||||||
|
case ext_data_control.ExtDataControlManagerV1InterfaceName:
|
||||||
|
mgr := ext_data_control.NewExtDataControlManagerV1(s.ctx)
|
||||||
|
bind(e.Name, e.Interface, e.Version, mgr)
|
||||||
|
s.dataControlMgr = mgr
|
||||||
|
case virtual_keyboard.ZwpVirtualKeyboardManagerV1InterfaceName:
|
||||||
|
mgr := virtual_keyboard.NewZwpVirtualKeyboardManagerV1(s.ctx)
|
||||||
|
bind(e.Name, e.Interface, e.Version, mgr)
|
||||||
|
s.virtualKeyboardMgr = mgr
|
||||||
|
case "wl_seat":
|
||||||
|
if s.seat != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seat := wlclient.NewSeat(s.ctx)
|
||||||
|
bind(e.Name, e.Interface, e.Version, seat)
|
||||||
|
s.seat = seat
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
display.Roundtrip()
|
||||||
|
display.Roundtrip()
|
||||||
|
|
||||||
|
if bindErr != nil {
|
||||||
|
s.Close()
|
||||||
|
return nil, bindErr
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *session) requireDataControl() (*ext_data_control.ExtDataControlManagerV1, error) {
|
||||||
|
switch {
|
||||||
|
case s.dataControlMgr == nil:
|
||||||
|
return nil, fmt.Errorf("compositor does not support ext_data_control_manager_v1")
|
||||||
|
case s.seat == nil:
|
||||||
|
return nil, fmt.Errorf("no seat available")
|
||||||
|
default:
|
||||||
|
return s.dataControlMgr, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *session) Close() {
|
||||||
|
if s.dataControlMgr != nil {
|
||||||
|
s.dataControlMgr.Destroy()
|
||||||
|
}
|
||||||
|
if s.registry != nil {
|
||||||
|
s.registry.Destroy()
|
||||||
|
}
|
||||||
|
s.display.Destroy()
|
||||||
|
}
|
||||||
@@ -61,6 +61,10 @@ func (cd *ConfigDeployer) DeployConfigurationsSelectiveWithReinstalls(ctx contex
|
|||||||
return cd.deployConfigurationsInternal(ctx, wm, terminal, installedDeps, replaceConfigs, reinstallItems, true)
|
return cd.deployConfigurationsInternal(ctx, wm, terminal, installedDeps, replaceConfigs, reinstallItems, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (cd *ConfigDeployer) DeployConfigurationsSelectiveWithReinstallsAndSystemd(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal, installedDeps []deps.Dependency, replaceConfigs map[string]bool, reinstallItems map[string]bool, useSystemd bool) ([]DeploymentResult, error) {
|
||||||
|
return cd.deployConfigurationsInternal(ctx, wm, terminal, installedDeps, replaceConfigs, reinstallItems, useSystemd)
|
||||||
|
}
|
||||||
|
|
||||||
func (cd *ConfigDeployer) deployConfigurationsInternal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal, installedDeps []deps.Dependency, replaceConfigs map[string]bool, reinstallItems map[string]bool, useSystemd bool) ([]DeploymentResult, error) {
|
func (cd *ConfigDeployer) deployConfigurationsInternal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal, installedDeps []deps.Dependency, replaceConfigs map[string]bool, reinstallItems map[string]bool, useSystemd bool) ([]DeploymentResult, error) {
|
||||||
var results []DeploymentResult
|
var results []DeploymentResult
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ mouse-hide-while-typing = true
|
|||||||
copy-on-select = false
|
copy-on-select = false
|
||||||
confirm-close-surface = false
|
confirm-close-surface = false
|
||||||
|
|
||||||
# Disable annoying copied to clipboard
|
# Disable in-app Ghostty toast notifications
|
||||||
app-notifications = no-clipboard-copy,no-config-reload
|
app-notifications = false
|
||||||
|
|
||||||
# Key bindings for common actions
|
# Key bindings for common actions
|
||||||
#keybind = ctrl+c=copy_to_clipboard
|
#keybind = ctrl+c=copy_to_clipboard
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ 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 + 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 + 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 + 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 + Y", hl.dsp.exec_cmd("dms ipc call dash toggle wallpaper"))
|
||||||
hl.bind("SUPER + TAB", hl.dsp.exec_cmd("dms ipc call hypr toggleOverview"))
|
hl.bind("SUPER + TAB", hl.dsp.exec_cmd("dms ipc call hypr toggleOverview"))
|
||||||
hl.bind("SUPER + O", hl.dsp.exec_cmd("dms ipc call hypr toggleOverview"))
|
hl.bind("SUPER + O", hl.dsp.exec_cmd("dms ipc call hypr toggleOverview"))
|
||||||
hl.bind("SUPER + X", hl.dsp.exec_cmd("dms ipc call powermenu toggle"))
|
hl.bind("SUPER + X", hl.dsp.exec_cmd("dms ipc call powermenu toggle"))
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ end)
|
|||||||
|
|
||||||
hl.config({
|
hl.config({
|
||||||
input = {
|
input = {
|
||||||
kb_layout = "us",
|
-- empty inherits XKB_DEFAULT_LAYOUT (libxkbcommon), falls back to "us"
|
||||||
|
kb_layout = "",
|
||||||
numlock_by_default = true,
|
numlock_by_default = true,
|
||||||
|
follow_mouse = 0,
|
||||||
touchpad = {
|
touchpad = {
|
||||||
tap_to_click = true,
|
tap_to_click = true,
|
||||||
natural_scroll = true,
|
natural_scroll = true,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ bind=SUPER,n,spawn,dms ipc call notifications toggle
|
|||||||
# Notepad
|
# Notepad
|
||||||
bind=SUPER+SHIFT,n,spawn,dms ipc call notepad toggle
|
bind=SUPER+SHIFT,n,spawn,dms ipc call notepad toggle
|
||||||
# Browse Wallpapers
|
# Browse Wallpapers
|
||||||
bind=SUPER,y,spawn,dms ipc call dankdash wallpaper
|
bind=SUPER,y,spawn,dms ipc call dash toggle wallpaper
|
||||||
# Power Menu
|
# Power Menu
|
||||||
bind=SUPER,x,spawn,dms ipc call powermenu toggle
|
bind=SUPER,x,spawn,dms ipc call powermenu toggle
|
||||||
# Cycle Display Profile
|
# Cycle Display Profile
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ binds {
|
|||||||
spawn "dms" "ipc" "call" "settings" "focusOrToggle";
|
spawn "dms" "ipc" "call" "settings" "focusOrToggle";
|
||||||
}
|
}
|
||||||
Mod+Y hotkey-overlay-title="Browse Wallpapers" {
|
Mod+Y hotkey-overlay-title="Browse Wallpapers" {
|
||||||
spawn "dms" "ipc" "call" "dankdash" "wallpaper";
|
spawn "dms" "ipc" "call" "dash" "toggle" "wallpaper";
|
||||||
}
|
}
|
||||||
Mod+N hotkey-overlay-title="Notification Center" { spawn "dms" "ipc" "call" "notifications" "toggle"; }
|
Mod+N hotkey-overlay-title="Notification Center" { spawn "dms" "ipc" "call" "notifications" "toggle"; }
|
||||||
Mod+Shift+N hotkey-overlay-title="Notepad" { spawn "dms" "ipc" "call" "notepad" "toggle"; }
|
Mod+Shift+N hotkey-overlay-title="Notepad" { spawn "dms" "ipc" "call" "notepad" "toggle"; }
|
||||||
|
|||||||
@@ -20,3 +20,10 @@ window-rule {
|
|||||||
tiled-state true
|
tiled-state true
|
||||||
draw-border-with-background false
|
draw-border-with-background false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
layer-rule {
|
||||||
|
exclude namespace="^dms:bar$"
|
||||||
|
background-effect {
|
||||||
|
xray false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -265,9 +265,9 @@ recent-windows {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Include dms files
|
// Include dms files
|
||||||
include "dms/colors.kdl"
|
include optional=true "dms/colors.kdl"
|
||||||
include "dms/layout.kdl"
|
include optional=true "dms/layout.kdl"
|
||||||
include "dms/alttab.kdl"
|
include optional=true "dms/alttab.kdl"
|
||||||
include "dms/binds.kdl"
|
include optional=true "dms/binds.kdl"
|
||||||
include "dms/outputs.kdl"
|
include optional=true "dms/outputs.kdl"
|
||||||
include "dms/cursor.kdl"
|
include optional=true "dms/cursor.kdl"
|
||||||
|
|||||||
@@ -133,6 +133,11 @@ func mergedAssociations() *MimeAssociations {
|
|||||||
return merged
|
return merged
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isSafeIniField rejects values that would corrupt a key=value line in mimeapps.list
|
||||||
|
func isSafeIniField(s string) bool {
|
||||||
|
return !strings.ContainsAny(s, "\n\r[]")
|
||||||
|
}
|
||||||
|
|
||||||
func writeUserMimeapps(update func(*MimeAssociations)) error {
|
func writeUserMimeapps(update func(*MimeAssociations)) error {
|
||||||
mimeappsWriteMu.Lock()
|
mimeappsWriteMu.Lock()
|
||||||
defer mimeappsWriteMu.Unlock()
|
defer mimeappsWriteMu.Unlock()
|
||||||
@@ -152,6 +157,7 @@ func writeUserMimeapps(update func(*MimeAssociations)) error {
|
|||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
w := bufio.NewWriter(&buf)
|
w := bufio.NewWriter(&buf)
|
||||||
|
|
||||||
|
var writeErr error
|
||||||
writeSection := func(name string, entries map[string]string) {
|
writeSection := func(name string, entries map[string]string) {
|
||||||
fmt.Fprintf(w, "[%s]\n", name)
|
fmt.Fprintf(w, "[%s]\n", name)
|
||||||
keys := make([]string, 0, len(entries))
|
keys := make([]string, 0, len(entries))
|
||||||
@@ -160,7 +166,14 @@ func writeUserMimeapps(update func(*MimeAssociations)) error {
|
|||||||
}
|
}
|
||||||
sort.Strings(keys)
|
sort.Strings(keys)
|
||||||
for _, k := range keys {
|
for _, k := range keys {
|
||||||
fmt.Fprintf(w, "%s=%s\n", k, entries[k])
|
v := entries[k]
|
||||||
|
if !isSafeIniField(k) || !isSafeIniField(v) {
|
||||||
|
if writeErr == nil {
|
||||||
|
writeErr = fmt.Errorf("invalid mimeapps.list field %q=%q", k, v)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "%s=%s\n", k, v)
|
||||||
}
|
}
|
||||||
fmt.Fprintln(w)
|
fmt.Fprintln(w)
|
||||||
}
|
}
|
||||||
@@ -177,6 +190,10 @@ func writeUserMimeapps(update func(*MimeAssociations)) error {
|
|||||||
writeSection(groupAdded, flatten(assoc.Added))
|
writeSection(groupAdded, flatten(assoc.Added))
|
||||||
writeSection(groupRemoved, flatten(assoc.Removed))
|
writeSection(groupRemoved, flatten(assoc.Removed))
|
||||||
|
|
||||||
|
if writeErr != nil {
|
||||||
|
return writeErr
|
||||||
|
}
|
||||||
|
|
||||||
if err := w.Flush(); err != nil {
|
if err := w.Flush(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,10 +119,30 @@ func (a *ArchDistribution) DetectDependenciesWithTerminal(ctx context.Context, w
|
|||||||
|
|
||||||
dependencies = append(dependencies, a.detectMatugen())
|
dependencies = append(dependencies, a.detectMatugen())
|
||||||
dependencies = append(dependencies, a.detectDgop())
|
dependencies = append(dependencies, a.detectDgop())
|
||||||
|
dependencies = append(dependencies, a.detectDanksearch())
|
||||||
|
dependencies = append(dependencies, a.detectDankCalendar())
|
||||||
|
|
||||||
return dependencies, nil
|
return dependencies, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *ArchDistribution) detectDanksearch() deps.Dependency {
|
||||||
|
dep := a.BaseDistribution.detectDanksearch()
|
||||||
|
dep.CanToggle = true
|
||||||
|
if a.packageInstalled("dsearch-git") {
|
||||||
|
dep.Variant = deps.VariantGit
|
||||||
|
}
|
||||||
|
return dep
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ArchDistribution) detectDankCalendar() deps.Dependency {
|
||||||
|
dep := a.BaseDistribution.detectDankCalendar()
|
||||||
|
dep.CanToggle = true
|
||||||
|
if a.packageInstalled("dankcalendar-git") {
|
||||||
|
dep.Variant = deps.VariantGit
|
||||||
|
}
|
||||||
|
return dep
|
||||||
|
}
|
||||||
|
|
||||||
func (a *ArchDistribution) detectXDGPortal() deps.Dependency {
|
func (a *ArchDistribution) detectXDGPortal() deps.Dependency {
|
||||||
return a.detectPackage("xdg-desktop-portal-gtk", "Desktop integration portal for GTK", a.packageInstalled("xdg-desktop-portal-gtk"))
|
return a.detectPackage("xdg-desktop-portal-gtk", "Desktop integration portal for GTK", a.packageInstalled("xdg-desktop-portal-gtk"))
|
||||||
}
|
}
|
||||||
@@ -132,7 +152,13 @@ func (a *ArchDistribution) detectAccountsService() deps.Dependency {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *ArchDistribution) detectDMSGreeter() deps.Dependency {
|
func (a *ArchDistribution) detectDMSGreeter() deps.Dependency {
|
||||||
return a.detectOptionalPackage("dms-greeter", "DankMaterialShell greetd greeter", a.packageInstalled("greetd-dms-greeter-git"))
|
installed := a.packageInstalled("greetd-dms-greeter-git") || a.packageInstalled("greetd-dms-greeter-bin")
|
||||||
|
dep := a.detectOptionalPackage("dms-greeter", "DankMaterialShell greetd greeter", installed)
|
||||||
|
dep.CanToggle = true
|
||||||
|
if a.packageInstalled("greetd-dms-greeter-git") {
|
||||||
|
dep.Variant = deps.VariantGit
|
||||||
|
}
|
||||||
|
return dep
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *ArchDistribution) packageInstalled(pkg string) bool {
|
func (a *ArchDistribution) packageInstalled(pkg string) bool {
|
||||||
@@ -191,7 +217,7 @@ func (a *ArchDistribution) GetPackageMappingWithVariants(wm deps.WindowManager,
|
|||||||
"dms (DankMaterialShell)": a.getDMSMapping(variants["dms (DankMaterialShell)"]),
|
"dms (DankMaterialShell)": a.getDMSMapping(variants["dms (DankMaterialShell)"]),
|
||||||
"git": {Name: "git", Repository: RepoTypeSystem},
|
"git": {Name: "git", Repository: RepoTypeSystem},
|
||||||
"quickshell": a.getQuickshellMapping(variants["quickshell"]),
|
"quickshell": a.getQuickshellMapping(variants["quickshell"]),
|
||||||
"dms-greeter": {Name: "greetd-dms-greeter-git", Repository: RepoTypeAUR},
|
"dms-greeter": a.getDMSGreeterMapping(variants["dms-greeter"]),
|
||||||
"matugen": a.getMatugenMapping(variants["matugen"]),
|
"matugen": a.getMatugenMapping(variants["matugen"]),
|
||||||
"dgop": {Name: "dgop", Repository: RepoTypeSystem},
|
"dgop": {Name: "dgop", Repository: RepoTypeSystem},
|
||||||
"ghostty": {Name: "ghostty", Repository: RepoTypeSystem},
|
"ghostty": {Name: "ghostty", Repository: RepoTypeSystem},
|
||||||
@@ -199,6 +225,8 @@ func (a *ArchDistribution) GetPackageMappingWithVariants(wm deps.WindowManager,
|
|||||||
"alacritty": {Name: "alacritty", Repository: RepoTypeSystem},
|
"alacritty": {Name: "alacritty", Repository: RepoTypeSystem},
|
||||||
"xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem},
|
"xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem},
|
||||||
"accountsservice": {Name: "accountsservice", Repository: RepoTypeSystem},
|
"accountsservice": {Name: "accountsservice", Repository: RepoTypeSystem},
|
||||||
|
"danksearch": a.getDanksearchMapping(variants["danksearch"]),
|
||||||
|
"dankcalendar": a.getDankCalendarMapping(variants["dankcalendar"]),
|
||||||
}
|
}
|
||||||
|
|
||||||
switch wm {
|
switch wm {
|
||||||
@@ -253,6 +281,27 @@ func (a *ArchDistribution) getMatugenMapping(variant deps.PackageVariant) Packag
|
|||||||
return PackageMapping{Name: "matugen", Repository: RepoTypeSystem}
|
return PackageMapping{Name: "matugen", Repository: RepoTypeSystem}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *ArchDistribution) getDanksearchMapping(variant deps.PackageVariant) PackageMapping {
|
||||||
|
if variant == deps.VariantGit {
|
||||||
|
return PackageMapping{Name: "dsearch-git", Repository: RepoTypeAUR}
|
||||||
|
}
|
||||||
|
return PackageMapping{Name: "dsearch-bin", Repository: RepoTypeAUR}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ArchDistribution) getDankCalendarMapping(variant deps.PackageVariant) PackageMapping {
|
||||||
|
if variant == deps.VariantGit {
|
||||||
|
return PackageMapping{Name: "dankcalendar-git", Repository: RepoTypeAUR}
|
||||||
|
}
|
||||||
|
return PackageMapping{Name: "dankcalendar-bin", Repository: RepoTypeAUR}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ArchDistribution) getDMSGreeterMapping(variant deps.PackageVariant) PackageMapping {
|
||||||
|
if variant == deps.VariantGit {
|
||||||
|
return PackageMapping{Name: "greetd-dms-greeter-git", Repository: RepoTypeAUR}
|
||||||
|
}
|
||||||
|
return PackageMapping{Name: "greetd-dms-greeter-bin", Repository: RepoTypeAUR}
|
||||||
|
}
|
||||||
|
|
||||||
func (a *ArchDistribution) getDMSMapping(variant deps.PackageVariant) PackageMapping {
|
func (a *ArchDistribution) getDMSMapping(variant deps.PackageVariant) PackageMapping {
|
||||||
if forceDMSGit || variant == deps.VariantGit {
|
if forceDMSGit || variant == deps.VariantGit {
|
||||||
return PackageMapping{Name: "dms-shell-git", Repository: RepoTypeAUR}
|
return PackageMapping{Name: "dms-shell-git", Repository: RepoTypeAUR}
|
||||||
|
|||||||
@@ -107,6 +107,14 @@ func (b *BaseDistribution) detectDgop() deps.Dependency {
|
|||||||
return b.detectCommand("dgop", "Desktop portal management tool")
|
return b.detectCommand("dgop", "Desktop portal management tool")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *BaseDistribution) detectDanksearch() deps.Dependency {
|
||||||
|
return b.detectOptionalPackage("danksearch", "File indexing and search service", b.commandExists("dsearch") || b.commandExists("danksearch"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BaseDistribution) detectDankCalendar() deps.Dependency {
|
||||||
|
return b.detectOptionalPackage("dankcalendar", "Calendar application", b.commandExists("dcal") || b.commandExists("dankcalendar"))
|
||||||
|
}
|
||||||
|
|
||||||
func (b *BaseDistribution) detectDMS() deps.Dependency {
|
func (b *BaseDistribution) detectDMS() deps.Dependency {
|
||||||
dmsPath := filepath.Join(os.Getenv("HOME"), ".config/quickshell/dms")
|
dmsPath := filepath.Join(os.Getenv("HOME"), ".config/quickshell/dms")
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ func (d *DebianDistribution) DetectDependenciesWithTerminal(ctx context.Context,
|
|||||||
|
|
||||||
dependencies = append(dependencies, d.detectMatugen())
|
dependencies = append(dependencies, d.detectMatugen())
|
||||||
dependencies = append(dependencies, d.detectDgop())
|
dependencies = append(dependencies, d.detectDgop())
|
||||||
|
dependencies = append(dependencies, d.detectDanksearch())
|
||||||
|
dependencies = append(dependencies, d.detectDankCalendar())
|
||||||
|
|
||||||
return dependencies, nil
|
return dependencies, nil
|
||||||
}
|
}
|
||||||
@@ -135,6 +137,8 @@ func (d *DebianDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
|
|||||||
"matugen": {Name: "matugen", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
"matugen": {Name: "matugen", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||||
"dgop": {Name: "dgop", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
"dgop": {Name: "dgop", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||||
"ghostty": {Name: "ghostty", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
"ghostty": {Name: "ghostty", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||||
|
"danksearch": {Name: "danksearch", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||||
|
"dankcalendar": {Name: "dankcalendar-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||||
}
|
}
|
||||||
|
|
||||||
if wm == deps.WindowManagerNiri {
|
if wm == deps.WindowManagerNiri {
|
||||||
@@ -157,7 +161,60 @@ func (d *DebianDistribution) getQuickshellMapping(variant deps.PackageVariant) P
|
|||||||
if forceQuickshellGit || variant == deps.VariantGit {
|
if forceQuickshellGit || variant == deps.VariantGit {
|
||||||
return PackageMapping{Name: "quickshell-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}
|
return PackageMapping{Name: "quickshell-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}
|
||||||
}
|
}
|
||||||
return PackageMapping{Name: "quickshell", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}
|
// Debian 13 ships stable quickshell in trixie-backports only.
|
||||||
|
if d.debianNeedsQuickshellBackports() {
|
||||||
|
return PackageMapping{Name: "quickshell/trixie-backports", Repository: RepoTypeSystem}
|
||||||
|
}
|
||||||
|
return PackageMapping{Name: "quickshell", Repository: RepoTypeSystem}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DebianDistribution) debianNeedsQuickshellBackports() bool {
|
||||||
|
osInfo, err := GetOSInfo()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return osInfo.VersionID == "13" || strings.EqualFold(osInfo.VersionCodename, "trixie")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DebianDistribution) ensureQuickshellBackports(ctx context.Context, systemPkgs []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||||
|
needsBackports := false
|
||||||
|
for _, pkg := range systemPkgs {
|
||||||
|
if strings.Contains(pkg, "trixie-backports") {
|
||||||
|
needsBackports = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !needsBackports {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
policyOut, err := exec.CommandContext(ctx, "apt-cache", "policy").Output()
|
||||||
|
if err == nil && strings.Contains(string(policyOut), "trixie-backports") {
|
||||||
|
d.log("trixie-backports already configured")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
listFile := "/etc/apt/sources.list.d/trixie-backports.list"
|
||||||
|
repoLine := "deb http://deb.debian.org/debian trixie-backports main contrib non-free non-free-firmware"
|
||||||
|
|
||||||
|
progressChan <- InstallProgressMsg{
|
||||||
|
Phase: PhaseSystemPackages,
|
||||||
|
Progress: 0.30,
|
||||||
|
Step: "Enabling trixie-backports for quickshell...",
|
||||||
|
IsComplete: false,
|
||||||
|
NeedsSudo: true,
|
||||||
|
CommandInfo: fmt.Sprintf("echo '%s' | sudo tee %s", repoLine, listFile),
|
||||||
|
LogOutput: "Debian 13 ships quickshell in trixie-backports",
|
||||||
|
}
|
||||||
|
|
||||||
|
addCmd := privesc.ExecCommand(ctx, sudoPassword,
|
||||||
|
fmt.Sprintf("bash -c \"echo '%s' | tee %s\"", repoLine, listFile))
|
||||||
|
if err := d.runWithProgress(addCmd, progressChan, PhaseSystemPackages, 0.30, 0.31); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get update")
|
||||||
|
return d.runWithProgress(updateCmd, progressChan, PhaseSystemPackages, 0.31, 0.33)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DebianDistribution) getNiriMapping(variant deps.PackageVariant) PackageMapping {
|
func (d *DebianDistribution) getNiriMapping(variant deps.PackageVariant) PackageMapping {
|
||||||
@@ -262,6 +319,10 @@ func (d *DebianDistribution) InstallPackages(ctx context.Context, dependencies [
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := d.ensureQuickshellBackports(ctx, systemPkgs, sudoPassword, progressChan); err != nil {
|
||||||
|
return fmt.Errorf("failed to enable trixie-backports for quickshell: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// System Packages
|
// System Packages
|
||||||
if len(systemPkgs) > 0 {
|
if len(systemPkgs) > 0 {
|
||||||
progressChan <- InstallProgressMsg{
|
progressChan <- InstallProgressMsg{
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package distros
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetupDsearchService enables the dsearch.service user unit. Enablement failures
|
||||||
|
// are returned for the caller to surface as a non-fatal warning.
|
||||||
|
func SetupDsearchService(ctx context.Context, logf func(string)) error {
|
||||||
|
if logf == nil {
|
||||||
|
logf = func(string) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := runSystemctlUser(ctx, "daemon-reload"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := runSystemctlUser(ctx, "enable", "--now", "dsearch.service"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
logf("Enabled dsearch.service")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSystemctlUser(ctx context.Context, args ...string) error {
|
||||||
|
cmd := exec.CommandContext(ctx, "systemctl", append([]string{"--user"}, args...)...)
|
||||||
|
if output, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("systemctl --user %v failed: %w: %s", args, err, string(output))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -104,6 +104,8 @@ func (f *FedoraDistribution) DetectDependenciesWithTerminal(ctx context.Context,
|
|||||||
|
|
||||||
dependencies = append(dependencies, f.detectMatugen())
|
dependencies = append(dependencies, f.detectMatugen())
|
||||||
dependencies = append(dependencies, f.detectDgop())
|
dependencies = append(dependencies, f.detectDgop())
|
||||||
|
dependencies = append(dependencies, f.detectDanksearch())
|
||||||
|
dependencies = append(dependencies, f.detectDankCalendar())
|
||||||
|
|
||||||
return dependencies, nil
|
return dependencies, nil
|
||||||
}
|
}
|
||||||
@@ -138,6 +140,8 @@ func (f *FedoraDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
|
|||||||
"matugen": {Name: "matugen", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
|
"matugen": {Name: "matugen", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
|
||||||
"dms (DankMaterialShell)": f.getDmsMapping(variants["dms (DankMaterialShell)"]),
|
"dms (DankMaterialShell)": f.getDmsMapping(variants["dms (DankMaterialShell)"]),
|
||||||
"dgop": {Name: "dgop", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
|
"dgop": {Name: "dgop", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
|
||||||
|
"danksearch": {Name: "danksearch", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
|
||||||
|
"dankcalendar": {Name: "dankcalendar-git", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
|
||||||
}
|
}
|
||||||
|
|
||||||
switch wm {
|
switch wm {
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ func (g *GentooDistribution) DetectDependenciesWithTerminal(ctx context.Context,
|
|||||||
|
|
||||||
dependencies = append(dependencies, g.detectMatugen())
|
dependencies = append(dependencies, g.detectMatugen())
|
||||||
dependencies = append(dependencies, g.detectDgop())
|
dependencies = append(dependencies, g.detectDgop())
|
||||||
|
dependencies = append(dependencies, g.detectDanksearch())
|
||||||
|
|
||||||
return dependencies, nil
|
return dependencies, nil
|
||||||
}
|
}
|
||||||
@@ -171,6 +172,7 @@ func (g *GentooDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
|
|||||||
"matugen": {Name: "x11-misc/matugen", Repository: RepoTypeGURU, AcceptKeywords: archKeyword},
|
"matugen": {Name: "x11-misc/matugen", Repository: RepoTypeGURU, AcceptKeywords: archKeyword},
|
||||||
"dms (DankMaterialShell)": g.getDmsMapping(),
|
"dms (DankMaterialShell)": g.getDmsMapping(),
|
||||||
"dgop": {Name: "gui-apps/dgop", Repository: RepoTypeGURU, AcceptKeywords: archKeyword},
|
"dgop": {Name: "gui-apps/dgop", Repository: RepoTypeGURU, AcceptKeywords: archKeyword},
|
||||||
|
"danksearch": {Name: "gui-apps/danksearch", Repository: RepoTypeGURU, AcceptKeywords: archKeyword},
|
||||||
}
|
}
|
||||||
|
|
||||||
switch wm {
|
switch wm {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const (
|
|||||||
FamilyDebian DistroFamily = "debian"
|
FamilyDebian DistroFamily = "debian"
|
||||||
FamilyNix DistroFamily = "nix"
|
FamilyNix DistroFamily = "nix"
|
||||||
FamilyGentoo DistroFamily = "gentoo"
|
FamilyGentoo DistroFamily = "gentoo"
|
||||||
|
FamilyVoid DistroFamily = "void"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PackageManagerType defines the package manager a distro uses
|
// PackageManagerType defines the package manager a distro uses
|
||||||
@@ -29,6 +30,7 @@ const (
|
|||||||
PackageManagerZypper PackageManagerType = "zypper"
|
PackageManagerZypper PackageManagerType = "zypper"
|
||||||
PackageManagerNix PackageManagerType = "nix"
|
PackageManagerNix PackageManagerType = "nix"
|
||||||
PackageManagerPortage PackageManagerType = "portage"
|
PackageManagerPortage PackageManagerType = "portage"
|
||||||
|
PackageManagerXBPS PackageManagerType = "xbps"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RepositoryType defines the type of repository for a package
|
// RepositoryType defines the type of repository for a package
|
||||||
@@ -42,6 +44,7 @@ const (
|
|||||||
RepoTypeOBS RepositoryType = "obs" // OpenBuild Service (Debian/OpenSUSE)
|
RepoTypeOBS RepositoryType = "obs" // OpenBuild Service (Debian/OpenSUSE)
|
||||||
RepoTypeFlake RepositoryType = "flake" // Nix flake
|
RepoTypeFlake RepositoryType = "flake" // Nix flake
|
||||||
RepoTypeGURU RepositoryType = "guru" // Gentoo GURU
|
RepoTypeGURU RepositoryType = "guru" // Gentoo GURU
|
||||||
|
RepoTypeXBPS RepositoryType = "xbps" // Custom XBPS repository
|
||||||
RepoTypeManual RepositoryType = "manual" // Manual build from source
|
RepoTypeManual RepositoryType = "manual" // Manual build from source
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ type minimalInstallGroup struct {
|
|||||||
|
|
||||||
func shouldPreferMinimalInstall(pkg string) bool {
|
func shouldPreferMinimalInstall(pkg string) bool {
|
||||||
switch pkg {
|
switch pkg {
|
||||||
case "niri", "niri-git":
|
case "niri", "niri-git", "hyprland", "hyprland-git":
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -91,6 +91,8 @@ func (o *OpenSUSEDistribution) DetectDependenciesWithTerminal(ctx context.Contex
|
|||||||
|
|
||||||
dependencies = append(dependencies, o.detectMatugen())
|
dependencies = append(dependencies, o.detectMatugen())
|
||||||
dependencies = append(dependencies, o.detectDgop())
|
dependencies = append(dependencies, o.detectDgop())
|
||||||
|
dependencies = append(dependencies, o.detectDanksearch())
|
||||||
|
dependencies = append(dependencies, o.detectDankCalendar())
|
||||||
|
|
||||||
return dependencies, nil
|
return dependencies, nil
|
||||||
}
|
}
|
||||||
@@ -129,6 +131,8 @@ func (o *OpenSUSEDistribution) GetPackageMappingWithVariants(wm deps.WindowManag
|
|||||||
"ghostty": {Name: "ghostty", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
"ghostty": {Name: "ghostty", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||||
"matugen": {Name: "matugen", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
"matugen": {Name: "matugen", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||||
"dgop": {Name: "dgop", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
"dgop": {Name: "dgop", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||||
|
"danksearch": {Name: "danksearch", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||||
|
"dankcalendar": {Name: "dankcalendar-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||||
}
|
}
|
||||||
|
|
||||||
switch wm {
|
switch wm {
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ func (u *UbuntuDistribution) DetectDependenciesWithTerminal(ctx context.Context,
|
|||||||
|
|
||||||
dependencies = append(dependencies, u.detectMatugen())
|
dependencies = append(dependencies, u.detectMatugen())
|
||||||
dependencies = append(dependencies, u.detectDgop())
|
dependencies = append(dependencies, u.detectDgop())
|
||||||
|
dependencies = append(dependencies, u.detectDanksearch())
|
||||||
|
dependencies = append(dependencies, u.detectDankCalendar())
|
||||||
|
|
||||||
return dependencies, nil
|
return dependencies, nil
|
||||||
}
|
}
|
||||||
@@ -124,6 +126,8 @@ func (u *UbuntuDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
|
|||||||
"matugen": {Name: "matugen", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
|
"matugen": {Name: "matugen", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
|
||||||
"dgop": {Name: "dgop", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
|
"dgop": {Name: "dgop", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
|
||||||
"ghostty": {Name: "ghostty", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
|
"ghostty": {Name: "ghostty", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
|
||||||
|
"danksearch": {Name: "danksearch", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
|
||||||
|
"dankcalendar": {Name: "dankcalendar-git", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
|
||||||
}
|
}
|
||||||
|
|
||||||
switch wm {
|
switch wm {
|
||||||
|
|||||||
@@ -0,0 +1,541 @@
|
|||||||
|
package distros
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
VoidDMSRepo = "https://void.danklinux.com/dms/current"
|
||||||
|
VoidDankLinuxRepo = "https://void.danklinux.com/danklinux/current"
|
||||||
|
VoidHyprlandRepo = "https://mirror.black-hole.dev/x86_64"
|
||||||
|
|
||||||
|
voidRunitSvDir = "/etc/sv"
|
||||||
|
voidRunitServiceDir = "/var/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register("void", "#478061", FamilyVoid, func(config DistroConfig, logChan chan<- string) Distribution {
|
||||||
|
return NewVoidDistribution(config, logChan)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type VoidDistribution struct {
|
||||||
|
*BaseDistribution
|
||||||
|
config DistroConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewVoidDistribution(config DistroConfig, logChan chan<- string) *VoidDistribution {
|
||||||
|
return &VoidDistribution{
|
||||||
|
BaseDistribution: NewBaseDistribution(logChan),
|
||||||
|
config: config,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VoidDistribution) GetID() string {
|
||||||
|
return v.config.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VoidDistribution) GetColorHex() string {
|
||||||
|
return v.config.ColorHex
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VoidDistribution) GetFamily() DistroFamily {
|
||||||
|
return v.config.Family
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VoidDistribution) GetPackageManager() PackageManagerType {
|
||||||
|
return PackageManagerXBPS
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VoidDistribution) DetectDependencies(ctx context.Context, wm deps.WindowManager) ([]deps.Dependency, error) {
|
||||||
|
return v.DetectDependenciesWithTerminal(ctx, wm, deps.TerminalGhostty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VoidDistribution) DetectDependenciesWithTerminal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal) ([]deps.Dependency, error) {
|
||||||
|
var dependencies []deps.Dependency
|
||||||
|
|
||||||
|
dependencies = append(dependencies, v.detectDMS())
|
||||||
|
dependencies = append(dependencies, v.detectSpecificTerminal(terminal))
|
||||||
|
dependencies = append(dependencies, v.detectGit())
|
||||||
|
dependencies = append(dependencies, v.detectWindowManager(wm))
|
||||||
|
dependencies = append(dependencies, v.detectQuickshell())
|
||||||
|
dependencies = append(dependencies, v.detectDMSGreeter())
|
||||||
|
dependencies = append(dependencies, v.detectXDGPortal())
|
||||||
|
dependencies = append(dependencies, v.detectAccountsService())
|
||||||
|
dependencies = append(dependencies, v.detectDBus())
|
||||||
|
dependencies = append(dependencies, v.detectElogind())
|
||||||
|
dependencies = append(dependencies, v.detectMesaDri())
|
||||||
|
|
||||||
|
if wm == deps.WindowManagerHyprland {
|
||||||
|
dependencies = append(dependencies, v.detectHyprlandTools()...)
|
||||||
|
}
|
||||||
|
|
||||||
|
if wm == deps.WindowManagerNiri || wm == deps.WindowManagerMango {
|
||||||
|
dependencies = append(dependencies, v.detectXwaylandSatellite())
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies = append(dependencies, v.detectMatugen())
|
||||||
|
dependencies = append(dependencies, v.detectDgop())
|
||||||
|
dependencies = append(dependencies, v.detectDanksearch())
|
||||||
|
dependencies = append(dependencies, v.detectDankCalendar())
|
||||||
|
|
||||||
|
return dependencies, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VoidDistribution) detectDMS() deps.Dependency {
|
||||||
|
status := deps.StatusMissing
|
||||||
|
version := ""
|
||||||
|
variant := deps.VariantStable
|
||||||
|
|
||||||
|
if v.packageInstalled("dms-git") {
|
||||||
|
status = deps.StatusInstalled
|
||||||
|
version = v.packageVersion("dms-git")
|
||||||
|
variant = deps.VariantGit
|
||||||
|
} else if v.packageInstalled("dms") {
|
||||||
|
status = deps.StatusInstalled
|
||||||
|
version = v.packageVersion("dms")
|
||||||
|
} else if v.commandExists("dms") {
|
||||||
|
status = deps.StatusInstalled
|
||||||
|
}
|
||||||
|
|
||||||
|
return deps.Dependency{
|
||||||
|
Name: "dms (DankMaterialShell)",
|
||||||
|
Status: status,
|
||||||
|
Version: version,
|
||||||
|
Description: "Desktop Management System package",
|
||||||
|
Required: true,
|
||||||
|
Variant: variant,
|
||||||
|
CanToggle: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VoidDistribution) detectQuickshell() deps.Dependency {
|
||||||
|
dep := v.BaseDistribution.detectQuickshell()
|
||||||
|
dep.CanToggle = false
|
||||||
|
return dep
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VoidDistribution) detectXDGPortal() deps.Dependency {
|
||||||
|
return v.detectPackage("xdg-desktop-portal-gtk", "Desktop integration portal for GTK", v.packageInstalled("xdg-desktop-portal-gtk"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VoidDistribution) detectDMSGreeter() deps.Dependency {
|
||||||
|
return v.detectOptionalPackage("dms-greeter", "DankMaterialShell greetd greeter", v.packageInstalled("dms-greeter"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VoidDistribution) detectAccountsService() deps.Dependency {
|
||||||
|
return v.detectPackage("accountsservice", "D-Bus interface for user account query and manipulation", v.packageInstalled("accountsservice"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VoidDistribution) detectDBus() deps.Dependency {
|
||||||
|
return v.detectPackage("dbus", "D-Bus system and session message bus", v.packageInstalled("dbus"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VoidDistribution) detectElogind() deps.Dependency {
|
||||||
|
return v.detectPackage("elogind", "loginctl/logind provider for power management and session tracking", v.packageInstalled("elogind") || v.commandExists("loginctl"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VoidDistribution) detectMesaDri() deps.Dependency {
|
||||||
|
return v.detectPackage("mesa-dri", "Mesa DRI/EGL drivers (GPU rendering; compositors find no outputs without it)", v.packageInstalled("mesa-dri"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VoidDistribution) detectXwaylandSatellite() deps.Dependency {
|
||||||
|
return v.detectPackage("xwayland-satellite", "Xwayland support", v.packageInstalled("xwayland-satellite"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VoidDistribution) packageInstalled(pkg string) bool {
|
||||||
|
return exec.Command("xbps-query", pkg).Run() == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VoidDistribution) packageVersion(pkg string) string {
|
||||||
|
output, err := exec.Command("xbps-query", "-p", "pkgver", pkg).Output()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(output))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VoidDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
|
||||||
|
return v.GetPackageMappingWithVariants(wm, make(map[string]deps.PackageVariant))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VoidDistribution) GetPackageMappingWithVariants(wm deps.WindowManager, variants map[string]deps.PackageVariant) map[string]PackageMapping {
|
||||||
|
packages := map[string]PackageMapping{
|
||||||
|
"git": {Name: "git", Repository: RepoTypeSystem},
|
||||||
|
"ghostty": {Name: "ghostty", Repository: RepoTypeSystem},
|
||||||
|
"kitty": {Name: "kitty", Repository: RepoTypeSystem},
|
||||||
|
"alacritty": {Name: "alacritty", Repository: RepoTypeSystem},
|
||||||
|
"xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem},
|
||||||
|
"accountsservice": {Name: "accountsservice", Repository: RepoTypeSystem},
|
||||||
|
"dbus": {Name: "dbus", Repository: RepoTypeSystem},
|
||||||
|
"elogind": {Name: "elogind", Repository: RepoTypeSystem},
|
||||||
|
"mesa-dri": {Name: "mesa-dri", Repository: RepoTypeSystem},
|
||||||
|
|
||||||
|
"quickshell": {Name: "quickshell", Repository: RepoTypeSystem},
|
||||||
|
"matugen": {Name: "matugen", Repository: RepoTypeSystem},
|
||||||
|
"dms (DankMaterialShell)": v.getDmsMapping(variants["dms (DankMaterialShell)"]),
|
||||||
|
"dms-greeter": {Name: "dms-greeter", Repository: RepoTypeXBPS, RepoURL: VoidDMSRepo},
|
||||||
|
"dgop": {Name: "dgop", Repository: RepoTypeXBPS, RepoURL: VoidDankLinuxRepo},
|
||||||
|
"danksearch": {Name: "danksearch", Repository: RepoTypeXBPS, RepoURL: VoidDankLinuxRepo},
|
||||||
|
"dankcalendar": {Name: "dankcalendar", Repository: RepoTypeXBPS, RepoURL: VoidDankLinuxRepo},
|
||||||
|
}
|
||||||
|
|
||||||
|
switch wm {
|
||||||
|
case deps.WindowManagerHyprland:
|
||||||
|
packages["hyprland"] = PackageMapping{Name: "hyprland", Repository: RepoTypeXBPS, RepoURL: VoidHyprlandRepo}
|
||||||
|
packages["hyprctl"] = PackageMapping{Name: "hyprland", Repository: RepoTypeXBPS, RepoURL: VoidHyprlandRepo}
|
||||||
|
packages["jq"] = PackageMapping{Name: "jq", Repository: RepoTypeSystem}
|
||||||
|
case deps.WindowManagerNiri:
|
||||||
|
packages["niri"] = PackageMapping{Name: "niri", Repository: RepoTypeSystem}
|
||||||
|
packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem}
|
||||||
|
case deps.WindowManagerMango:
|
||||||
|
packages["mango"] = PackageMapping{Name: "mangowc", Repository: RepoTypeSystem}
|
||||||
|
packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem}
|
||||||
|
}
|
||||||
|
|
||||||
|
return packages
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VoidDistribution) getDmsMapping(variant deps.PackageVariant) PackageMapping {
|
||||||
|
if variant == deps.VariantStable {
|
||||||
|
return PackageMapping{Name: "dms", Repository: RepoTypeXBPS, RepoURL: VoidDMSRepo}
|
||||||
|
}
|
||||||
|
return PackageMapping{Name: "dms-git", Repository: RepoTypeXBPS, RepoURL: VoidDMSRepo}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VoidDistribution) InstallPrerequisites(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||||
|
progressChan <- InstallProgressMsg{
|
||||||
|
Phase: PhasePrerequisites,
|
||||||
|
Progress: 0.06,
|
||||||
|
Step: "Checking XBPS...",
|
||||||
|
IsComplete: false,
|
||||||
|
LogOutput: "Checking for xbps-install",
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := exec.LookPath("xbps-install"); err != nil {
|
||||||
|
return fmt.Errorf("xbps-install not found; Void Linux package tools are required: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VoidDistribution) InstallPackages(ctx context.Context, dependencies []deps.Dependency, wm deps.WindowManager, sudoPassword string, reinstallFlags map[string]bool, disabledFlags map[string]bool, skipGlobalUseFlags bool, progressChan chan<- InstallProgressMsg) error {
|
||||||
|
progressChan <- InstallProgressMsg{
|
||||||
|
Phase: PhasePrerequisites,
|
||||||
|
Progress: 0.05,
|
||||||
|
Step: "Checking system prerequisites...",
|
||||||
|
IsComplete: false,
|
||||||
|
LogOutput: "Starting prerequisite check...",
|
||||||
|
}
|
||||||
|
|
||||||
|
if wm == deps.WindowManagerHyprland {
|
||||||
|
arch, err := v.xbpsArch(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to detect XBPS architecture for Hyprland repository selection: %w", err)
|
||||||
|
}
|
||||||
|
if arch != "x86_64" {
|
||||||
|
return fmt.Errorf("hyprland on Void Linux is installed from %s, which currently provides x86_64 packages only (detected %s)", VoidHyprlandRepo, arch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := v.InstallPrerequisites(ctx, sudoPassword, progressChan); err != nil {
|
||||||
|
return fmt.Errorf("failed to install prerequisites: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
systemPkgs, xbpsPkgs := v.categorizePackages(dependencies, wm, reinstallFlags, disabledFlags)
|
||||||
|
|
||||||
|
if len(xbpsPkgs) > 0 {
|
||||||
|
progressChan <- InstallProgressMsg{
|
||||||
|
Phase: PhaseSystemPackages,
|
||||||
|
Progress: 0.15,
|
||||||
|
Step: "Enabling DMS XBPS repositories...",
|
||||||
|
IsComplete: false,
|
||||||
|
NeedsSudo: true,
|
||||||
|
LogOutput: "Setting up custom XBPS repositories for DMS packages",
|
||||||
|
}
|
||||||
|
if err := v.enableXBPSRepos(ctx, xbpsPkgs, sudoPassword, progressChan); err != nil {
|
||||||
|
return fmt.Errorf("failed to enable XBPS repositories: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allPkgs := v.uniquePackageNames(systemPkgs, v.extractPackageNames(xbpsPkgs))
|
||||||
|
if len(allPkgs) > 0 {
|
||||||
|
progressChan <- InstallProgressMsg{
|
||||||
|
Phase: PhaseSystemPackages,
|
||||||
|
Progress: 0.35,
|
||||||
|
Step: fmt.Sprintf("Installing %d XBPS packages...", len(allPkgs)),
|
||||||
|
IsComplete: false,
|
||||||
|
NeedsSudo: true,
|
||||||
|
LogOutput: fmt.Sprintf("Installing XBPS packages: %s", strings.Join(allPkgs, ", ")),
|
||||||
|
}
|
||||||
|
if err := v.installXBPSPackages(ctx, allPkgs, sudoPassword, progressChan); err != nil {
|
||||||
|
return fmt.Errorf("failed to install XBPS packages: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
progressChan <- InstallProgressMsg{
|
||||||
|
Phase: PhaseConfiguration,
|
||||||
|
Progress: 0.90,
|
||||||
|
Step: "Configuring system...",
|
||||||
|
IsComplete: false,
|
||||||
|
LogOutput: "Starting post-installation configuration...",
|
||||||
|
}
|
||||||
|
|
||||||
|
v.log("Void Linux detected; DMS environment and autostart will be configured in the compositor config instead of systemd")
|
||||||
|
if err := v.ensureSessionServices(ctx, sudoPassword, progressChan); err != nil {
|
||||||
|
return fmt.Errorf("failed to enable Void session services: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
progressChan <- InstallProgressMsg{
|
||||||
|
Phase: PhaseComplete,
|
||||||
|
Progress: 1.0,
|
||||||
|
Step: "Installation complete!",
|
||||||
|
IsComplete: true,
|
||||||
|
LogOutput: "All packages installed and configured successfully",
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VoidDistribution) ensureSessionServices(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||||
|
if !v.isRunitSystem() {
|
||||||
|
v.log("Void runit service directory not detected; skipping dbus/elogind service enablement")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// D-Bus activation alone starts elogind without its wrapper mounts; the runit service is required.
|
||||||
|
for _, service := range []string{"dbus", "elogind"} {
|
||||||
|
if !v.runitServiceInstalled(service) {
|
||||||
|
v.log(fmt.Sprintf("Warning: %s runit service not found in %s; power/session actions may not work until %s is installed", service, voidRunitSvDir, service))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if v.runitServiceEnabled(service) {
|
||||||
|
v.log(fmt.Sprintf("Void runit service %s already enabled", service))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
progressChan <- InstallProgressMsg{
|
||||||
|
Phase: PhaseConfiguration,
|
||||||
|
Progress: 0.92,
|
||||||
|
Step: fmt.Sprintf("Enabling %s runit service...", service),
|
||||||
|
IsComplete: false,
|
||||||
|
NeedsSudo: true,
|
||||||
|
CommandInfo: fmt.Sprintf("sudo ln -sf %s %s", filepath.Join(voidRunitSvDir, service), filepath.Join(voidRunitServiceDir, service)),
|
||||||
|
LogOutput: fmt.Sprintf("Enabling Void runit service: %s", service),
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := privesc.ExecCommand(ctx, sudoPassword, fmt.Sprintf("ln -sf %s %s", filepath.Join(voidRunitSvDir, service), filepath.Join(voidRunitServiceDir, service)))
|
||||||
|
if err := v.runWithProgress(cmd, progressChan, PhaseConfiguration, 0.92, 0.95); err != nil {
|
||||||
|
return fmt.Errorf("failed to enable %s runit service: %w", service, err)
|
||||||
|
}
|
||||||
|
v.log(fmt.Sprintf("✓ Enabled %s runit service", service))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VoidDistribution) isRunitSystem() bool {
|
||||||
|
if fi, err := os.Stat("/run/runit"); err == nil && fi.IsDir() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if _, err := os.Stat("/run/systemd/system"); err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if fi, err := os.Stat(voidRunitServiceDir); err == nil && fi.IsDir() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VoidDistribution) runitServiceInstalled(name string) bool {
|
||||||
|
fi, err := os.Stat(filepath.Join(voidRunitSvDir, name))
|
||||||
|
return err == nil && fi.IsDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VoidDistribution) runitServiceEnabled(name string) bool {
|
||||||
|
_, err := os.Lstat(filepath.Join(voidRunitServiceDir, name))
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VoidDistribution) categorizePackages(dependencies []deps.Dependency, wm deps.WindowManager, reinstallFlags map[string]bool, disabledFlags map[string]bool) ([]string, []PackageMapping) {
|
||||||
|
systemPkgs := []string{}
|
||||||
|
xbpsPkgs := []PackageMapping{}
|
||||||
|
|
||||||
|
variantMap := make(map[string]deps.PackageVariant)
|
||||||
|
for _, dep := range dependencies {
|
||||||
|
variantMap[dep.Name] = dep.Variant
|
||||||
|
}
|
||||||
|
|
||||||
|
packageMap := v.GetPackageMappingWithVariants(wm, variantMap)
|
||||||
|
|
||||||
|
for _, dep := range dependencies {
|
||||||
|
if disabledFlags[dep.Name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if dep.Status == deps.StatusInstalled && !reinstallFlags[dep.Name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
pkgInfo, exists := packageMap[dep.Name]
|
||||||
|
if !exists {
|
||||||
|
v.log(fmt.Sprintf("Warning: No package mapping for %s", dep.Name))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch pkgInfo.Repository {
|
||||||
|
case RepoTypeXBPS:
|
||||||
|
xbpsPkgs = append(xbpsPkgs, pkgInfo)
|
||||||
|
case RepoTypeSystem:
|
||||||
|
systemPkgs = append(systemPkgs, pkgInfo.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return systemPkgs, xbpsPkgs
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VoidDistribution) enableXBPSRepos(ctx context.Context, xbpsPkgs []PackageMapping, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||||
|
enabledRepos := make(map[string]bool)
|
||||||
|
enabledRepoURLs := []string{}
|
||||||
|
|
||||||
|
for _, pkg := range xbpsPkgs {
|
||||||
|
if pkg.RepoURL == "" || enabledRepos[pkg.RepoURL] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
repoName := v.xbpsRepoName(pkg.RepoURL)
|
||||||
|
confPath := filepath.Join("/etc/xbps.d", repoName+".conf")
|
||||||
|
repoLine := fmt.Sprintf("repository=%s", pkg.RepoURL)
|
||||||
|
repoFileContent := repoLine + "\n"
|
||||||
|
|
||||||
|
if content, err := os.ReadFile(confPath); err == nil && string(content) == repoFileContent {
|
||||||
|
v.log(fmt.Sprintf("XBPS repo %s already configured, skipping", pkg.RepoURL))
|
||||||
|
enabledRepos[pkg.RepoURL] = true
|
||||||
|
enabledRepoURLs = append(enabledRepoURLs, pkg.RepoURL)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
progressChan <- InstallProgressMsg{
|
||||||
|
Phase: PhaseSystemPackages,
|
||||||
|
Progress: 0.18,
|
||||||
|
Step: fmt.Sprintf("Adding XBPS repo %s...", repoName),
|
||||||
|
IsComplete: false,
|
||||||
|
NeedsSudo: true,
|
||||||
|
CommandInfo: fmt.Sprintf("echo 'repository=%s' | sudo tee %s", pkg.RepoURL, confPath),
|
||||||
|
LogOutput: fmt.Sprintf("Adding XBPS repository: %s", pkg.RepoURL),
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdirCmd := privesc.ExecCommand(ctx, sudoPassword, "mkdir -p /etc/xbps.d")
|
||||||
|
if err := v.runWithProgress(mkdirCmd, progressChan, PhaseSystemPackages, 0.18, 0.19); err != nil {
|
||||||
|
return fmt.Errorf("failed to create /etc/xbps.d: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeCmd := privesc.ExecCommand(ctx, sudoPassword,
|
||||||
|
fmt.Sprintf("bash -c 'printf \"%%s\\n\" %q > %s'", repoLine, confPath))
|
||||||
|
if err := v.runWithProgress(writeCmd, progressChan, PhaseSystemPackages, 0.19, 0.22); err != nil {
|
||||||
|
return fmt.Errorf("failed to add XBPS repo %s: %w", pkg.RepoURL, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
enabledRepos[pkg.RepoURL] = true
|
||||||
|
enabledRepoURLs = append(enabledRepoURLs, pkg.RepoURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(enabledRepos) > 0 {
|
||||||
|
syncArgs := []string{"xbps-install", "-Sy", "-i"}
|
||||||
|
for _, repoURL := range enabledRepoURLs {
|
||||||
|
syncArgs = append(syncArgs, "--repository", repoURL)
|
||||||
|
}
|
||||||
|
syncCommand := strings.Join(syncArgs, " ")
|
||||||
|
|
||||||
|
progressChan <- InstallProgressMsg{
|
||||||
|
Phase: PhaseSystemPackages,
|
||||||
|
Progress: 0.25,
|
||||||
|
Step: "Synchronizing XBPS repositories...",
|
||||||
|
IsComplete: false,
|
||||||
|
NeedsSudo: true,
|
||||||
|
CommandInfo: "sudo sh -c 'yes y | " + syncCommand + "'",
|
||||||
|
LogOutput: "Synchronizing XBPS repository indexes",
|
||||||
|
}
|
||||||
|
|
||||||
|
syncCmd := privesc.ExecCommand(ctx, sudoPassword, "sh -c 'yes y | "+syncCommand+"'")
|
||||||
|
if err := v.runWithProgress(syncCmd, progressChan, PhaseSystemPackages, 0.25, 0.30); err != nil {
|
||||||
|
return fmt.Errorf("failed to synchronize XBPS repositories: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VoidDistribution) xbpsRepoName(repoURL string) string {
|
||||||
|
switch repoURL {
|
||||||
|
case VoidDMSRepo:
|
||||||
|
return "dms"
|
||||||
|
case VoidDankLinuxRepo:
|
||||||
|
return "danklinux"
|
||||||
|
case VoidHyprlandRepo:
|
||||||
|
return "hyprland"
|
||||||
|
default:
|
||||||
|
name := strings.TrimPrefix(repoURL, "https://")
|
||||||
|
name = strings.TrimPrefix(name, "http://")
|
||||||
|
name = strings.NewReplacer("/", "-", ".", "-").Replace(name)
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VoidDistribution) xbpsArch(ctx context.Context) (string, error) {
|
||||||
|
output, err := exec.CommandContext(ctx, "xbps-uhelper", "arch").Output()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(output)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VoidDistribution) installXBPSPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||||
|
if len(packages) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
args := append([]string{"xbps-install", "-Sy"}, packages...)
|
||||||
|
progressChan <- InstallProgressMsg{
|
||||||
|
Phase: PhaseSystemPackages,
|
||||||
|
Progress: 0.40,
|
||||||
|
Step: "Installing XBPS packages...",
|
||||||
|
IsComplete: false,
|
||||||
|
NeedsSudo: true,
|
||||||
|
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
|
||||||
|
return v.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.40, 0.85)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VoidDistribution) extractPackageNames(packages []PackageMapping) []string {
|
||||||
|
names := make([]string, len(packages))
|
||||||
|
for i, pkg := range packages {
|
||||||
|
names[i] = pkg.Name
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VoidDistribution) uniquePackageNames(groups ...[]string) []string {
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
var unique []string
|
||||||
|
for _, group := range groups {
|
||||||
|
for _, pkg := range group {
|
||||||
|
if pkg == "" || seen[pkg] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[pkg] = true
|
||||||
|
unique = append(unique, pkg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return unique
|
||||||
|
}
|
||||||
@@ -132,7 +132,7 @@ func (c *GeoClueClient) startSignalPump() error {
|
|||||||
if err := c.dbusConn.AddMatchSignal(
|
if err := c.dbusConn.AddMatchSignal(
|
||||||
dbus.WithMatchObjectPath(c.clientPath),
|
dbus.WithMatchObjectPath(c.clientPath),
|
||||||
dbus.WithMatchInterface(dbusGeoClueClientInterface),
|
dbus.WithMatchInterface(dbusGeoClueClientInterface),
|
||||||
dbus.WithMatchSender(dbusGeoClueClientLocationUpdated),
|
dbus.WithMatchMember("LocationUpdated"),
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ const appArmorProfileDest = "/etc/apparmor.d/usr.bin.dms-greeter"
|
|||||||
|
|
||||||
const GreeterCacheDir = "/var/cache/dms-greeter"
|
const GreeterCacheDir = "/var/cache/dms-greeter"
|
||||||
|
|
||||||
|
const (
|
||||||
|
runitSvDir = "/etc/sv"
|
||||||
|
runitServiceDir = "/var/service"
|
||||||
|
)
|
||||||
|
|
||||||
func DetectDMSPath() (string, error) {
|
func DetectDMSPath() (string, error) {
|
||||||
return config.LocateDMSConfig()
|
return config.LocateDMSConfig()
|
||||||
}
|
}
|
||||||
@@ -41,6 +46,142 @@ func IsNixOS() bool {
|
|||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func IsVoidLinux() bool {
|
||||||
|
osInfo, err := distros.GetOSInfo()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
config, exists := distros.Registry[osInfo.Distribution.ID]
|
||||||
|
return exists && config.Family == distros.FamilyVoid
|
||||||
|
}
|
||||||
|
|
||||||
|
func isRunit() bool {
|
||||||
|
if fi, err := os.Stat("/run/runit"); err == nil && fi.IsDir() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if _, err := os.Stat("/run/systemd/system"); err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if fi, err := os.Stat(runitServiceDir); err == nil && fi.IsDir() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func runitServiceInstalled(name string) bool {
|
||||||
|
fi, err := os.Stat(filepath.Join(runitSvDir, name))
|
||||||
|
return err == nil && fi.IsDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
func runitServiceEnabled(name string) bool {
|
||||||
|
_, err := os.Lstat(filepath.Join(runitServiceDir, name))
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func enableRunitService(name, sudoPassword string) error {
|
||||||
|
if !runitServiceInstalled(name) {
|
||||||
|
return fmt.Errorf("runit service %q not found in %s", name, runitSvDir)
|
||||||
|
}
|
||||||
|
if runitServiceEnabled(name) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return privesc.Run(context.Background(), sudoPassword, "ln", "-sf",
|
||||||
|
filepath.Join(runitSvDir, name), filepath.Join(runitServiceDir, name))
|
||||||
|
}
|
||||||
|
|
||||||
|
func disableRunitService(name, sudoPassword string) error {
|
||||||
|
if !runitServiceEnabled(name) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return privesc.Run(context.Background(), sudoPassword, "rm", "-f",
|
||||||
|
filepath.Join(runitServiceDir, name))
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureRunitSeat(greeterUser, sudoPassword string, logFunc func(string)) {
|
||||||
|
if runitServiceInstalled("seatd") {
|
||||||
|
if err := enableRunitService("seatd", sudoPassword); err != nil {
|
||||||
|
logFunc(fmt.Sprintf("⚠ could not enable seatd: %v", err))
|
||||||
|
} else {
|
||||||
|
logFunc("✓ seatd enabled")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logFunc("⚠ seatd not installed — the greeter compositor needs it for GPU/seat access")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := privesc.Run(context.Background(), sudoPassword, "usermod", "-aG", "_seatd,video,input", greeterUser); err != nil {
|
||||||
|
logFunc(fmt.Sprintf("⚠ could not add %s to seat groups: %v", greeterUser, err))
|
||||||
|
} else {
|
||||||
|
logFunc(fmt.Sprintf("✓ %s added to seat groups (_seatd, video, input)", greeterUser))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// VoidGreetdRunScript orders greetd after dbus/elogind so the first greeter session can't race elogind's runtime-dir setup.
|
||||||
|
const VoidGreetdRunScript = `#!/bin/sh
|
||||||
|
sv check dbus >/dev/null || exit 1
|
||||||
|
sv check elogind >/dev/null || exit 1
|
||||||
|
exec greetd
|
||||||
|
`
|
||||||
|
|
||||||
|
// EnsureVoidGreetdRunScript rewrites /etc/sv/greetd/run with dbus/elogind ordering (greetd updates restore stock; enable re-asserts).
|
||||||
|
func EnsureVoidGreetdRunScript(logFunc func(string), sudoPassword string) {
|
||||||
|
const runPath = "/etc/sv/greetd/run"
|
||||||
|
if data, err := os.ReadFile(runPath); err == nil && strings.Contains(string(data), "sv check elogind") {
|
||||||
|
logFunc("✓ greetd run script already waits for elogind")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
script := fmt.Sprintf("cat > %s <<'EOF'\n%sEOF\nchmod 755 %s", runPath, VoidGreetdRunScript, runPath)
|
||||||
|
if err := privesc.Run(context.Background(), sudoPassword, "sh", "-c", script); err != nil {
|
||||||
|
logFunc(fmt.Sprintf("⚠ could not update %s: %v", runPath, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logFunc("✓ greetd run script now waits for dbus/elogind")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureVoidLogindGreeter configures the elogind-backed greeter on Void.
|
||||||
|
func ensureVoidLogindGreeter(greeterUser, sudoPassword string, logFunc func(string)) {
|
||||||
|
for _, service := range []string{"dbus", "elogind"} {
|
||||||
|
if err := enableRunitService(service, sudoPassword); err != nil {
|
||||||
|
logFunc(fmt.Sprintf("⚠ could not enable %s: %v", service, err))
|
||||||
|
} else {
|
||||||
|
logFunc(fmt.Sprintf("✓ %s enabled", service))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EnsureVoidGreetdRunScript(logFunc, sudoPassword)
|
||||||
|
if runitServiceEnabled("seatd") {
|
||||||
|
if err := disableRunitService("seatd", sudoPassword); err != nil {
|
||||||
|
logFunc(fmt.Sprintf("⚠ could not disable seatd: %v", err))
|
||||||
|
} else {
|
||||||
|
logFunc("✓ seatd disabled (elogind manages the seat)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := privesc.Run(context.Background(), sudoPassword, "usermod", "-aG", "video,input", greeterUser); err != nil {
|
||||||
|
logFunc(fmt.Sprintf("⚠ could not add %s to video/input groups: %v", greeterUser, err))
|
||||||
|
} else {
|
||||||
|
logFunc(fmt.Sprintf("✓ %s added to video/input groups (elogind manages the seat)", greeterUser))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureGreetdPamRundir(sudoPassword string, logFunc func(string)) {
|
||||||
|
const pamPath = "/etc/pam.d/greetd"
|
||||||
|
data, err := os.ReadFile(pamPath)
|
||||||
|
if err != nil {
|
||||||
|
logFunc(fmt.Sprintf("⚠ could not read %s: %v", pamPath, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.Contains(string(data), "pam_rundir") {
|
||||||
|
logFunc("✓ pam_rundir already present in greetd PAM")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
line := "session optional pam_rundir.so"
|
||||||
|
if err := privesc.Run(context.Background(), sudoPassword, "sh", "-c",
|
||||||
|
fmt.Sprintf("printf '%%s\\n' %q >> %s", line, pamPath)); err != nil {
|
||||||
|
logFunc(fmt.Sprintf("⚠ could not add pam_rundir to %s: %v", pamPath, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logFunc("✓ pam_rundir added to greetd PAM (provides XDG_RUNTIME_DIR for the session)")
|
||||||
|
}
|
||||||
|
|
||||||
func DetectGreeterGroup() string {
|
func DetectGreeterGroup() string {
|
||||||
data, err := os.ReadFile("/etc/group")
|
data, err := os.ReadFile("/etc/group")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -233,24 +374,39 @@ func stripDesktopExecCodes(execLine string) string {
|
|||||||
return strings.Join(cleaned, " ")
|
return strings.Join(cleaned, " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatInitialSessionCommand(sessionExec string) string {
|
func shellQuote(value string) string {
|
||||||
execLine := strings.TrimSpace(stripDesktopExecCodes(sessionExec))
|
return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
|
||||||
if execLine == "" {
|
}
|
||||||
|
|
||||||
|
func stableDMSCommand() string {
|
||||||
|
for _, candidate := range []string{"/usr/bin/dms", "/usr/local/bin/dms"} {
|
||||||
|
info, err := os.Stat(candidate)
|
||||||
|
if err == nil && !info.IsDir() && info.Mode()&0o111 != 0 {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "dms"
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatInitialSessionCommand(cacheDir string) string {
|
||||||
|
cacheDir = strings.TrimSpace(cacheDir)
|
||||||
|
if cacheDir == "" {
|
||||||
return `command = ""`
|
return `command = ""`
|
||||||
}
|
}
|
||||||
escaped := strings.ReplaceAll(execLine, `'`, `'\''`)
|
launcher := fmt.Sprintf("%s greeter launch-session --from-memory --cache-dir %s", stableDMSCommand(), shellQuote(cacheDir))
|
||||||
|
escaped := strings.ReplaceAll(launcher, `'`, `'\''`)
|
||||||
inner := fmt.Sprintf("env XDG_SESSION_TYPE=wayland sh -c 'exec %s'", escaped)
|
inner := fmt.Sprintf("env XDG_SESSION_TYPE=wayland sh -c 'exec %s'", escaped)
|
||||||
tomlEscaped := strings.ReplaceAll(inner, `\`, `\\`)
|
tomlEscaped := strings.ReplaceAll(inner, `\`, `\\`)
|
||||||
tomlEscaped = strings.ReplaceAll(tomlEscaped, `"`, `\"`)
|
tomlEscaped = strings.ReplaceAll(tomlEscaped, `"`, `\"`)
|
||||||
return fmt.Sprintf(`command = "%s"`, tomlEscaped)
|
return fmt.Sprintf(`command = "%s"`, tomlEscaped)
|
||||||
}
|
}
|
||||||
|
|
||||||
func upsertInitialSession(configContent, loginUser, sessionExec string, enabled bool) string {
|
func upsertInitialSession(configContent, loginUser, cacheDir string, enabled bool) string {
|
||||||
if !enabled {
|
if !enabled {
|
||||||
return removeTomlSection(configContent, "initial_session")
|
return removeTomlSection(configContent, "initial_session")
|
||||||
}
|
}
|
||||||
|
|
||||||
commandLine := formatInitialSessionCommand(sessionExec)
|
commandLine := formatInitialSessionCommand(cacheDir)
|
||||||
lines := strings.Split(configContent, "\n")
|
lines := strings.Split(configContent, "\n")
|
||||||
var out []string
|
var out []string
|
||||||
|
|
||||||
@@ -330,6 +486,7 @@ type greeterAutoLoginConfig struct {
|
|||||||
type greeterAutoLoginMemory struct {
|
type greeterAutoLoginMemory struct {
|
||||||
LastSuccessfulUser string `json:"lastSuccessfulUser"`
|
LastSuccessfulUser string `json:"lastSuccessfulUser"`
|
||||||
LastSessionID string `json:"lastSessionId"`
|
LastSessionID string `json:"lastSessionId"`
|
||||||
|
LastSessionDesktopID string `json:"lastSessionDesktopId"`
|
||||||
LastSessionExec string `json:"lastSessionExec"`
|
LastSessionExec string `json:"lastSessionExec"`
|
||||||
AutoLoginEnabled bool `json:"autoLoginEnabled"`
|
AutoLoginEnabled bool `json:"autoLoginEnabled"`
|
||||||
}
|
}
|
||||||
@@ -372,16 +529,28 @@ func execFromDesktopFile(path string) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
inDesktopEntry := false
|
||||||
for line := range strings.SplitSeq(string(data), "\n") {
|
for line := range strings.SplitSeq(string(data), "\n") {
|
||||||
trimmed := strings.TrimSpace(line)
|
trimmed := strings.TrimSpace(line)
|
||||||
if strings.HasPrefix(trimmed, "Exec=") {
|
switch {
|
||||||
return strings.TrimSpace(trimmed[len("Exec="):]), nil
|
case trimmed == "" || strings.HasPrefix(trimmed, "#"):
|
||||||
|
continue
|
||||||
|
case strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]"):
|
||||||
|
inDesktopEntry = trimmed == "[Desktop Entry]"
|
||||||
|
continue
|
||||||
|
case !inDesktopEntry:
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
key, value, found := strings.Cut(trimmed, "=")
|
||||||
|
if !found || strings.TrimSpace(key) != "Exec" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(value), nil
|
||||||
}
|
}
|
||||||
return "", fmt.Errorf("no Exec= line found in %s", path)
|
return "", fmt.Errorf("no Exec= line found in %s", path)
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveGreeterAutoLoginState(cacheDir, homeDir string) (enabled bool, loginUser string, sessionExec string, err error) {
|
func resolveGreeterAutoLoginState(cacheDir, homeDir string) (enabled bool, loginUser string, sessionID string, err error) {
|
||||||
settingsPath := filepath.Join(cacheDir, "settings.json")
|
settingsPath := filepath.Join(cacheDir, "settings.json")
|
||||||
if _, statErr := os.Stat(settingsPath); statErr != nil {
|
if _, statErr := os.Stat(settingsPath); statErr != nil {
|
||||||
settingsPath = filepath.Join(homeDir, ".config", "DankMaterialShell", "settings.json")
|
settingsPath = filepath.Join(homeDir, ".config", "DankMaterialShell", "settings.json")
|
||||||
@@ -416,15 +585,9 @@ func resolveGreeterAutoLoginState(cacheDir, homeDir string) (enabled bool, login
|
|||||||
loginUser = current.Username
|
loginUser = current.Username
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionExec = mem.LastSessionExec
|
sessionID = sessionDesktopIDFromMemory(mem)
|
||||||
if sessionExec == "" && mem.LastSessionID != "" {
|
|
||||||
sessionExec, err = execFromDesktopFile(mem.LastSessionID)
|
|
||||||
if err != nil {
|
|
||||||
sessionExec = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true, loginUser, sessionExec, nil
|
return true, loginUser, sessionID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeGreetdConfig(configPath, content string, logFunc func(string), sudoPassword, successMsg string) error {
|
func writeGreetdConfig(configPath, content string, logFunc func(string), sudoPassword, successMsg string) error {
|
||||||
@@ -540,7 +703,7 @@ func readGreeterMemoryFile(memoryPath, sudoPassword string) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func SyncGreetdAutoLogin(cacheDir, homeDir string, logFunc func(string), sudoPassword string) error {
|
func SyncGreetdAutoLogin(cacheDir, homeDir string, logFunc func(string), sudoPassword string) error {
|
||||||
enabled, loginUser, sessionExec, err := resolveGreeterAutoLoginState(cacheDir, homeDir)
|
enabled, loginUser, sessionID, err := resolveGreeterAutoLoginState(cacheDir, homeDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -568,7 +731,7 @@ func SyncGreetdAutoLogin(cacheDir, homeDir string, logFunc func(string), sudoPas
|
|||||||
return writeGreetdConfig(configPath, newConfig, logFunc, sudoPassword, "✓ Disabled greeter auto-login")
|
return writeGreetdConfig(configPath, newConfig, logFunc, sudoPassword, "✓ Disabled greeter auto-login")
|
||||||
}
|
}
|
||||||
|
|
||||||
if loginUser == "" || sessionExec == "" {
|
if loginUser == "" || sessionID == "" {
|
||||||
if logFunc != nil {
|
if logFunc != nil {
|
||||||
logFunc("⚠ Greeter auto-login is enabled but user or session is not configured yet. Log in manually once, then run sync.")
|
logFunc("⚠ Greeter auto-login is enabled but user or session is not configured yet. Log in manually once, then run sync.")
|
||||||
}
|
}
|
||||||
@@ -579,7 +742,7 @@ func SyncGreetdAutoLogin(cacheDir, homeDir string, logFunc func(string), sudoPas
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
newConfig := upsertInitialSession(configContent, loginUser, sessionExec, true)
|
newConfig := upsertInitialSession(configContent, loginUser, cacheDir, true)
|
||||||
if newConfig == configContent {
|
if newConfig == configContent {
|
||||||
if logFunc != nil {
|
if logFunc != nil {
|
||||||
logFunc(fmt.Sprintf("✓ Greeter auto-login already configured for %s", loginUser))
|
logFunc(fmt.Sprintf("✓ Greeter auto-login already configured for %s", loginUser))
|
||||||
@@ -756,6 +919,8 @@ func EnsureGreetdInstalled(logFunc func(string), sudoPassword string) error {
|
|||||||
installCmd = privesc.ExecCommand(ctx, sudoPassword, "apt-get install -y greetd")
|
installCmd = privesc.ExecCommand(ctx, sudoPassword, "apt-get install -y greetd")
|
||||||
case distros.FamilyGentoo:
|
case distros.FamilyGentoo:
|
||||||
installCmd = privesc.ExecCommand(ctx, sudoPassword, "emerge --ask n sys-apps/greetd")
|
installCmd = privesc.ExecCommand(ctx, sudoPassword, "emerge --ask n sys-apps/greetd")
|
||||||
|
case distros.FamilyVoid:
|
||||||
|
installCmd = privesc.ExecCommand(ctx, sudoPassword, "xbps-install -Sy 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:
|
||||||
@@ -882,6 +1047,14 @@ func TryInstallGreeterPackage(logFunc func(string), sudoPassword string) bool {
|
|||||||
}
|
}
|
||||||
failHint = fmt.Sprintf("⚠ dms-greeter install failed. Install from AUR: %s -S greetd-dms-greeter-git", aurHelper)
|
failHint = fmt.Sprintf("⚠ dms-greeter install failed. Install from AUR: %s -S greetd-dms-greeter-git", aurHelper)
|
||||||
installCmd = exec.CommandContext(ctx, aurHelper, "-S", "--noconfirm", "greetd-dms-greeter-git")
|
installCmd = exec.CommandContext(ctx, aurHelper, "-S", "--noconfirm", "greetd-dms-greeter-git")
|
||||||
|
case distros.FamilyVoid:
|
||||||
|
failHint = "⚠ dms-greeter install failed. Add the DMS XBPS repo manually:\necho 'repository=https://void.danklinux.com/dms/current' | sudo tee /etc/xbps.d/dms.conf\nsudo xbps-install -Sy dms-greeter"
|
||||||
|
logFunc("Adding DMS XBPS repository...")
|
||||||
|
if err := ensureVoidXBPSRepo(ctx, sudoPassword, "dms", distros.VoidDMSRepo); err != nil {
|
||||||
|
logFunc(fmt.Sprintf("⚠ Failed to add DMS XBPS repository: %v", err))
|
||||||
|
}
|
||||||
|
privesc.ExecCommand(ctx, sudoPassword, "sh -c 'yes y | xbps-install -Sy -i --repository "+distros.VoidDMSRepo+"'").Run()
|
||||||
|
installCmd = privesc.ExecCommand(ctx, sudoPassword, "xbps-install -Sy dms-greeter")
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -899,6 +1072,20 @@ func TryInstallGreeterPackage(logFunc func(string), sudoPassword string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ensureVoidXBPSRepo(ctx context.Context, sudoPassword, name, repoURL string) error {
|
||||||
|
confPath := filepath.Join("/etc/xbps.d", name+".conf")
|
||||||
|
repoLine := fmt.Sprintf("repository=%s", repoURL)
|
||||||
|
repoFileContent := repoLine + "\n"
|
||||||
|
if content, err := os.ReadFile(confPath); err == nil && string(content) == repoFileContent {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := privesc.Run(ctx, sudoPassword, "mkdir", "-p", "/etc/xbps.d"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return privesc.Run(ctx, sudoPassword, "sh", "-c",
|
||||||
|
fmt.Sprintf("printf '%%s\\n' %q > %s", repoLine, confPath))
|
||||||
|
}
|
||||||
|
|
||||||
// CopyGreeterFiles installs the dms-greeter wrapper and sets up cache directory
|
// CopyGreeterFiles installs the dms-greeter wrapper and sets up cache directory
|
||||||
func CopyGreeterFiles(dmsPath, compositor string, logFunc func(string), sudoPassword string) error {
|
func CopyGreeterFiles(dmsPath, compositor string, logFunc func(string), sudoPassword string) error {
|
||||||
if IsGreeterPackaged() {
|
if IsGreeterPackaged() {
|
||||||
@@ -1611,6 +1798,10 @@ func syncGreeterColorSource(homeDir, cacheDir string, state greeterThemeSyncStat
|
|||||||
}
|
}
|
||||||
|
|
||||||
func SyncDMSConfigs(dmsPath, compositor string, logFunc func(string), sudoPassword string) error {
|
func SyncDMSConfigs(dmsPath, compositor string, logFunc func(string), sudoPassword string) error {
|
||||||
|
if err := EnsureVoidLogindGreetdCommand(logFunc, sudoPassword); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
homeDir, err := os.UserHomeDir()
|
homeDir, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get user home directory: %w", err)
|
return fmt.Errorf("failed to get user home directory: %w", err)
|
||||||
@@ -2142,13 +2333,7 @@ vt = 1
|
|||||||
return fmt.Errorf("failed to read greetd config: %w", err)
|
return fmt.Errorf("failed to read greetd config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
wrapperCmd := resolveGreeterWrapperPath()
|
commandValue := buildGreetdCommand(resolveGreeterWrapperPath(), compositor, dmsPath, IsVoidLinux())
|
||||||
|
|
||||||
compositorLower := strings.ToLower(compositor)
|
|
||||||
commandValue := fmt.Sprintf("%s --command %s --cache-dir %s", wrapperCmd, compositorLower, GreeterCacheDir)
|
|
||||||
if dmsPath != "" {
|
|
||||||
commandValue = fmt.Sprintf("%s -p %s", commandValue, dmsPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
commandLine := fmt.Sprintf(`command = "%s"`, commandValue)
|
commandLine := fmt.Sprintf(`command = "%s"`, commandValue)
|
||||||
newConfig := upsertDefaultSession(configContent, greeterUser, commandLine)
|
newConfig := upsertDefaultSession(configContent, greeterUser, commandLine)
|
||||||
@@ -2160,6 +2345,84 @@ vt = 1
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildGreetdCommand(wrapperCmd, compositor, dmsPath string, useVoidLogind bool) string {
|
||||||
|
commandValue := fmt.Sprintf("%s --command %s --cache-dir %s", wrapperCmd, strings.ToLower(compositor), GreeterCacheDir)
|
||||||
|
if dmsPath != "" {
|
||||||
|
commandValue = fmt.Sprintf("%s -p %s", commandValue, dmsPath)
|
||||||
|
}
|
||||||
|
if useVoidLogind {
|
||||||
|
commandValue = "env LIBSEAT_BACKEND=logind DMS_VOID=1 " + commandValue
|
||||||
|
}
|
||||||
|
return commandValue
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureVoidLogindGreetdCommand migrates DMS greeter commands on Void.
|
||||||
|
func EnsureVoidLogindGreetdCommand(logFunc func(string), sudoPassword string) error {
|
||||||
|
if !IsVoidLinux() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const configPath = "/etc/greetd/config.toml"
|
||||||
|
data, err := os.ReadFile(configPath)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read greetd config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
configContent := string(data)
|
||||||
|
command := extractDefaultSessionCommand(configContent)
|
||||||
|
if command == "" || !strings.Contains(command, "dms-greeter") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
migratedCommand := voidLogindGreeterCommand(command)
|
||||||
|
if migratedCommand == command {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
greeterUser := extractDefaultSessionUser(configContent)
|
||||||
|
if greeterUser == "" {
|
||||||
|
greeterUser = DetectGreeterUser()
|
||||||
|
}
|
||||||
|
newConfig := upsertDefaultSession(configContent, greeterUser, fmt.Sprintf(`command = "%s"`, migratedCommand))
|
||||||
|
return writeGreetdConfig(configPath, newConfig, logFunc, sudoPassword, "✓ Updated existing Void greeter to use elogind")
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractDefaultSessionCommand(configContent string) string {
|
||||||
|
inDefaultSession := false
|
||||||
|
for line := range strings.SplitSeq(configContent, "\n") {
|
||||||
|
if section, ok := parseTomlSection(line); ok {
|
||||||
|
inDefaultSession = section == "default_session"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !inDefaultSession {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
trimmed := stripTomlComment(line)
|
||||||
|
if !strings.HasPrefix(trimmed, "command =") && !strings.HasPrefix(trimmed, "command=") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(trimmed, "=", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if command := strings.Trim(strings.TrimSpace(parts[1]), `"`); command != "" {
|
||||||
|
return command
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func voidLogindGreeterCommand(command string) string {
|
||||||
|
if strings.Contains(command, "LIBSEAT_BACKEND=logind") && strings.Contains(command, "DMS_VOID=1") {
|
||||||
|
return command
|
||||||
|
}
|
||||||
|
return "env LIBSEAT_BACKEND=logind DMS_VOID=1 " + command
|
||||||
|
}
|
||||||
|
|
||||||
func stripConfigFlag(command string) string {
|
func stripConfigFlag(command string) string {
|
||||||
for _, flag := range []string{" -C ", " --config "} {
|
for _, flag := range []string{" -C ", " --config "} {
|
||||||
idx := strings.Index(command, flag)
|
idx := strings.Index(command, flag)
|
||||||
@@ -2265,6 +2528,19 @@ func checkSystemdEnabled(service string) (string, error) {
|
|||||||
func DisableConflictingDisplayManagers(sudoPassword string, logFunc func(string)) error {
|
func DisableConflictingDisplayManagers(sudoPassword string, logFunc func(string)) error {
|
||||||
conflictingDMs := []string{"gdm", "gdm3", "lightdm", "sddm", "lxdm", "xdm", "cosmic-greeter"}
|
conflictingDMs := []string{"gdm", "gdm3", "lightdm", "sddm", "lxdm", "xdm", "cosmic-greeter"}
|
||||||
for _, dm := range conflictingDMs {
|
for _, dm := range conflictingDMs {
|
||||||
|
if isRunit() {
|
||||||
|
if !runitServiceEnabled(dm) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
logFunc(fmt.Sprintf("Disabling conflicting display manager: %s", dm))
|
||||||
|
if err := disableRunitService(dm, sudoPassword); err != nil {
|
||||||
|
logFunc(fmt.Sprintf("⚠ Warning: Failed to disable %s: %v", dm, err))
|
||||||
|
} else {
|
||||||
|
logFunc(fmt.Sprintf("✓ Disabled %s", dm))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
state, err := checkSystemdEnabled(dm)
|
state, err := checkSystemdEnabled(dm)
|
||||||
if err != nil || state == "" || state == "not-found" {
|
if err != nil || state == "" || state == "not-found" {
|
||||||
continue
|
continue
|
||||||
@@ -2284,6 +2560,23 @@ func DisableConflictingDisplayManagers(sudoPassword string, logFunc func(string)
|
|||||||
|
|
||||||
// EnableGreetd unmasks and enables greetd, forcing it over any other DM.
|
// EnableGreetd unmasks and enables greetd, forcing it over any other DM.
|
||||||
func EnableGreetd(sudoPassword string, logFunc func(string)) error {
|
func EnableGreetd(sudoPassword string, logFunc func(string)) error {
|
||||||
|
if isRunit() {
|
||||||
|
if !runitServiceInstalled("greetd") {
|
||||||
|
return fmt.Errorf("greetd service not found in %s; ensure greetd is installed", runitSvDir)
|
||||||
|
}
|
||||||
|
if IsVoidLinux() {
|
||||||
|
ensureVoidLogindGreeter(DetectGreeterUser(), sudoPassword, logFunc)
|
||||||
|
} else {
|
||||||
|
ensureRunitSeat(DetectGreeterUser(), sudoPassword, logFunc)
|
||||||
|
}
|
||||||
|
ensureGreetdPamRundir(sudoPassword, logFunc)
|
||||||
|
if err := enableRunitService("greetd", sudoPassword); err != nil {
|
||||||
|
return fmt.Errorf("failed to enable greetd: %w", err)
|
||||||
|
}
|
||||||
|
logFunc(fmt.Sprintf("✓ greetd enabled (%s)", runitServiceDir))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
state, err := checkSystemdEnabled("greetd")
|
state, err := checkSystemdEnabled("greetd")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to check greetd state: %w", err)
|
return fmt.Errorf("failed to check greetd state: %w", err)
|
||||||
@@ -2307,6 +2600,11 @@ func EnableGreetd(sudoPassword string, logFunc func(string)) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func EnsureGraphicalTarget(sudoPassword string, logFunc func(string)) error {
|
func EnsureGraphicalTarget(sudoPassword string, logFunc func(string)) error {
|
||||||
|
if isRunit() {
|
||||||
|
logFunc("✓ runit detected; no graphical target is needed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
cmd := exec.Command("systemctl", "get-default")
|
cmd := exec.Command("systemctl", "get-default")
|
||||||
output, err := cmd.Output()
|
output, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -111,15 +111,18 @@ command = "/usr/bin/dms-greeter --command niri"
|
|||||||
|
|
||||||
t.Run("inserts initial session", func(t *testing.T) {
|
t.Run("inserts initial session", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
got := upsertInitialSession(baseConfig, "alice", "niri", true)
|
got := upsertInitialSession(baseConfig, "alice", "/var/cache/dms-greeter", true)
|
||||||
if !strings.Contains(got, "[initial_session]") {
|
if !strings.Contains(got, "[initial_session]") {
|
||||||
t.Fatalf("expected [initial_session] section, got:\n%s", got)
|
t.Fatalf("expected [initial_session] section, got:\n%s", got)
|
||||||
}
|
}
|
||||||
if !strings.Contains(got, `user = "alice"`) {
|
if !strings.Contains(got, `user = "alice"`) {
|
||||||
t.Fatalf("expected alice user in initial session, got:\n%s", got)
|
t.Fatalf("expected alice user in initial session, got:\n%s", got)
|
||||||
}
|
}
|
||||||
if !strings.Contains(got, `env XDG_SESSION_TYPE=wayland sh -c 'exec niri'`) {
|
if !strings.Contains(got, `dms greeter launch-session --from-memory --cache-dir`) {
|
||||||
t.Fatalf("expected wrapped session command, got:\n%s", got)
|
t.Fatalf("expected stable launch-session command, got:\n%s", got)
|
||||||
|
}
|
||||||
|
if strings.Contains(got, `exec niri`) {
|
||||||
|
t.Fatalf("initial session must not bake the desktop Exec command, got:\n%s", got)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -130,12 +133,12 @@ command = "/usr/bin/dms-greeter --command niri"
|
|||||||
user = "bob"
|
user = "bob"
|
||||||
command = "old-command"
|
command = "old-command"
|
||||||
`
|
`
|
||||||
got := upsertInitialSession(existing, "alice", "Hyprland", true)
|
got := upsertInitialSession(existing, "alice", "/var/cache/dms-greeter", true)
|
||||||
if strings.Contains(got, `user = "bob"`) {
|
if strings.Contains(got, `user = "bob"`) {
|
||||||
t.Fatalf("expected bob to be replaced, got:\n%s", got)
|
t.Fatalf("expected bob to be replaced, got:\n%s", got)
|
||||||
}
|
}
|
||||||
if !strings.Contains(got, `exec Hyprland`) {
|
if !strings.Contains(got, `dms greeter launch-session --from-memory`) {
|
||||||
t.Fatalf("expected Hyprland command, got:\n%s", got)
|
t.Fatalf("expected launch-session command, got:\n%s", got)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -166,6 +169,56 @@ func TestStripDesktopExecCodes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildGreetdCommand(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
wrapper string
|
||||||
|
compositor string
|
||||||
|
dmsPath string
|
||||||
|
useVoidLogind bool
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "standard command",
|
||||||
|
wrapper: "/usr/bin/dms-greeter",
|
||||||
|
compositor: "Niri",
|
||||||
|
want: "/usr/bin/dms-greeter --command niri --cache-dir /var/cache/dms-greeter",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "void selects elogind and keeps custom DMS path",
|
||||||
|
wrapper: "/usr/bin/dms-greeter",
|
||||||
|
compositor: "Niri",
|
||||||
|
dmsPath: "/usr/share/quickshell/dms-greeter",
|
||||||
|
useVoidLogind: true,
|
||||||
|
want: "env LIBSEAT_BACKEND=logind DMS_VOID=1 /usr/bin/dms-greeter --command niri --cache-dir /var/cache/dms-greeter -p /usr/share/quickshell/dms-greeter",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
if got := buildGreetdCommand(tt.wrapper, tt.compositor, tt.dmsPath, tt.useVoidLogind); got != tt.want {
|
||||||
|
t.Fatalf("buildGreetdCommand() = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVoidLogindGreeterCommand(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const oldCommand = "/usr/bin/dms-greeter --command niri -C /etc/greetd/niri.kdl"
|
||||||
|
const want = "env LIBSEAT_BACKEND=logind DMS_VOID=1 " + oldCommand
|
||||||
|
if got := voidLogindGreeterCommand(oldCommand); got != want {
|
||||||
|
t.Fatalf("voidLogindGreeterCommand() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if got := voidLogindGreeterCommand(want); got != want {
|
||||||
|
t.Fatalf("voidLogindGreeterCommand() must be idempotent, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolveGreeterAutoLoginState(t *testing.T) {
|
func TestResolveGreeterAutoLoginState(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -179,15 +232,46 @@ func TestResolveGreeterAutoLoginState(t *testing.T) {
|
|||||||
}`)
|
}`)
|
||||||
writeTestFile(t, filepath.Join(cacheDir, ".local/state/memory.json"), `{
|
writeTestFile(t, filepath.Join(cacheDir, ".local/state/memory.json"), `{
|
||||||
"lastSuccessfulUser": "alice",
|
"lastSuccessfulUser": "alice",
|
||||||
"lastSessionExec": "niri"
|
"lastSessionDesktopId": "niri.desktop"
|
||||||
}`)
|
}`)
|
||||||
|
|
||||||
enabled, loginUser, sessionExec, err := resolveGreeterAutoLoginState(cacheDir, homeDir)
|
enabled, loginUser, sessionID, err := resolveGreeterAutoLoginState(cacheDir, homeDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("resolveGreeterAutoLoginState returned error: %v", err)
|
t.Fatalf("resolveGreeterAutoLoginState returned error: %v", err)
|
||||||
}
|
}
|
||||||
if !enabled || loginUser != "alice" || sessionExec != "niri" {
|
if !enabled || loginUser != "alice" || sessionID != "niri.desktop" {
|
||||||
t.Fatalf("got enabled=%v user=%q exec=%q", enabled, loginUser, sessionExec)
|
t.Fatalf("got enabled=%v user=%q session=%q", enabled, loginUser, sessionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveGreeterAutoLoginStateIgnoresStaleSessionExec(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cacheDir := t.TempDir()
|
||||||
|
homeDir := t.TempDir()
|
||||||
|
|
||||||
|
writeTestFile(t, filepath.Join(cacheDir, "settings.json"), `{
|
||||||
|
"greeterAutoLogin": true,
|
||||||
|
"greeterRememberLastUser": true,
|
||||||
|
"greeterRememberLastSession": true
|
||||||
|
}`)
|
||||||
|
writeTestFile(t, filepath.Join(cacheDir, ".local/state/memory.json"), `{
|
||||||
|
"lastSuccessfulUser": "alice",
|
||||||
|
"lastSessionId": "/nix/store/old-session/share/wayland-sessions/example.desktop",
|
||||||
|
"lastSessionExec": "/nix/store/old-session/bin/start-example-session"
|
||||||
|
}`)
|
||||||
|
|
||||||
|
enabled, loginUser, sessionID, err := resolveGreeterAutoLoginState(cacheDir, homeDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolveGreeterAutoLoginState returned error: %v", err)
|
||||||
|
}
|
||||||
|
if !enabled || loginUser != "alice" || sessionID != "example.desktop" {
|
||||||
|
t.Fatalf("got enabled=%v user=%q session=%q", enabled, loginUser, sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := upsertInitialSession("", loginUser, cacheDir, true)
|
||||||
|
if strings.Contains(got, "/nix/store/old-session") {
|
||||||
|
t.Fatalf("initial session must not include stale store path, got:\n%s", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,12 +292,35 @@ func TestResolveGreeterAutoLoginStateIgnoresMemoryFlag(t *testing.T) {
|
|||||||
"lastSessionExec": "niri"
|
"lastSessionExec": "niri"
|
||||||
}`)
|
}`)
|
||||||
|
|
||||||
enabled, loginUser, sessionExec, err := resolveGreeterAutoLoginState(cacheDir, homeDir)
|
enabled, loginUser, sessionID, err := resolveGreeterAutoLoginState(cacheDir, homeDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("resolveGreeterAutoLoginState returned error: %v", err)
|
t.Fatalf("resolveGreeterAutoLoginState returned error: %v", err)
|
||||||
}
|
}
|
||||||
if enabled || loginUser != "" || sessionExec != "" {
|
if enabled || loginUser != "" || sessionID != "" {
|
||||||
t.Fatalf("expected disabled with empty user/exec, got enabled=%v user=%q exec=%q", enabled, loginUser, sessionExec)
|
t.Fatalf("expected disabled with empty user/session, got enabled=%v user=%q session=%q", enabled, loginUser, sessionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveSessionExecInDirs(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
oldDir := filepath.Join(t.TempDir(), "wayland-sessions")
|
||||||
|
newDir := filepath.Join(t.TempDir(), "wayland-sessions")
|
||||||
|
writeTestFile(t, filepath.Join(oldDir, "example.desktop"), `[Desktop Entry]
|
||||||
|
Name=Example Session
|
||||||
|
Exec=/nix/store/old-session/bin/start-example-session
|
||||||
|
`)
|
||||||
|
writeTestFile(t, filepath.Join(newDir, "example.desktop"), `[Desktop Entry]
|
||||||
|
Name=Example Session
|
||||||
|
Exec=/run/current-system/sw/bin/start-example-session
|
||||||
|
`)
|
||||||
|
|
||||||
|
got, err := resolveSessionExecInDirs("example.desktop", []string{newDir, oldDir})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolveSessionExecInDirs returned error: %v", err)
|
||||||
|
}
|
||||||
|
if got != "/run/current-system/sw/bin/start-example-session" {
|
||||||
|
t.Fatalf("resolveSessionExecInDirs = %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
package greeter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
func sessionDesktopIDFromPath(path string) string {
|
||||||
|
id := strings.TrimSpace(path)
|
||||||
|
if id == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(id, "/\\") {
|
||||||
|
id = filepath.Base(id)
|
||||||
|
}
|
||||||
|
if id == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(id, ".desktop") {
|
||||||
|
id += ".desktop"
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func sessionDesktopIDFromMemory(mem greeterAutoLoginMemory) string {
|
||||||
|
if id := sessionDesktopIDFromPath(mem.LastSessionDesktopID); id != "" {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
return sessionDesktopIDFromPath(mem.LastSessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sessionDesktopDirs() []string {
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
dirs := make([]string, 0, 8)
|
||||||
|
|
||||||
|
addBase := func(base string) {
|
||||||
|
base = strings.TrimSpace(base)
|
||||||
|
if base == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, sub := range []string{"wayland-sessions", "xsessions"} {
|
||||||
|
dir := filepath.Join(base, sub)
|
||||||
|
if seen[dir] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[dir] = true
|
||||||
|
dirs = append(dirs, dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if dataHome := os.Getenv("XDG_DATA_HOME"); dataHome != "" {
|
||||||
|
addBase(dataHome)
|
||||||
|
} else if home, err := os.UserHomeDir(); err == nil && home != "" {
|
||||||
|
addBase(filepath.Join(home, ".local", "share"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if dataDirs := os.Getenv("XDG_DATA_DIRS"); dataDirs != "" {
|
||||||
|
for _, dir := range strings.Split(dataDirs, ":") {
|
||||||
|
addBase(dir)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
addBase("/usr/local/share")
|
||||||
|
addBase("/usr/share")
|
||||||
|
}
|
||||||
|
|
||||||
|
return dirs
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveSessionExec(sessionID string) (string, error) {
|
||||||
|
return resolveSessionExecInDirs(sessionID, sessionDesktopDirs())
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveSessionExecInDirs(sessionID string, dirs []string) (string, error) {
|
||||||
|
id := sessionDesktopIDFromPath(sessionID)
|
||||||
|
if id == "" {
|
||||||
|
return "", fmt.Errorf("session id is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, dir := range dirs {
|
||||||
|
path := filepath.Join(dir, id)
|
||||||
|
execLine, err := execFromDesktopFile(path)
|
||||||
|
if err == nil {
|
||||||
|
return execLine, nil
|
||||||
|
}
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("session desktop file %q was not found", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseExecString splits a Desktop Entry Exec= value into argv without
|
||||||
|
// involving a shell, mirroring quickshell's DesktopEntry::parseExecString
|
||||||
|
// (string quoting, value escapes, field code stripping).
|
||||||
|
func parseExecString(execLine string) []string {
|
||||||
|
var args []string
|
||||||
|
var cur strings.Builder
|
||||||
|
inString := false
|
||||||
|
escape := 0
|
||||||
|
percent := false
|
||||||
|
|
||||||
|
for _, c := range execLine {
|
||||||
|
switch {
|
||||||
|
case escape == 0 && c == '\\':
|
||||||
|
escape = 1
|
||||||
|
case inString:
|
||||||
|
switch {
|
||||||
|
case c == '\\':
|
||||||
|
escape++
|
||||||
|
if escape == 4 {
|
||||||
|
cur.WriteByte('\\')
|
||||||
|
escape = 0
|
||||||
|
}
|
||||||
|
case escape == 2:
|
||||||
|
cur.WriteRune(c)
|
||||||
|
escape = 0
|
||||||
|
case escape != 0:
|
||||||
|
switch c {
|
||||||
|
case 's':
|
||||||
|
cur.WriteByte(' ')
|
||||||
|
case 'n':
|
||||||
|
cur.WriteByte('\n')
|
||||||
|
case 't':
|
||||||
|
cur.WriteByte('\t')
|
||||||
|
case 'r':
|
||||||
|
cur.WriteByte('\r')
|
||||||
|
default:
|
||||||
|
cur.WriteRune(c)
|
||||||
|
}
|
||||||
|
escape = 0
|
||||||
|
case c == '"' || c == '\'':
|
||||||
|
inString = false
|
||||||
|
default:
|
||||||
|
cur.WriteRune(c)
|
||||||
|
}
|
||||||
|
case escape != 0:
|
||||||
|
cur.WriteRune(c)
|
||||||
|
escape = 0
|
||||||
|
case percent:
|
||||||
|
if c == '%' {
|
||||||
|
cur.WriteByte('%')
|
||||||
|
}
|
||||||
|
percent = false
|
||||||
|
case c == '%':
|
||||||
|
percent = true
|
||||||
|
case c == '"' || c == '\'':
|
||||||
|
inString = true
|
||||||
|
case c == ' ':
|
||||||
|
if cur.Len() > 0 {
|
||||||
|
args = append(args, cur.String())
|
||||||
|
cur.Reset()
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
cur.WriteRune(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cur.Len() > 0 {
|
||||||
|
args = append(args, cur.String())
|
||||||
|
}
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
func LaunchSessionByID(sessionID string) error {
|
||||||
|
execLine, err := ResolveSessionExec(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
argv := parseExecString(strings.TrimSpace(execLine))
|
||||||
|
if len(argv) == 0 {
|
||||||
|
return fmt.Errorf("session %q has an empty Exec command", sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, err := exec.LookPath(argv[0])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("session %q command %q not found: %w", sessionID, argv[0], err)
|
||||||
|
}
|
||||||
|
|
||||||
|
env := append(os.Environ(), "XDG_SESSION_TYPE=wayland")
|
||||||
|
return syscall.Exec(resolved, argv, env)
|
||||||
|
}
|
||||||
|
|
||||||
|
func LaunchSessionFromMemory(cacheDir, homeDir string) error {
|
||||||
|
enabled, _, sessionID, err := resolveGreeterAutoLoginState(cacheDir, homeDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !enabled {
|
||||||
|
return fmt.Errorf("greeter auto-login is disabled")
|
||||||
|
}
|
||||||
|
if sessionID == "" {
|
||||||
|
return fmt.Errorf("greeter auto-login has no remembered session")
|
||||||
|
}
|
||||||
|
return LaunchSessionByID(sessionID)
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package greeter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseExecString(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
exec string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"plain", "niri --session", []string{"niri", "--session"}},
|
||||||
|
{"extra spaces", "niri --session", []string{"niri", "--session"}},
|
||||||
|
{"double quoted arg", `env "with space" run`, []string{"env", "with space", "run"}},
|
||||||
|
{"single quoted arg", `env 'with space' run`, []string{"env", "with space", "run"}},
|
||||||
|
{"escaped quote in quotes", `sh "say \\"hi\\""`, []string{"sh", `say "hi"`}},
|
||||||
|
{"field code dropped", "gnome-session %U", []string{"gnome-session"}},
|
||||||
|
{"field code mid-arg", "app --url=%u --run", []string{"app", "--url=", "--run"}},
|
||||||
|
{"literal percent", "app 100%% done", []string{"app", "100%", "done"}},
|
||||||
|
{"shell metachars stay literal", "sh -c $(reboot); echo", []string{"sh", "-c", "$(reboot);", "echo"}},
|
||||||
|
{"empty", "", nil},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := parseExecString(tt.exec); !reflect.DeepEqual(got, tt.want) {
|
||||||
|
t.Fatalf("parseExecString(%q) = %#v, want %#v", tt.exec, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecFromDesktopFileOnlyReadsDesktopEntryGroup(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "example.desktop")
|
||||||
|
writeTestFile(t, path, `[Desktop Action other]
|
||||||
|
Exec=/wrong/binary
|
||||||
|
|
||||||
|
[Desktop Entry]
|
||||||
|
Name=Example
|
||||||
|
Exec = /right/binary --flag
|
||||||
|
`)
|
||||||
|
|
||||||
|
got, err := execFromDesktopFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("execFromDesktopFile returned error: %v", err)
|
||||||
|
}
|
||||||
|
if got != "/right/binary --flag" {
|
||||||
|
t.Fatalf("execFromDesktopFile = %q, want %q", got, "/right/binary --flag")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,8 @@ type Config struct {
|
|||||||
ReplaceConfigs []string // specific configs to deploy (e.g. "niri", "ghostty")
|
ReplaceConfigs []string // specific configs to deploy (e.g. "niri", "ghostty")
|
||||||
ReplaceConfigsAll bool // deploy/replace all configurations
|
ReplaceConfigsAll bool // deploy/replace all configurations
|
||||||
Yes bool
|
Yes bool
|
||||||
|
DankSearch bool // install danksearch and enable its user service
|
||||||
|
DankCalendar bool // install dankcalendar
|
||||||
}
|
}
|
||||||
|
|
||||||
// Runner orchestrates unattended (headless) installation.
|
// Runner orchestrates unattended (headless) installation.
|
||||||
@@ -214,6 +216,11 @@ func (r *Runner) Run() error {
|
|||||||
return fmt.Errorf("package installation failed: %w", err)
|
return fmt.Errorf("package installation failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useSystemd := true
|
||||||
|
if distroConfig, exists := distros.Registry[osInfo.Distribution.ID]; exists && distroConfig.Family == distros.FamilyVoid {
|
||||||
|
useSystemd = false
|
||||||
|
}
|
||||||
|
|
||||||
// 9. Greeter setup (if dms-greeter was included)
|
// 9. Greeter setup (if dms-greeter was included)
|
||||||
if !disabledItems["dms-greeter"] && r.depExists(dependencies, "dms-greeter") {
|
if !disabledItems["dms-greeter"] && r.depExists(dependencies, "dms-greeter") {
|
||||||
compositorName := "niri"
|
compositorName := "niri"
|
||||||
@@ -231,18 +238,32 @@ func (r *Runner) Run() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 9b. danksearch service setup (if danksearch was included)
|
||||||
|
if useSystemd && !disabledItems["danksearch"] && r.depExists(dependencies, "danksearch") {
|
||||||
|
fmt.Fprintln(os.Stdout, "Enabling danksearch service...")
|
||||||
|
logFunc := func(line string) {
|
||||||
|
r.log(line)
|
||||||
|
fmt.Fprintf(os.Stdout, " danksearch: %s\n", line)
|
||||||
|
}
|
||||||
|
if err := distros.SetupDsearchService(context.Background(), logFunc); err != nil {
|
||||||
|
// Non-fatal, matching greeter behavior
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: danksearch service setup issue (non-fatal): %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 10. Deploy configurations
|
// 10. Deploy configurations
|
||||||
fmt.Fprintln(os.Stdout, "Deploying configurations...")
|
fmt.Fprintln(os.Stdout, "Deploying configurations...")
|
||||||
r.log("Starting configuration deployment")
|
r.log("Starting configuration deployment")
|
||||||
|
|
||||||
deployer := config.NewConfigDeployer(r.logChan)
|
deployer := config.NewConfigDeployer(r.logChan)
|
||||||
results, err := deployer.DeployConfigurationsSelectiveWithReinstalls(
|
results, err := deployer.DeployConfigurationsSelectiveWithReinstallsAndSystemd(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
wm,
|
wm,
|
||||||
terminal,
|
terminal,
|
||||||
dependencies,
|
dependencies,
|
||||||
replaceConfigs,
|
replaceConfigs,
|
||||||
reinstallItems,
|
reinstallItems,
|
||||||
|
useSystemd,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("configuration deployment failed: %w", err)
|
return fmt.Errorf("configuration deployment failed: %w", err)
|
||||||
@@ -267,19 +288,31 @@ func (r *Runner) Run() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildDisabledItems computes the set of dependencies that should be skipped
|
// buildDisabledItems computes the set of dependencies that should be skipped
|
||||||
// during installation, applying the --include-deps and --exclude-deps filters.
|
// during installation. Optional components are opt-in (disabled by default),
|
||||||
// dms-greeter is disabled by default (opt-in), matching TUI behavior.
|
// then re-enabled by the dedicated flags and --include-deps.
|
||||||
func (r *Runner) buildDisabledItems(dependencies []deps.Dependency) (map[string]bool, error) {
|
func (r *Runner) buildDisabledItems(dependencies []deps.Dependency) (map[string]bool, error) {
|
||||||
disabledItems := make(map[string]bool)
|
disabledItems := make(map[string]bool)
|
||||||
|
|
||||||
// dms-greeter is opt-in (disabled by default), matching TUI behavior
|
|
||||||
for i := range dependencies {
|
for i := range dependencies {
|
||||||
if dependencies[i].Name == "dms-greeter" {
|
if !dependencies[i].Required {
|
||||||
disabledItems["dms-greeter"] = true
|
disabledItems[dependencies[i].Name] = true
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dedicated flags resolve before include/exclude
|
||||||
|
if r.cfg.DankSearch {
|
||||||
|
if !r.depExists(dependencies, "danksearch") {
|
||||||
|
return nil, fmt.Errorf("--danksearch: not available on this distribution")
|
||||||
|
}
|
||||||
|
delete(disabledItems, "danksearch")
|
||||||
|
}
|
||||||
|
if r.cfg.DankCalendar {
|
||||||
|
if !r.depExists(dependencies, "dankcalendar") {
|
||||||
|
return nil, fmt.Errorf("--dankcalendar: not available on this distribution")
|
||||||
|
}
|
||||||
|
delete(disabledItems, "dankcalendar")
|
||||||
|
}
|
||||||
|
|
||||||
// Process --include-deps (enable items that are disabled by default)
|
// Process --include-deps (enable items that are disabled by default)
|
||||||
for _, name := range r.cfg.IncludeDeps {
|
for _, name := range r.cfg.IncludeDeps {
|
||||||
name = strings.TrimSpace(name)
|
name = strings.TrimSpace(name)
|
||||||
|
|||||||
@@ -342,17 +342,21 @@ func TestConfigReplaceConfigsStoredCorrectly(t *testing.T) {
|
|||||||
|
|
||||||
func TestBuildDisabledItems(t *testing.T) {
|
func TestBuildDisabledItems(t *testing.T) {
|
||||||
dependencies := []deps.Dependency{
|
dependencies := []deps.Dependency{
|
||||||
{Name: "niri", Status: deps.StatusInstalled},
|
{Name: "niri", Status: deps.StatusInstalled, Required: true},
|
||||||
{Name: "ghostty", Status: deps.StatusMissing},
|
{Name: "ghostty", Status: deps.StatusMissing, Required: true},
|
||||||
{Name: "dms (DankMaterialShell)", Status: deps.StatusInstalled},
|
{Name: "dms (DankMaterialShell)", Status: deps.StatusInstalled, Required: true},
|
||||||
{Name: "dms-greeter", Status: deps.StatusMissing},
|
{Name: "dms-greeter", Status: deps.StatusMissing},
|
||||||
{Name: "waybar", Status: deps.StatusMissing},
|
{Name: "danksearch", Status: deps.StatusMissing},
|
||||||
|
{Name: "dankcalendar", Status: deps.StatusMissing},
|
||||||
|
{Name: "waybar", Status: deps.StatusMissing, Required: true},
|
||||||
}
|
}
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
includeDeps []string
|
includeDeps []string
|
||||||
excludeDeps []string
|
excludeDeps []string
|
||||||
|
dankSearch bool
|
||||||
|
dankCalendar bool
|
||||||
deps []deps.Dependency // nil means use the shared fixture
|
deps []deps.Dependency // nil means use the shared fixture
|
||||||
wantErr bool
|
wantErr bool
|
||||||
errContains string // substring expected in error message
|
errContains string // substring expected in error message
|
||||||
@@ -360,19 +364,20 @@ func TestBuildDisabledItems(t *testing.T) {
|
|||||||
wantEnabled []string // dep names that should NOT be in disabledItems (extra check)
|
wantEnabled []string // dep names that should NOT be in disabledItems (extra check)
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "no flags set, dms-greeter disabled by default",
|
name: "no flags set, optional deps disabled by default",
|
||||||
wantDisabled: []string{"dms-greeter"},
|
wantDisabled: []string{"dms-greeter", "danksearch", "dankcalendar"},
|
||||||
wantEnabled: []string{"niri", "ghostty", "waybar"},
|
wantEnabled: []string{"niri", "ghostty", "waybar"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "include dms-greeter enables it",
|
name: "include dms-greeter enables it",
|
||||||
includeDeps: []string{"dms-greeter"},
|
includeDeps: []string{"dms-greeter"},
|
||||||
wantEnabled: []string{"dms-greeter"},
|
wantEnabled: []string{"dms-greeter"},
|
||||||
|
wantDisabled: []string{"danksearch", "dankcalendar"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "exclude a regular dep",
|
name: "exclude a regular dep",
|
||||||
excludeDeps: []string{"waybar"},
|
excludeDeps: []string{"waybar"},
|
||||||
wantDisabled: []string{"dms-greeter", "waybar"},
|
wantDisabled: []string{"dms-greeter", "danksearch", "dankcalendar", "waybar"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "include unknown dep returns error",
|
name: "include unknown dep returns error",
|
||||||
@@ -402,14 +407,41 @@ func TestBuildDisabledItems(t *testing.T) {
|
|||||||
name: "whitespace entries are skipped",
|
name: "whitespace entries are skipped",
|
||||||
includeDeps: []string{" ", "dms-greeter"},
|
includeDeps: []string{" ", "dms-greeter"},
|
||||||
wantEnabled: []string{"dms-greeter"},
|
wantEnabled: []string{"dms-greeter"},
|
||||||
|
wantDisabled: []string{"danksearch", "dankcalendar"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "no dms-greeter in deps, nothing disabled by default",
|
name: "no optional deps present, nothing disabled by default",
|
||||||
deps: []deps.Dependency{
|
deps: []deps.Dependency{
|
||||||
{Name: "niri", Status: deps.StatusInstalled},
|
{Name: "niri", Status: deps.StatusInstalled, Required: true},
|
||||||
},
|
},
|
||||||
wantEnabled: []string{"niri"},
|
wantEnabled: []string{"niri"},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "danksearch flag enables it",
|
||||||
|
dankSearch: true,
|
||||||
|
wantEnabled: []string{"danksearch"},
|
||||||
|
wantDisabled: []string{"dms-greeter", "dankcalendar"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "dankcalendar flag enables it",
|
||||||
|
dankCalendar: true,
|
||||||
|
wantEnabled: []string{"dankcalendar"},
|
||||||
|
wantDisabled: []string{"dms-greeter", "danksearch"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "danksearch flag when unavailable errors",
|
||||||
|
dankSearch: true,
|
||||||
|
deps: []deps.Dependency{{Name: "niri", Status: deps.StatusInstalled, Required: true}},
|
||||||
|
wantErr: true,
|
||||||
|
errContains: "--danksearch",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "dankcalendar flag when unavailable errors",
|
||||||
|
dankCalendar: true,
|
||||||
|
deps: []deps.Dependency{{Name: "niri", Status: deps.StatusInstalled, Required: true}},
|
||||||
|
wantErr: true,
|
||||||
|
errContains: "--dankcalendar",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -417,6 +449,8 @@ func TestBuildDisabledItems(t *testing.T) {
|
|||||||
r := NewRunner(Config{
|
r := NewRunner(Config{
|
||||||
IncludeDeps: tt.includeDeps,
|
IncludeDeps: tt.includeDeps,
|
||||||
ExcludeDeps: tt.excludeDeps,
|
ExcludeDeps: tt.excludeDeps,
|
||||||
|
DankSearch: tt.dankSearch,
|
||||||
|
DankCalendar: tt.dankCalendar,
|
||||||
})
|
})
|
||||||
d := tt.deps
|
d := tt.deps
|
||||||
if d == nil {
|
if d == nil {
|
||||||
|
|||||||
@@ -190,9 +190,13 @@ func (h *HyprlandProvider) formatRawAction(dispatcher, params string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *HyprlandProvider) formatKey(kb *HyprlandKeyBinding) string {
|
func (h *HyprlandProvider) formatKey(kb *HyprlandKeyBinding) string {
|
||||||
|
key := kb.Key
|
||||||
|
if canonical, ok := hyprlandScrollToCanonical(key); ok {
|
||||||
|
key = canonical
|
||||||
|
}
|
||||||
parts := make([]string, 0, len(kb.Mods)+1)
|
parts := make([]string, 0, len(kb.Mods)+1)
|
||||||
parts = append(parts, kb.Mods...)
|
parts = append(parts, kb.Mods...)
|
||||||
parts = append(parts, kb.Key)
|
parts = append(parts, key)
|
||||||
return strings.Join(parts, "+")
|
return strings.Join(parts, "+")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,6 +299,9 @@ type hyprlandOverrideBind struct {
|
|||||||
Options map[string]any
|
Options map[string]any
|
||||||
// Unbind: negative override (hl.unbind only, no rebind).
|
// Unbind: negative override (hl.unbind only, no rebind).
|
||||||
Unbind bool
|
Unbind bool
|
||||||
|
// RawLuaAction: Action is a custom hl.* Lua expression round-tripped from an
|
||||||
|
// existing Lua override; re-emit it verbatim instead of quoting it.
|
||||||
|
RawLuaAction bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *HyprlandProvider) ensureWritableConfig() error {
|
func (h *HyprlandProvider) ensureWritableConfig() error {
|
||||||
@@ -411,6 +418,9 @@ func normalizeLuaBindKeyPart(part string) string {
|
|||||||
case "alt", "mod1":
|
case "alt", "mod1":
|
||||||
return "ALT"
|
return "ALT"
|
||||||
}
|
}
|
||||||
|
if native, ok := hyprlandScrollToNative(part); ok {
|
||||||
|
return native
|
||||||
|
}
|
||||||
if len(part) == 1 {
|
if len(part) == 1 {
|
||||||
return strings.ToUpper(part)
|
return strings.ToUpper(part)
|
||||||
}
|
}
|
||||||
@@ -1039,18 +1049,26 @@ func luaActionStringFromHyprlangAction(action string) string {
|
|||||||
if expr, ok := luaActionStringFromKnownHyprlandAction(action); ok {
|
if expr, ok := luaActionStringFromKnownHyprlandAction(action); ok {
|
||||||
return expr
|
return expr
|
||||||
}
|
}
|
||||||
return action
|
// Unrecognized dispatchers are freeform text, not Lua; forward them to
|
||||||
|
// hyprctl quoted so a stray `"` can't produce broken Lua output.
|
||||||
|
return luaHyprctlDispatchFunction(action)
|
||||||
}
|
}
|
||||||
|
|
||||||
func luaExprToInternalAction(expr string) string {
|
// luaExprToInternalAction converts a parsed Lua bind expression back into
|
||||||
|
// "dispatcher params" text. isRawLua reports that expr matched no known hl.*
|
||||||
|
// shape and must be re-emitted verbatim as Lua on write-back.
|
||||||
|
func luaExprToInternalAction(expr string) (action string, isRawLua bool) {
|
||||||
d, p := luaExprToDispatcherParams(expr)
|
d, p := luaExprToDispatcherParams(expr)
|
||||||
|
if d == expr && p == "" {
|
||||||
|
return expr, true
|
||||||
|
}
|
||||||
if d == "exec" && p != "" && !strings.HasPrefix(p, "hyprctl dispatch lua:") {
|
if d == "exec" && p != "" && !strings.HasPrefix(p, "hyprctl dispatch lua:") {
|
||||||
return "exec " + p
|
return "exec " + p, false
|
||||||
}
|
}
|
||||||
if p != "" {
|
if p != "" {
|
||||||
return d + " " + p
|
return d + " " + p, false
|
||||||
}
|
}
|
||||||
return d
|
return d, false
|
||||||
}
|
}
|
||||||
|
|
||||||
func luaBindOptions(bind *hyprlandOverrideBind) []string {
|
func luaBindOptions(bind *hyprlandOverrideBind) []string {
|
||||||
@@ -1068,20 +1086,25 @@ func luaBindOptions(bind *hyprlandOverrideBind) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func writeLuaBindLine(sb *strings.Builder, bind *hyprlandOverrideBind) {
|
func writeLuaBindLine(sb *strings.Builder, bind *hyprlandOverrideBind) {
|
||||||
key := formatLuaBindKey(bind.Key)
|
key := strconv.Quote(formatLuaBindKey(bind.Key))
|
||||||
if bind.Unbind {
|
if bind.Unbind {
|
||||||
fmt.Fprintf(sb, `hl.unbind("%s")`, key)
|
fmt.Fprintf(sb, `hl.unbind(%s)`, key)
|
||||||
sb.WriteByte('\n')
|
sb.WriteByte('\n')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
expr := luaActionStringFromHyprlangAction(bind.Action)
|
var expr string
|
||||||
|
if bind.RawLuaAction {
|
||||||
|
expr = bind.Action
|
||||||
|
} else {
|
||||||
|
expr = luaActionStringFromHyprlangAction(bind.Action)
|
||||||
|
}
|
||||||
opts := luaBindOptions(bind)
|
opts := luaBindOptions(bind)
|
||||||
fmt.Fprintf(sb, `hl.unbind("%s")`, key)
|
fmt.Fprintf(sb, `hl.unbind(%s)`, key)
|
||||||
sb.WriteByte('\n')
|
sb.WriteByte('\n')
|
||||||
if len(opts) > 0 {
|
if len(opts) > 0 {
|
||||||
fmt.Fprintf(sb, `hl.bind("%s", %s, { %s })`, key, expr, strings.Join(opts, ", "))
|
fmt.Fprintf(sb, `hl.bind(%s, %s, { %s })`, key, expr, strings.Join(opts, ", "))
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(sb, `hl.bind("%s", %s)`, key, expr)
|
fmt.Fprintf(sb, `hl.bind(%s, %s)`, key, expr)
|
||||||
}
|
}
|
||||||
sb.WriteByte('\n')
|
sb.WriteByte('\n')
|
||||||
}
|
}
|
||||||
@@ -1097,7 +1120,7 @@ func parseLuaBindOverrideLine(line string) (*hyprlandOverrideBind, bool) {
|
|||||||
}
|
}
|
||||||
internalKey := luaKeyComboToInternalKey(kbc)
|
internalKey := luaKeyComboToInternalKey(kbc)
|
||||||
|
|
||||||
action := luaExprToInternalAction(actionExpr)
|
action, isRawLua := luaExprToInternalAction(actionExpr)
|
||||||
flags := luaBindOptFlags(optSuffix)
|
flags := luaBindOptFlags(optSuffix)
|
||||||
description := luaBindOptDescription(optSuffix)
|
description := luaBindOptDescription(optSuffix)
|
||||||
if description == "" {
|
if description == "" {
|
||||||
@@ -1108,6 +1131,7 @@ func parseLuaBindOverrideLine(line string) (*hyprlandOverrideBind, bool) {
|
|||||||
Action: action,
|
Action: action,
|
||||||
Description: description,
|
Description: description,
|
||||||
Flags: flags,
|
Flags: flags,
|
||||||
|
RawLuaAction: isRawLua,
|
||||||
}, true
|
}, true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1130,6 +1154,11 @@ func parseLuaUnbindLine(line string) (string, bool) {
|
|||||||
|
|
||||||
func luaKeyComboToInternalKey(combo string) string {
|
func luaKeyComboToInternalKey(combo string) string {
|
||||||
parts := strings.Fields(strings.ReplaceAll(strings.ReplaceAll(combo, "+", " "), " ", " "))
|
parts := strings.Fields(strings.ReplaceAll(strings.ReplaceAll(combo, "+", " "), " ", " "))
|
||||||
|
for i, part := range parts {
|
||||||
|
if canonical, ok := hyprlandScrollToCanonical(part); ok {
|
||||||
|
parts[i] = canonical
|
||||||
|
}
|
||||||
|
}
|
||||||
return strings.Join(parts, "+")
|
return strings.Join(parts, "+")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1142,7 +1171,7 @@ func readLuaOrHyprlangOverride(path string) (map[string]*hyprlandOverrideBind, e
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
lines := strings.Split(string(data), "\n")
|
lines := expandLuaConfigLines(strings.Split(string(data), "\n"))
|
||||||
parser := NewHyprlandParser("")
|
parser := NewHyprlandParser("")
|
||||||
pendingUnbinds := make(map[string]string)
|
pendingUnbinds := make(map[string]string)
|
||||||
for _, line := range lines {
|
for _, line := range lines {
|
||||||
|
|||||||
@@ -0,0 +1,414 @@
|
|||||||
|
package providers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"maps"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Lua configs can express binds dynamically: variables (mainMod .. " + C"),
|
||||||
|
// tostring() calls, and numeric for loops (workspace binds). This resolves such
|
||||||
|
// expressions to literal key combos so the static bind parser can read them.
|
||||||
|
|
||||||
|
const luaMaxLoopIterations = 1000
|
||||||
|
|
||||||
|
var (
|
||||||
|
luaAssignRE = regexp.MustCompile(`^(?:local\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)$`)
|
||||||
|
luaForRE = regexp.MustCompile(`^for\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(-?\d+)\s*,\s*(-?\d+)\s*(?:,\s*(-?\d+)\s*)?do\b(.*)$`)
|
||||||
|
luaTostringRE = regexp.MustCompile(`to(?:string|number)\s*\(\s*("(?:\\.|[^"])*"|'(?:\\.|[^'])*'|-?\d+(?:\.\d+)?)\s*\)`)
|
||||||
|
luaBlockOpenRE = regexp.MustCompile(`\b(?:function|for|while|if)\b`)
|
||||||
|
luaBlockCloseRE = regexp.MustCompile(`\bend\b`)
|
||||||
|
luaNumberRE = regexp.MustCompile(`^-?\d+(?:\.\d+)?$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
type luaVarEnv map[string]string
|
||||||
|
|
||||||
|
type luaForHeader struct {
|
||||||
|
varName string
|
||||||
|
start int
|
||||||
|
stop int
|
||||||
|
step int
|
||||||
|
inline string
|
||||||
|
}
|
||||||
|
|
||||||
|
func expandLuaConfigLines(lines []string) []string {
|
||||||
|
return expandLuaBlock(lines, luaVarEnv{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func expandLuaBlock(lines []string, env luaVarEnv) []string {
|
||||||
|
out := make([]string, 0, len(lines))
|
||||||
|
for i := 0; i < len(lines); i++ {
|
||||||
|
code := strings.TrimSpace(luaStripLineComment(lines[i]))
|
||||||
|
|
||||||
|
if name, value, ok := parseLuaStringAssignment(code, env); ok {
|
||||||
|
env[name] = value
|
||||||
|
out = append(out, lines[i])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
header, ok := parseLuaForHeader(code)
|
||||||
|
if !ok {
|
||||||
|
out = append(out, resolveLuaDynamicLine(lines[i], env))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
body, consumed, complete := collectLuaForBody(header, lines, i)
|
||||||
|
if !complete {
|
||||||
|
out = append(out, resolveLuaDynamicLine(lines[i], env))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, expandLuaForLoop(header, body, env)...)
|
||||||
|
i = consumed
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseLuaStringAssignment(code string, env luaVarEnv) (name, value string, ok bool) {
|
||||||
|
m := luaAssignRE.FindStringSubmatch(code)
|
||||||
|
if m == nil {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
value, ok = evalLuaConcat(m[2], env)
|
||||||
|
if !ok {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return m[1], value, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseLuaForHeader(code string) (luaForHeader, bool) {
|
||||||
|
m := luaForRE.FindStringSubmatch(code)
|
||||||
|
if m == nil {
|
||||||
|
return luaForHeader{}, false
|
||||||
|
}
|
||||||
|
start, _ := strconv.Atoi(m[2])
|
||||||
|
stop, _ := strconv.Atoi(m[3])
|
||||||
|
step := 1
|
||||||
|
if m[4] != "" {
|
||||||
|
step, _ = strconv.Atoi(m[4])
|
||||||
|
}
|
||||||
|
if step == 0 {
|
||||||
|
return luaForHeader{}, false
|
||||||
|
}
|
||||||
|
return luaForHeader{varName: m[1], start: start, stop: stop, step: step, inline: strings.TrimSpace(m[5])}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectLuaForBody(header luaForHeader, lines []string, headerIdx int) (body []string, consumed int, complete bool) {
|
||||||
|
depth := 1
|
||||||
|
if header.inline != "" {
|
||||||
|
delta := luaBlockDelta(header.inline)
|
||||||
|
if depth+delta <= 0 {
|
||||||
|
if stmt := strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(header.inline), "end")); stmt != "" {
|
||||||
|
body = append(body, stmt)
|
||||||
|
}
|
||||||
|
return body, headerIdx, true
|
||||||
|
}
|
||||||
|
depth += delta
|
||||||
|
body = append(body, header.inline)
|
||||||
|
}
|
||||||
|
for j := headerIdx + 1; j < len(lines); j++ {
|
||||||
|
delta := luaBlockDelta(lines[j])
|
||||||
|
if depth+delta <= 0 {
|
||||||
|
return body, j, true
|
||||||
|
}
|
||||||
|
depth += delta
|
||||||
|
body = append(body, lines[j])
|
||||||
|
}
|
||||||
|
return nil, headerIdx, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func expandLuaForLoop(header luaForHeader, body []string, env luaVarEnv) []string {
|
||||||
|
var out []string
|
||||||
|
inRange := func(v int) bool {
|
||||||
|
if header.step > 0 {
|
||||||
|
return v <= header.stop
|
||||||
|
}
|
||||||
|
return v >= header.stop
|
||||||
|
}
|
||||||
|
count := 0
|
||||||
|
for v := header.start; inRange(v); v += header.step {
|
||||||
|
if count++; count > luaMaxLoopIterations {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
value := strconv.Itoa(v)
|
||||||
|
iterLines := make([]string, len(body))
|
||||||
|
for k, bl := range body {
|
||||||
|
iterLines[k] = substituteLuaIdent(bl, header.varName, value)
|
||||||
|
}
|
||||||
|
out = append(out, expandLuaBlock(iterLines, cloneLuaEnv(env))...)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func luaBlockDelta(line string) int {
|
||||||
|
masked := luaMaskStrings(line)
|
||||||
|
return len(luaBlockOpenRE.FindAllString(masked, -1)) - len(luaBlockCloseRE.FindAllString(masked, -1))
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveLuaDynamicLine(line string, env luaVarEnv) string {
|
||||||
|
if !strings.Contains(line, "hl.bind") && !strings.Contains(line, "hl.unbind") {
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
line = normalizeLuaToString(line)
|
||||||
|
return rewriteLuaBindKeyArg(line, env)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeLuaToString(line string) string {
|
||||||
|
return luaTostringRE.ReplaceAllStringFunc(line, func(m string) string {
|
||||||
|
inner := luaTostringRE.FindStringSubmatch(m)[1]
|
||||||
|
if inner[0] == '"' || inner[0] == '\'' {
|
||||||
|
return inner
|
||||||
|
}
|
||||||
|
return strconv.Quote(inner)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func rewriteLuaBindKeyArg(line string, env luaVarEnv) string {
|
||||||
|
for _, fn := range []string{"hl.bind", "hl.unbind"} {
|
||||||
|
idx := strings.Index(line, fn)
|
||||||
|
if idx < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
open := skipLuaWS(line, idx+len(fn))
|
||||||
|
if open >= len(line) || line[open] != '(' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
argStart := skipLuaWS(line, open+1)
|
||||||
|
expr, end, ok := parseLuaFirstArgExpr(line, argStart)
|
||||||
|
if !ok || isLuaPlainStringArg(expr) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
value, ok := evalLuaConcat(expr, env)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return line[:argStart] + strconv.Quote(value) + line[end:]
|
||||||
|
}
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
|
||||||
|
func evalLuaConcat(expr string, env luaVarEnv) (string, bool) {
|
||||||
|
parts := splitLuaConcat(expr)
|
||||||
|
var sb strings.Builder
|
||||||
|
for _, part := range parts {
|
||||||
|
value, ok := evalLuaOperand(part, env)
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
sb.WriteString(value)
|
||||||
|
}
|
||||||
|
return sb.String(), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func evalLuaOperand(op string, env luaVarEnv) (string, bool) {
|
||||||
|
op = strings.TrimSpace(op)
|
||||||
|
if op == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
switch op[0] {
|
||||||
|
case '"', '\'':
|
||||||
|
s, next, ok := parseLuaStringLiteral(op, 0)
|
||||||
|
return s, next == len(op) && ok
|
||||||
|
}
|
||||||
|
if luaNumberRE.MatchString(op) {
|
||||||
|
return op, true
|
||||||
|
}
|
||||||
|
if inner, ok := luaUnwrapCall(op, "tostring"); ok {
|
||||||
|
return evalLuaConcat(inner, env)
|
||||||
|
}
|
||||||
|
if inner, ok := luaUnwrapCall(op, "tonumber"); ok {
|
||||||
|
return evalLuaConcat(inner, env)
|
||||||
|
}
|
||||||
|
if value, ok := env[op]; ok {
|
||||||
|
return value, true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitLuaConcat(expr string) []string {
|
||||||
|
var parts []string
|
||||||
|
parenDepth, braceDepth, bracketDepth := 0, 0, 0
|
||||||
|
inStr := byte(0)
|
||||||
|
esc := false
|
||||||
|
start := 0
|
||||||
|
for i := 0; i < len(expr); i++ {
|
||||||
|
c := expr[i]
|
||||||
|
if inStr != 0 {
|
||||||
|
switch {
|
||||||
|
case esc:
|
||||||
|
esc = false
|
||||||
|
case c == '\\' && inStr == '"':
|
||||||
|
esc = true
|
||||||
|
case c == inStr:
|
||||||
|
inStr = 0
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch c {
|
||||||
|
case '"', '\'':
|
||||||
|
inStr = c
|
||||||
|
case '(':
|
||||||
|
parenDepth++
|
||||||
|
case ')':
|
||||||
|
if parenDepth > 0 {
|
||||||
|
parenDepth--
|
||||||
|
}
|
||||||
|
case '{':
|
||||||
|
braceDepth++
|
||||||
|
case '}':
|
||||||
|
if braceDepth > 0 {
|
||||||
|
braceDepth--
|
||||||
|
}
|
||||||
|
case '[':
|
||||||
|
bracketDepth++
|
||||||
|
case ']':
|
||||||
|
if bracketDepth > 0 {
|
||||||
|
bracketDepth--
|
||||||
|
}
|
||||||
|
case '.':
|
||||||
|
if parenDepth == 0 && braceDepth == 0 && bracketDepth == 0 && i+1 < len(expr) && expr[i+1] == '.' {
|
||||||
|
parts = append(parts, expr[start:i])
|
||||||
|
i++
|
||||||
|
start = i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return append(parts, expr[start:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func substituteLuaIdent(line, name, value string) string {
|
||||||
|
if !strings.Contains(line, name) {
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
var sb strings.Builder
|
||||||
|
inStr := byte(0)
|
||||||
|
esc := false
|
||||||
|
for i := 0; i < len(line); {
|
||||||
|
c := line[i]
|
||||||
|
if inStr != 0 {
|
||||||
|
sb.WriteByte(c)
|
||||||
|
switch {
|
||||||
|
case esc:
|
||||||
|
esc = false
|
||||||
|
case c == '\\' && inStr == '"':
|
||||||
|
esc = true
|
||||||
|
case c == inStr:
|
||||||
|
inStr = 0
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c == '"' || c == '\'' {
|
||||||
|
inStr = c
|
||||||
|
sb.WriteByte(c)
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if isLuaIdentStart(c) {
|
||||||
|
j := i + 1
|
||||||
|
for j < len(line) && isLuaIdentByte(line[j]) {
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
word := line[i:j]
|
||||||
|
if word == name && (i == 0 || line[i-1] != '.') {
|
||||||
|
sb.WriteString(value)
|
||||||
|
} else {
|
||||||
|
sb.WriteString(word)
|
||||||
|
}
|
||||||
|
i = j
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sb.WriteByte(c)
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func luaMaskStrings(line string) string {
|
||||||
|
b := []byte(line)
|
||||||
|
inStr := byte(0)
|
||||||
|
esc := false
|
||||||
|
for i := 0; i < len(b); i++ {
|
||||||
|
c := b[i]
|
||||||
|
if inStr != 0 {
|
||||||
|
wasEnd := !esc && c == inStr
|
||||||
|
esc = !esc && c == '\\' && inStr == '"'
|
||||||
|
b[i] = ' '
|
||||||
|
if wasEnd {
|
||||||
|
inStr = 0
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch c {
|
||||||
|
case '"', '\'':
|
||||||
|
inStr = c
|
||||||
|
b[i] = ' '
|
||||||
|
case '-':
|
||||||
|
if i+1 < len(b) && b[i+1] == '-' {
|
||||||
|
for ; i < len(b); i++ {
|
||||||
|
b[i] = ' '
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func luaStripLineComment(line string) string {
|
||||||
|
inStr := byte(0)
|
||||||
|
esc := false
|
||||||
|
for i := 0; i+1 < len(line); i++ {
|
||||||
|
c := line[i]
|
||||||
|
if inStr != 0 {
|
||||||
|
switch {
|
||||||
|
case esc:
|
||||||
|
esc = false
|
||||||
|
case c == '\\' && inStr == '"':
|
||||||
|
esc = true
|
||||||
|
case c == inStr:
|
||||||
|
inStr = 0
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch c {
|
||||||
|
case '"', '\'':
|
||||||
|
inStr = c
|
||||||
|
case '-':
|
||||||
|
if line[i+1] == '-' {
|
||||||
|
return line[:i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
|
||||||
|
func luaUnwrapCall(op, fn string) (string, bool) {
|
||||||
|
op = strings.TrimSpace(op)
|
||||||
|
if !strings.HasPrefix(op, fn) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
rest := strings.TrimSpace(op[len(fn):])
|
||||||
|
if !strings.HasPrefix(rest, "(") || !strings.HasSuffix(rest, ")") {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return rest[1 : len(rest)-1], true
|
||||||
|
}
|
||||||
|
|
||||||
|
func isLuaPlainStringArg(expr string) bool {
|
||||||
|
expr = strings.TrimSpace(expr)
|
||||||
|
if expr == "" || (expr[0] != '"' && expr[0] != '\'') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, next, ok := parseLuaStringLiteral(expr, 0)
|
||||||
|
return ok && next == len(expr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isLuaIdentStart(c byte) bool {
|
||||||
|
return c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneLuaEnv(env luaVarEnv) luaVarEnv {
|
||||||
|
clone := make(luaVarEnv, len(env))
|
||||||
|
maps.Copy(clone, env)
|
||||||
|
return clone
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package providers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExpandLuaConfigLinesVariableConcat(t *testing.T) {
|
||||||
|
lines := []string{
|
||||||
|
`local mainMod = "SUPER"`,
|
||||||
|
`hl.bind(mainMod .. " + C", hl.dsp.window.close())`,
|
||||||
|
`hl.bind(mainMod .. " + H", hl.dsp.focus({direction = "l"}))`,
|
||||||
|
`hl.bind("ALT + TAB", hl.dsp.window.cycle_next({}))`,
|
||||||
|
}
|
||||||
|
got := expandLuaConfigLines(lines)
|
||||||
|
|
||||||
|
want := []string{
|
||||||
|
`hl.bind("SUPER + C",`,
|
||||||
|
`hl.bind("SUPER + H",`,
|
||||||
|
`hl.bind("ALT + TAB",`,
|
||||||
|
}
|
||||||
|
joined := strings.Join(got, "\n")
|
||||||
|
for _, w := range want {
|
||||||
|
if !strings.Contains(joined, w) {
|
||||||
|
t.Errorf("expanded output missing %q\n---\n%s", w, joined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpandLuaConfigLinesForLoop(t *testing.T) {
|
||||||
|
lines := []string{
|
||||||
|
`local mainMod = "SUPER"`,
|
||||||
|
`for i = 1, 3 do`,
|
||||||
|
` hl.bind(mainMod .. " + " .. i, hl.dsp.focus({workspace = tostring(i)}))`,
|
||||||
|
` hl.bind(mainMod .. " SHIFT + " .. i, hl.dsp.window.move({workspace = tostring(i)}))`,
|
||||||
|
`end`,
|
||||||
|
}
|
||||||
|
got := strings.Join(expandLuaConfigLines(lines), "\n")
|
||||||
|
|
||||||
|
for _, w := range []string{
|
||||||
|
`hl.bind("SUPER + 1",`,
|
||||||
|
`hl.bind("SUPER + 2",`,
|
||||||
|
`hl.bind("SUPER + 3",`,
|
||||||
|
`hl.bind("SUPER SHIFT + 3",`,
|
||||||
|
`{workspace = "1"}`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(got, w) {
|
||||||
|
t.Errorf("expanded loop missing %q\n---\n%s", w, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseLuaLinesDynamicBinds(t *testing.T) {
|
||||||
|
content := strings.Join([]string{
|
||||||
|
`local mainMod = "SUPER"`,
|
||||||
|
`hl.bind(mainMod .. " + C", hl.dsp.window.close())`,
|
||||||
|
`hl.bind("ALT + TAB", hl.dsp.window.cycle_next({}))`,
|
||||||
|
`for i = 1, 2 do`,
|
||||||
|
` hl.bind(mainMod .. " + " .. i, hl.dsp.focus({workspace = tostring(i)}))`,
|
||||||
|
`end`,
|
||||||
|
}, "\n")
|
||||||
|
|
||||||
|
parser := NewHyprlandParser("")
|
||||||
|
section, err := parser.parseLuaLines(content, "", "test.lua", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseLuaLines: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
keys := map[string]*HyprlandKeyBinding{}
|
||||||
|
for i := range section.Keybinds {
|
||||||
|
kb := §ion.Keybinds[i]
|
||||||
|
keys[parser.formatBindKey(kb)] = kb
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, want := range []string{"SUPER+C", "ALT+TAB", "SUPER+1", "SUPER+2"} {
|
||||||
|
if _, ok := keys[want]; !ok {
|
||||||
|
t.Errorf("missing bind %q; got %v", want, keysList(keys))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if kb := keys["SUPER+C"]; kb != nil && kb.Dispatcher != "killactive" {
|
||||||
|
t.Errorf("SUPER+C dispatcher = %q, want killactive", kb.Dispatcher)
|
||||||
|
}
|
||||||
|
if kb := keys["SUPER+1"]; kb != nil {
|
||||||
|
if kb.Dispatcher != "workspace" || kb.Params != "1" {
|
||||||
|
t.Errorf("SUPER+1 = %q %q, want workspace 1", kb.Dispatcher, kb.Params)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func keysList(m map[string]*HyprlandKeyBinding) []string {
|
||||||
|
out := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvalLuaConcat(t *testing.T) {
|
||||||
|
env := luaVarEnv{"mainMod": "SUPER", "i": "5"}
|
||||||
|
tests := []struct {
|
||||||
|
expr string
|
||||||
|
want string
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{`mainMod .. " + C"`, "SUPER + C", true},
|
||||||
|
{`mainMod .. " + " .. i`, "SUPER + 5", true},
|
||||||
|
{`mainMod .. " + " .. tostring(i)`, "SUPER + 5", true},
|
||||||
|
{`"ALT + TAB"`, "ALT + TAB", true},
|
||||||
|
{`mainMod .. someFunc()`, "", false},
|
||||||
|
{`unknownVar .. "x"`, "", false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
got, ok := evalLuaConcat(tt.expr, env)
|
||||||
|
if ok != tt.ok || (ok && got != tt.want) {
|
||||||
|
t.Errorf("evalLuaConcat(%q) = %q,%v want %q,%v", tt.expr, got, ok, tt.want, tt.ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubstituteLuaIdent(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
line, name, value, want string
|
||||||
|
}{
|
||||||
|
{`hl.bind(m .. " + " .. i, x)`, "i", "3", `hl.bind(m .. " + " .. 3, x)`},
|
||||||
|
{`hl.dsp.exec("light -i")`, "i", "3", `hl.dsp.exec("light -i")`},
|
||||||
|
{`foo.i`, "i", "3", `foo.i`},
|
||||||
|
{`tostring(i)`, "i", "3", `tostring(3)`},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := substituteLuaIdent(tt.line, tt.name, tt.value); got != tt.want {
|
||||||
|
t.Errorf("substituteLuaIdent(%q,%q,%q) = %q, want %q", tt.line, tt.name, tt.value, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -347,9 +347,13 @@ func (p *HyprlandParser) buildDMSStatus() *HyprlandDMSStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *HyprlandParser) formatBindKey(kb *HyprlandKeyBinding) string {
|
func (p *HyprlandParser) formatBindKey(kb *HyprlandKeyBinding) string {
|
||||||
|
key := kb.Key
|
||||||
|
if canonical, ok := hyprlandScrollToCanonical(key); ok {
|
||||||
|
key = canonical
|
||||||
|
}
|
||||||
parts := make([]string, 0, len(kb.Mods)+1)
|
parts := make([]string, 0, len(kb.Mods)+1)
|
||||||
parts = append(parts, kb.Mods...)
|
parts = append(parts, kb.Mods...)
|
||||||
parts = append(parts, kb.Key)
|
parts = append(parts, key)
|
||||||
return strings.Join(parts, "+")
|
return strings.Join(parts, "+")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -619,7 +623,7 @@ func (p *HyprlandParser) parseLuaLines(content string, baseDir, absPath, section
|
|||||||
prevSource := p.currentSource
|
prevSource := p.currentSource
|
||||||
p.currentSource = absPath
|
p.currentSource = absPath
|
||||||
|
|
||||||
lines := strings.Split(content, "\n")
|
lines := expandLuaConfigLines(strings.Split(content, "\n"))
|
||||||
boundInFile := make(map[string]bool)
|
boundInFile := make(map[string]bool)
|
||||||
for _, line := range lines {
|
for _, line := range lines {
|
||||||
trimmed := strings.TrimSpace(line)
|
trimmed := strings.TrimSpace(line)
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ func TestWriteLuaBindLineLeavesCustomLuaDispatcherRaw(t *testing.T) {
|
|||||||
Key: "Super+u",
|
Key: "Super+u",
|
||||||
Action: "hl.dsp.no_op()",
|
Action: "hl.dsp.no_op()",
|
||||||
Description: "Custom Lua",
|
Description: "Custom Lua",
|
||||||
|
RawLuaAction: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
want := `hl.unbind("SUPER + U")
|
want := `hl.unbind("SUPER + U")
|
||||||
@@ -159,6 +160,24 @@ hl.bind("SUPER + U", hl.dsp.no_op(), { description = "Custom Lua" })`
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWriteLuaBindLineQuotesUnrecognizedActionWithoutRawLuaFlag(t *testing.T) {
|
||||||
|
var sb strings.Builder
|
||||||
|
writeLuaBindLine(&sb, &hyprlandOverrideBind{
|
||||||
|
Key: "Super+u",
|
||||||
|
Action: `customdispatcher "),os.execute("id")--`,
|
||||||
|
})
|
||||||
|
|
||||||
|
got := sb.String()
|
||||||
|
if !strings.Contains(got, "hl.exec_cmd(") {
|
||||||
|
t.Fatalf("expected unrecognized action to go through the hyprctl-dispatch wrapper, got %q", got)
|
||||||
|
}
|
||||||
|
// an unpaired bare quote means the action broke out of its string literal
|
||||||
|
withoutEscapedQuotes := strings.ReplaceAll(got, `\"`, "")
|
||||||
|
if n := strings.Count(withoutEscapedQuotes, `"`); n%2 != 0 {
|
||||||
|
t.Fatalf("action broke out of its string literal (%d unpaired quotes): %q", n, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLuaActionStringFromHyprlangActionUsesNativeDispatchers(t *testing.T) {
|
func TestLuaActionStringFromHyprlangActionUsesNativeDispatchers(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
action string
|
action string
|
||||||
@@ -226,15 +245,12 @@ func TestParseLuaBindLineHandlesFunctionDispatcherFallback(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLuaActionStringLeavesCustomLuaDispatcherRaw(t *testing.T) {
|
func TestLuaActionStringFromHyprlangActionAlwaysQuotesUnrecognizedText(t *testing.T) {
|
||||||
got := luaActionStringFromHyprlangAction("hl.dsp.no_op()")
|
got := luaActionStringFromHyprlangAction("hl.dsp.no_op()")
|
||||||
want := `hl.dsp.no_op()`
|
want := `function() hl.exec_cmd("hyprctl dispatch hl.dsp.no_op()") end`
|
||||||
if got != want {
|
if got != want {
|
||||||
t.Fatalf("luaActionStringFromHyprlangAction() = %q, want %q", got, want)
|
t.Fatalf("luaActionStringFromHyprlangAction() = %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
if strings.Contains(got, "hl.dispatch") || strings.Contains(got, "hyprctl dispatch") {
|
|
||||||
t.Fatalf("expected custom Lua dispatcher expression to stay raw, got %q", got)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReadLuaOverrideMigratesTrailingCommentToDescription(t *testing.T) {
|
func TestReadLuaOverrideMigratesTrailingCommentToDescription(t *testing.T) {
|
||||||
@@ -486,6 +502,61 @@ hl.bind("SUPER + 1", hl.dsp.exec_cmd("hyprctl dispatch workspace 1"))
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHyprlandSetBindTranslatesScrollWheelToMouse(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
dmsDir := filepath.Join(tmpDir, "dms")
|
||||||
|
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
bindsUser := filepath.Join(dmsDir, "binds-user.lua")
|
||||||
|
if err := os.WriteFile(bindsUser, []byte("-- DMS user keybind overrides\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := NewHyprlandProvider(tmpDir)
|
||||||
|
if err := provider.SetBind("SUPER + WheelScrollDown", "workspace 1", "", nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := readFile(t, bindsUser)
|
||||||
|
if !strings.Contains(got, `hl.bind("SUPER + mouse_down"`) {
|
||||||
|
t.Fatalf("expected scroll key translated to mouse_down, got:\n%s", got)
|
||||||
|
}
|
||||||
|
if strings.Contains(got, "WheelScroll") {
|
||||||
|
t.Fatalf("expected no raw niri scroll keysym in hyprland output, got:\n%s", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := provider.SetBind("SUPER + WheelScrollDown", "workspace 2", "", nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got = readFile(t, bindsUser)
|
||||||
|
if strings.Count(got, `hl.bind("SUPER + mouse_down"`) != 1 {
|
||||||
|
t.Fatalf("expected exactly one mouse_down bind after re-save, got:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHyprlandScrollWheelRoundTrips(t *testing.T) {
|
||||||
|
for native, canonical := range map[string]string{
|
||||||
|
"mouse_up": "WheelScrollUp",
|
||||||
|
"mouse_down": "WheelScrollDown",
|
||||||
|
"mouse_left": "WheelScrollLeft",
|
||||||
|
"mouse_right": "WheelScrollRight",
|
||||||
|
} {
|
||||||
|
if got := luaKeyComboToInternalKey("SUPER + " + native); got != "SUPER+"+canonical {
|
||||||
|
t.Errorf("luaKeyComboToInternalKey(%q) = %q, want SUPER+%s", native, got, canonical)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readFile(t *testing.T, path string) string {
|
||||||
|
t.Helper()
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
|
||||||
func TestHyprlandRemoveBindReplacesExistingOverrideWithNegativeOverride(t *testing.T) {
|
func TestHyprlandRemoveBindReplacesExistingOverrideWithNegativeOverride(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
dmsDir := filepath.Join(tmpDir, "dms")
|
dmsDir := filepath.Join(tmpDir, "dms")
|
||||||
|
|||||||
@@ -236,6 +236,9 @@ func (m *MangoWCProvider) SetBind(key, action, description string, options map[s
|
|||||||
if optionPrefix := m.bindPrefixFromOptions(options); optionPrefix != "" {
|
if optionPrefix := m.bindPrefixFromOptions(options); optionPrefix != "" {
|
||||||
prefix = optionPrefix
|
prefix = optionPrefix
|
||||||
}
|
}
|
||||||
|
if _, leaf := m.parseKeyString(key); isScrollKey(leaf) {
|
||||||
|
prefix = mangowcAxisBindPrefix
|
||||||
|
}
|
||||||
|
|
||||||
existingBinds[normalizedKey] = &mangowcOverrideBind{
|
existingBinds[normalizedKey] = &mangowcOverrideBind{
|
||||||
Key: key,
|
Key: key,
|
||||||
@@ -346,6 +349,12 @@ func (m *MangoWCProvider) parseOverrideBindLine(line, precedingComment string) (
|
|||||||
keyName := strings.TrimSpace(fields[1])
|
keyName := strings.TrimSpace(fields[1])
|
||||||
command := strings.TrimSpace(fields[2])
|
command := strings.TrimSpace(fields[2])
|
||||||
|
|
||||||
|
if prefix == mangowcAxisBindPrefix {
|
||||||
|
if canonical, ok := mangowcDirectionToScroll(keyName); ok {
|
||||||
|
keyName = canonical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var params string
|
var params string
|
||||||
if len(fields) > 3 {
|
if len(fields) > 3 {
|
||||||
params = strings.TrimSpace(fields[3])
|
params = strings.TrimSpace(fields[3])
|
||||||
@@ -365,6 +374,9 @@ func (m *MangoWCProvider) parseOverrideBindLine(line, precedingComment string) (
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *MangoWCProvider) isBindPrefix(prefix string) bool {
|
func (m *MangoWCProvider) isBindPrefix(prefix string) bool {
|
||||||
|
if prefix == mangowcAxisBindPrefix {
|
||||||
|
return true
|
||||||
|
}
|
||||||
if !strings.HasPrefix(prefix, "bind") {
|
if !strings.HasPrefix(prefix, "bind") {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -591,6 +603,11 @@ func (m *MangoWCProvider) writeBindLine(sb *strings.Builder, bind *mangowcOverri
|
|||||||
if prefix == "" {
|
if prefix == "" {
|
||||||
prefix = "bind"
|
prefix = "bind"
|
||||||
}
|
}
|
||||||
|
if prefix == mangowcAxisBindPrefix {
|
||||||
|
if direction, ok := mangowcScrollToDirection(key); ok {
|
||||||
|
key = direction
|
||||||
|
}
|
||||||
|
}
|
||||||
sb.WriteString(prefix)
|
sb.WriteString(prefix)
|
||||||
sb.WriteString("=")
|
sb.WriteString("=")
|
||||||
if mods == "" {
|
if mods == "" {
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ func (p *MangoWCParser) ParseKeys() []MangoWCKeyBinding {
|
|||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !strings.HasPrefix(trimmed, "bind") {
|
if !strings.HasPrefix(trimmed, "bind") && !strings.HasPrefix(trimmed, mangowcAxisBindPrefix) {
|
||||||
pendingComment = ""
|
pendingComment = ""
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -427,7 +427,7 @@ func (p *MangoWCParser) parseFileWithSource(filePath string) ([]MangoWCKeyBindin
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.HasPrefix(trimmed, "bind") {
|
if !strings.HasPrefix(trimmed, "bind") && !strings.HasPrefix(trimmed, mangowcAxisBindPrefix) {
|
||||||
pendingComment = ""
|
pendingComment = ""
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -493,7 +493,7 @@ func (p *MangoWCParser) parseDMSBindsDirectly(dmsBindsPath string) []MangoWCKeyB
|
|||||||
// line directly above) is the description: mango feeds inline comments to spawn
|
// line directly above) is the description: mango feeds inline comments to spawn
|
||||||
// as argv, so DMS keeps descriptions on the line above; inline `#` is a fallback.
|
// as argv, so DMS keeps descriptions on the line above; inline `#` is a fallback.
|
||||||
func (p *MangoWCParser) getKeybindAtLineContent(line string, precedingComment string) *MangoWCKeyBinding {
|
func (p *MangoWCParser) getKeybindAtLineContent(line string, precedingComment string) *MangoWCKeyBinding {
|
||||||
bindMatch := regexp.MustCompile(`^(bind[lsrp]*)\s*=\s*(.+)$`)
|
bindMatch := regexp.MustCompile(`^(bind[lsrp]*|axisbind)\s*=\s*(.+)$`)
|
||||||
matches := bindMatch.FindStringSubmatch(line)
|
matches := bindMatch.FindStringSubmatch(line)
|
||||||
if len(matches) < 3 {
|
if len(matches) < 3 {
|
||||||
return nil
|
return nil
|
||||||
@@ -527,6 +527,12 @@ func (p *MangoWCParser) getKeybindAtLineContent(line string, precedingComment st
|
|||||||
key := strings.TrimSpace(keyFields[1])
|
key := strings.TrimSpace(keyFields[1])
|
||||||
command := strings.TrimSpace(keyFields[2])
|
command := strings.TrimSpace(keyFields[2])
|
||||||
|
|
||||||
|
if matches[1] == mangowcAxisBindPrefix {
|
||||||
|
if canonical, ok := mangowcDirectionToScroll(key); ok {
|
||||||
|
key = canonical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var params string
|
var params string
|
||||||
if len(keyFields) > 3 {
|
if len(keyFields) > 3 {
|
||||||
params = strings.TrimSpace(keyFields[3])
|
params = strings.TrimSpace(keyFields[3])
|
||||||
|
|||||||
@@ -6,6 +6,29 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestMangoWCParseAxisBindToScrollKey(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
cfg := filepath.Join(tmpDir, "config.conf")
|
||||||
|
content := "axisbind=SUPER,UP,spawn,dms ipc call test\n"
|
||||||
|
if err := os.WriteFile(cfg, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
binds, err := ParseMangoWCKeys(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseMangoWCKeys failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(binds) != 1 {
|
||||||
|
t.Fatalf("expected 1 bind, got %d", len(binds))
|
||||||
|
}
|
||||||
|
if binds[0].Key != "WheelScrollUp" {
|
||||||
|
t.Fatalf("expected axis direction parsed as WheelScrollUp, got %q", binds[0].Key)
|
||||||
|
}
|
||||||
|
if len(binds[0].Mods) != 1 || binds[0].Mods[0] != "SUPER" {
|
||||||
|
t.Fatalf("expected SUPER mod, got %v", binds[0].Mods)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMangoWCAutogenerateComment(t *testing.T) {
|
func TestMangoWCAutogenerateComment(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
command string
|
command string
|
||||||
|
|||||||
@@ -417,6 +417,40 @@ bind=SUPER,3,view,3
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMangoWCSetBindTranslatesScrollWheelToAxisBind(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)
|
||||||
|
}
|
||||||
|
bindsPath := filepath.Join(dmsDir, "binds.conf")
|
||||||
|
seed := "# === Custom Keybinds ===\nbind=SUPER,t,spawn,ghostty\ngesturebind=none,left,3,focusdir,left\n"
|
||||||
|
if err := os.WriteFile(bindsPath, []byte(seed), 0o644); err != nil {
|
||||||
|
t.Fatalf("failed to write seed binds: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := NewMangoWCProvider(tmpDir)
|
||||||
|
if err := provider.SetBind("SUPER+WheelScrollDown", "spawn dms ipc call test", "Scroll down", nil); err != nil {
|
||||||
|
t.Fatalf("SetBind failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
content := readFile(t, bindsPath)
|
||||||
|
if !strings.Contains(content, "axisbind=SUPER,DOWN,spawn,dms ipc call test") {
|
||||||
|
t.Fatalf("expected scroll bind written as axisbind direction, got:\n%s", content)
|
||||||
|
}
|
||||||
|
if strings.Contains(content, "WheelScroll") {
|
||||||
|
t.Fatalf("expected no raw niri scroll keysym in mango output, got:\n%s", content)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := provider.SetBind("SUPER+WheelScrollDown", "spawn dms ipc call test2", "Scroll down", nil); err != nil {
|
||||||
|
t.Fatalf("SetBind failed: %v", err)
|
||||||
|
}
|
||||||
|
content = readFile(t, bindsPath)
|
||||||
|
if strings.Count(content, "axisbind=SUPER,DOWN,") != 1 {
|
||||||
|
t.Fatalf("expected exactly one axisbind after re-save, got:\n%s", content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMangoWCRemoveBindPreservesNonBindLines(t *testing.T) {
|
func TestMangoWCRemoveBindPreservesNonBindLines(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
dmsDir := filepath.Join(tmpDir, "dms")
|
dmsDir := filepath.Join(tmpDir, "dms")
|
||||||
|
|||||||
@@ -19,6 +19,20 @@ type NiriProvider struct {
|
|||||||
parsed bool
|
parsed bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type niriActionPart struct {
|
||||||
|
value string
|
||||||
|
quoted bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var niriActionPropertyOrder = []string{"focus", "show-pointer", "write-to-disk", "skip-confirmation", "delay-ms"}
|
||||||
|
var niriActionProperties = map[string]struct{}{
|
||||||
|
"focus": {},
|
||||||
|
"show-pointer": {},
|
||||||
|
"write-to-disk": {},
|
||||||
|
"skip-confirmation": {},
|
||||||
|
"delay-ms": {},
|
||||||
|
}
|
||||||
|
|
||||||
func NewNiriProvider(configDir string) *NiriProvider {
|
func NewNiriProvider(configDir string) *NiriProvider {
|
||||||
if configDir == "" {
|
if configDir == "" {
|
||||||
configDir = defaultNiriConfigDir()
|
configDir = defaultNiriConfigDir()
|
||||||
@@ -55,6 +69,7 @@ func (n *NiriProvider) GetCheatSheet() (*keybinds.CheatSheet, error) {
|
|||||||
sheet := &keybinds.CheatSheet{
|
sheet := &keybinds.CheatSheet{
|
||||||
Title: "Niri Keybinds",
|
Title: "Niri Keybinds",
|
||||||
Provider: n.Name(),
|
Provider: n.Name(),
|
||||||
|
ModKey: result.ModKey,
|
||||||
Binds: categorizedBinds,
|
Binds: categorizedBinds,
|
||||||
DMSBindsIncluded: result.DMSBindsIncluded,
|
DMSBindsIncluded: result.DMSBindsIncluded,
|
||||||
}
|
}
|
||||||
@@ -352,7 +367,7 @@ func (n *NiriProvider) buildActionFromNode(bindNode *document.Node) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if actionNode.Properties != nil {
|
if actionNode.Properties != nil {
|
||||||
for _, propName := range []string{"focus", "show-pointer", "write-to-disk", "skip-confirmation", "delay-ms"} {
|
for _, propName := range niriActionPropertyOrder {
|
||||||
if val, ok := actionNode.Properties.Get(propName); ok {
|
if val, ok := actionNode.Properties.Get(propName); ok {
|
||||||
parts = append(parts, propName+"="+val.String())
|
parts = append(parts, propName+"="+val.String())
|
||||||
}
|
}
|
||||||
@@ -440,10 +455,10 @@ func (n *NiriProvider) buildActionNode(action string) *document.Node {
|
|||||||
return node
|
return node
|
||||||
}
|
}
|
||||||
|
|
||||||
node.SetName(parts[0])
|
node.SetName(parts[0].value)
|
||||||
for _, arg := range parts[1:] {
|
for _, arg := range parts[1:] {
|
||||||
if strings.Contains(arg, "=") {
|
if n.isNiriActionPropertyToken(arg) {
|
||||||
kv := strings.SplitN(arg, "=", 2)
|
kv := strings.SplitN(arg.value, "=", 2)
|
||||||
switch kv[1] {
|
switch kv[1] {
|
||||||
case "true":
|
case "true":
|
||||||
node.AddProperty(kv[0], true, "")
|
node.AddProperty(kv[0], true, "")
|
||||||
@@ -454,13 +469,25 @@ func (n *NiriProvider) buildActionNode(action string) *document.Node {
|
|||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
node.AddArgument(arg, "")
|
node.AddArgument(arg.value, "")
|
||||||
}
|
}
|
||||||
return node
|
return node
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *NiriProvider) parseActionParts(action string) []string {
|
func (n *NiriProvider) isNiriActionPropertyToken(part niriActionPart) bool {
|
||||||
var parts []string
|
if part.quoted || !strings.Contains(part.value, "=") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
key, _, ok := strings.Cut(part.value, "=")
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, ok = niriActionProperties[key]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *NiriProvider) parseActionParts(action string) []niriActionPart {
|
||||||
|
var parts []niriActionPart
|
||||||
var current strings.Builder
|
var current strings.Builder
|
||||||
var inQuote, escaped, wasQuoted bool
|
var inQuote, escaped, wasQuoted bool
|
||||||
|
|
||||||
@@ -476,7 +503,7 @@ func (n *NiriProvider) parseActionParts(action string) []string {
|
|||||||
inQuote = !inQuote
|
inQuote = !inQuote
|
||||||
case r == ' ' && !inQuote:
|
case r == ' ' && !inQuote:
|
||||||
if current.Len() > 0 || wasQuoted {
|
if current.Len() > 0 || wasQuoted {
|
||||||
parts = append(parts, current.String())
|
parts = append(parts, niriActionPart{value: current.String(), quoted: wasQuoted})
|
||||||
current.Reset()
|
current.Reset()
|
||||||
wasQuoted = false
|
wasQuoted = false
|
||||||
}
|
}
|
||||||
@@ -485,7 +512,7 @@ func (n *NiriProvider) parseActionParts(action string) []string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if current.Len() > 0 || wasQuoted {
|
if current.Len() > 0 || wasQuoted {
|
||||||
parts = append(parts, current.String())
|
parts = append(parts, niriActionPart{value: current.String(), quoted: wasQuoted})
|
||||||
}
|
}
|
||||||
return parts
|
return parts
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ type NiriSection struct {
|
|||||||
|
|
||||||
type NiriParser struct {
|
type NiriParser struct {
|
||||||
configDir string
|
configDir string
|
||||||
|
modKey string
|
||||||
processedFiles map[string]bool
|
processedFiles map[string]bool
|
||||||
bindMap map[string]*NiriKeyBinding
|
bindMap map[string]*NiriKeyBinding
|
||||||
bindOrder []string
|
bindOrder []string
|
||||||
@@ -51,7 +52,7 @@ type NiriParser struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func parseKDL(data []byte) (*document.Document, error) {
|
func parseKDL(data []byte) (*document.Document, error) {
|
||||||
return kdl.Parse(strings.NewReader(normalizeKDLBraces(string(data))))
|
return kdl.Parse(strings.NewReader(normalizeKDLBraces(quoteLeadingUnderscoreIdents(string(data)))))
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeKDLBraces(input string) string {
|
func normalizeKDLBraces(input string) string {
|
||||||
@@ -94,6 +95,93 @@ func normalizeKDLBraces(input string) string {
|
|||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// quoteLeadingUnderscoreIdents wraps bare KDL identifiers that begin with '_'
|
||||||
|
// in double quotes. kdl-go rejects '_' as the first character of a bare
|
||||||
|
// identifier (e.g. the common `_JAVA_AWT_WM_NONREPARENTING "1"` environment
|
||||||
|
// node), even though niri's own parser and the KDL spec accept it — so without
|
||||||
|
// this the whole config fails to parse and no keybinds load. Quoting lets
|
||||||
|
// kdl-go parse it; this is safe because the niri parser only dispatches on
|
||||||
|
// fixed node/section names (binds, recent-windows, include, ...) that never
|
||||||
|
// start with '_', so re-quoting such a name cannot change what DMS reads.
|
||||||
|
// Underscores elsewhere in an identifier (XDG_CURRENT_DESKTOP) are left
|
||||||
|
// untouched, and underscores inside strings or comments are skipped. Only a
|
||||||
|
// leading '_' is handled; other start characters kdl-go over-rejects (e.g. '.'
|
||||||
|
// or '?') do not occur in niri configs.
|
||||||
|
func quoteLeadingUnderscoreIdents(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 == '/' && i+1 < n && input[i+1] == '-':
|
||||||
|
// KDL slashdash: /- comments out the next node/value. Keep the
|
||||||
|
// marker but treat what follows as a fresh token start, so a
|
||||||
|
// slashdashed leading-underscore node (e.g. `/-_FOO "1"`) still
|
||||||
|
// gets quoted instead of crashing kdl-go.
|
||||||
|
sb.WriteByte('/')
|
||||||
|
sb.WriteByte('-')
|
||||||
|
prev = ' '
|
||||||
|
i += 2
|
||||||
|
case c == '_' && isIdentBoundary(prev):
|
||||||
|
end := scanBareIdent(input, i)
|
||||||
|
sb.WriteByte('"')
|
||||||
|
sb.WriteString(input[i:end])
|
||||||
|
sb.WriteByte('"')
|
||||||
|
prev = '"'
|
||||||
|
i = end
|
||||||
|
default:
|
||||||
|
sb.WriteByte(c)
|
||||||
|
prev = c
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// isIdentBoundary reports whether the previously emitted byte ends a token, so
|
||||||
|
// that a following '_' starts a fresh bare identifier rather than sitting in
|
||||||
|
// the middle of one.
|
||||||
|
func isIdentBoundary(prev byte) bool {
|
||||||
|
switch prev {
|
||||||
|
case 0, ' ', '\t', '\n', '\r', '{', '}', ';', '=', '(', ')', ',':
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanBareIdent returns the index just past the bare identifier starting at
|
||||||
|
// start, stopping at whitespace or any KDL delimiter.
|
||||||
|
func scanBareIdent(s string, start int) int {
|
||||||
|
n := len(s)
|
||||||
|
for i := start; i < n; i++ {
|
||||||
|
switch s[i] {
|
||||||
|
case ' ', '\t', '\n', '\r', '"', '{', '}', '(', ')', ';', '=', ',', '/', '\\', '<', '>', '[', ']':
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
func findStringEnd(s string, start int) int {
|
func findStringEnd(s string, start int) int {
|
||||||
n := len(s)
|
n := len(s)
|
||||||
for i := start + 1; i < n; {
|
for i := start + 1; i < n; {
|
||||||
@@ -150,6 +238,7 @@ func isBraceAdjacentSpace(b byte) bool {
|
|||||||
func NewNiriParser(configDir string) *NiriParser {
|
func NewNiriParser(configDir string) *NiriParser {
|
||||||
return &NiriParser{
|
return &NiriParser{
|
||||||
configDir: configDir,
|
configDir: configDir,
|
||||||
|
modKey: "Super",
|
||||||
processedFiles: make(map[string]bool),
|
processedFiles: make(map[string]bool),
|
||||||
bindMap: make(map[string]*NiriKeyBinding),
|
bindMap: make(map[string]*NiriKeyBinding),
|
||||||
bindOrder: []string{},
|
bindOrder: []string{},
|
||||||
@@ -290,6 +379,8 @@ func (p *NiriParser) processNodes(nodes []*document.Node, section *NiriSection,
|
|||||||
switch name {
|
switch name {
|
||||||
case "include":
|
case "include":
|
||||||
p.handleInclude(node, section, baseDir)
|
p.handleInclude(node, section, baseDir)
|
||||||
|
case "input":
|
||||||
|
p.handleInput(node)
|
||||||
case "binds":
|
case "binds":
|
||||||
p.extractBinds(node, section, "")
|
p.extractBinds(node, section, "")
|
||||||
case "recent-windows":
|
case "recent-windows":
|
||||||
@@ -298,6 +389,19 @@ func (p *NiriParser) processNodes(nodes []*document.Node, section *NiriSection,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *NiriParser) handleInput(node *document.Node) {
|
||||||
|
for _, child := range node.Children {
|
||||||
|
if child.Name.String() != "mod-key" || len(child.Arguments) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
modKey := strings.Trim(strings.TrimSpace(child.Arguments[0].String()), "\"")
|
||||||
|
if modKey != "" {
|
||||||
|
p.modKey = modKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (p *NiriParser) handleInclude(node *document.Node, section *NiriSection, baseDir string) {
|
func (p *NiriParser) handleInclude(node *document.Node, section *NiriSection, baseDir string) {
|
||||||
if len(node.Arguments) == 0 {
|
if len(node.Arguments) == 0 {
|
||||||
return
|
return
|
||||||
@@ -447,6 +551,7 @@ func (p *NiriParser) parseKeyCombo(combo string) ([]string, string) {
|
|||||||
|
|
||||||
type NiriParseResult struct {
|
type NiriParseResult struct {
|
||||||
Section *NiriSection
|
Section *NiriSection
|
||||||
|
ModKey string
|
||||||
DMSBindsIncluded bool
|
DMSBindsIncluded bool
|
||||||
DMSStatus *DMSBindsStatusInfo
|
DMSStatus *DMSBindsStatusInfo
|
||||||
ConflictingConfigs map[string]*NiriKeyBinding
|
ConflictingConfigs map[string]*NiriKeyBinding
|
||||||
@@ -499,6 +604,7 @@ func ParseNiriKeys(configDir string) (*NiriParseResult, error) {
|
|||||||
}
|
}
|
||||||
return &NiriParseResult{
|
return &NiriParseResult{
|
||||||
Section: section,
|
Section: section,
|
||||||
|
ModKey: parser.modKey,
|
||||||
DMSBindsIncluded: parser.HasDMSBindsIncluded(),
|
DMSBindsIncluded: parser.HasDMSBindsIncluded(),
|
||||||
DMSStatus: parser.buildDMSStatus(),
|
DMSStatus: parser.buildDMSStatus(),
|
||||||
ConflictingConfigs: parser.conflictingConfigs,
|
ConflictingConfigs: parser.conflictingConfigs,
|
||||||
|
|||||||
@@ -7,6 +7,28 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestNiriParseModKey(t *testing.T) {
|
||||||
|
config := `input {
|
||||||
|
mod-key "Alt"
|
||||||
|
}
|
||||||
|
binds {
|
||||||
|
Mod+T { spawn "kitty"; }
|
||||||
|
}
|
||||||
|
`
|
||||||
|
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: %v", err)
|
||||||
|
}
|
||||||
|
if result.ModKey != "Alt" {
|
||||||
|
t.Errorf("ModKey = %q, want %q", result.ModKey, "Alt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNiriParse_NoSpaceBeforeBrace(t *testing.T) {
|
func TestNiriParse_NoSpaceBeforeBrace(t *testing.T) {
|
||||||
config := `recent-windows {
|
config := `recent-windows {
|
||||||
binds {
|
binds {
|
||||||
@@ -71,6 +93,101 @@ func TestNormalizeKDLBraces(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestQuoteLeadingUnderscoreIdents(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
out string
|
||||||
|
}{
|
||||||
|
{"leading underscore node", `_JAVA_AWT_WM_NONREPARENTING "1"`, `"_JAVA_AWT_WM_NONREPARENTING" "1"`},
|
||||||
|
{"mid underscore untouched", `XDG_CURRENT_DESKTOP "niri"`, `XDG_CURRENT_DESKTOP "niri"`},
|
||||||
|
{"indented node", "environment {\n _FOO \"1\"\n}", "environment {\n \"_FOO\" \"1\"\n}"},
|
||||||
|
{"underscore in string", `spawn "_not_a_node"`, `spawn "_not_a_node"`},
|
||||||
|
{"underscore in line comment", "// _comment\n_FOO \"1\"", "// _comment\n\"_FOO\" \"1\""},
|
||||||
|
{"underscore in block comment", "/* _x */ _FOO \"1\"", "/* _x */ \"_FOO\" \"1\""},
|
||||||
|
{"block comment abuts node", `/* x */_FOO "1"`, `/* x */"_FOO" "1"`},
|
||||||
|
{"slashdash before node", `/-_FOO "1"`, `/-"_FOO" "1"`},
|
||||||
|
{"node after closing paren", "node (u8)_v", `node (u8)"_v"`},
|
||||||
|
{"node before brace without space", "_FOO{ }", `"_FOO"{ }`},
|
||||||
|
{"lone underscore", `_ "x"`, `"_" "x"`},
|
||||||
|
{"property value", "node key=_val", `node key="_val"`},
|
||||||
|
{"no underscores", "node child", "node child"},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := quoteLeadingUnderscoreIdents(tc.in)
|
||||||
|
if got != tc.out {
|
||||||
|
t.Errorf("quoteLeadingUnderscoreIdents(%q) = %q, want %q", tc.in, got, tc.out)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNiriParseLeadingUnderscoreEnvironment(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configFile := filepath.Join(tmpDir, "config.kdl")
|
||||||
|
|
||||||
|
// A leading-underscore environment node (a common Java/tiling-WM fix) must
|
||||||
|
// not abort parsing of the rest of the config — keybinds still have to load.
|
||||||
|
content := `environment {
|
||||||
|
XDG_CURRENT_DESKTOP "niri"
|
||||||
|
_JAVA_AWT_WM_NONREPARENTING "1"
|
||||||
|
}
|
||||||
|
binds {
|
||||||
|
Mod+Q { close-window; }
|
||||||
|
Mod+KP_Home { focus-workspace 1; }
|
||||||
|
}
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatalf("Failed to write test config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := ParseNiriKeys(tmpDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseNiriKeys failed on config with leading-underscore env node: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Section.Keybinds) != 2 {
|
||||||
|
t.Errorf("Expected 2 keybinds, got %d", len(result.Section.Keybinds))
|
||||||
|
}
|
||||||
|
|
||||||
|
foundClose := false
|
||||||
|
for _, kb := range result.Section.Keybinds {
|
||||||
|
if kb.Action == "close-window" {
|
||||||
|
foundClose = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundClose {
|
||||||
|
t.Error("close-window keybind not found — leading-underscore env node broke parsing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNiriParseSlashdashLeadingUnderscore(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configFile := filepath.Join(tmpDir, "config.kdl")
|
||||||
|
|
||||||
|
// A slashdashed leading-underscore node must not abort parsing either.
|
||||||
|
content := `environment {
|
||||||
|
/-_JAVA_AWT_WM_NONREPARENTING "1"
|
||||||
|
}
|
||||||
|
binds {
|
||||||
|
Mod+Q { close-window; }
|
||||||
|
}
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatalf("Failed to write test config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := ParseNiriKeys(tmpDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseNiriKeys failed on config with slashdashed leading-underscore node: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Section.Keybinds) != 1 {
|
||||||
|
t.Errorf("Expected 1 keybind, got %d", len(result.Section.Keybinds))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNiriParseKeyCombo(t *testing.T) {
|
func TestNiriParseKeyCombo(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
combo string
|
combo string
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ func TestNiriProviderGetCheatSheet(t *testing.T) {
|
|||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
configFile := filepath.Join(tmpDir, "config.kdl")
|
configFile := filepath.Join(tmpDir, "config.kdl")
|
||||||
|
|
||||||
content := `binds {
|
content := `input {
|
||||||
|
mod-key "Alt"
|
||||||
|
}
|
||||||
|
binds {
|
||||||
Mod+Q { close-window; }
|
Mod+Q { close-window; }
|
||||||
Mod+F { fullscreen-window; }
|
Mod+F { fullscreen-window; }
|
||||||
Mod+T hotkey-overlay-title="Open Terminal" { spawn "kitty"; }
|
Mod+T hotkey-overlay-title="Open Terminal" { spawn "kitty"; }
|
||||||
@@ -45,6 +48,10 @@ func TestNiriProviderGetCheatSheet(t *testing.T) {
|
|||||||
t.Errorf("Provider = %q, want %q", cheatSheet.Provider, "niri")
|
t.Errorf("Provider = %q, want %q", cheatSheet.Provider, "niri")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cheatSheet.ModKey != "Alt" {
|
||||||
|
t.Errorf("ModKey = %q, want %q", cheatSheet.ModKey, "Alt")
|
||||||
|
}
|
||||||
|
|
||||||
windowBinds := cheatSheet.Binds["Window"]
|
windowBinds := cheatSheet.Binds["Window"]
|
||||||
if len(windowBinds) < 2 {
|
if len(windowBinds) < 2 {
|
||||||
t.Errorf("Expected at least 2 Window binds, got %d", len(windowBinds))
|
t.Errorf("Expected at least 2 Window binds, got %d", len(windowBinds))
|
||||||
@@ -227,6 +234,58 @@ func TestNiriGenerateBindsContent(t *testing.T) {
|
|||||||
expected: `binds {
|
expected: `binds {
|
||||||
Mod+Space hotkey-overlay-title="Application Launcher" { spawn "dms" "ipc" "call" "spotlight" "toggle"; }
|
Mod+Space hotkey-overlay-title="Application Launcher" { spawn "dms" "ipc" "call" "spotlight" "toggle"; }
|
||||||
}
|
}
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "spawn with equals arg",
|
||||||
|
binds: map[string]*overrideBind{
|
||||||
|
"Mod+B": {
|
||||||
|
Key: "Mod+B",
|
||||||
|
Action: `spawn /opt/browser --profile-directory=Default`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: `binds {
|
||||||
|
Mod+B { spawn "/opt/browser" "--profile-directory=Default"; }
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "spawn shell command with quoted equals args",
|
||||||
|
binds: map[string]*overrideBind{
|
||||||
|
"Mod+C": {
|
||||||
|
Key: "Mod+C",
|
||||||
|
Action: `spawn sh -c "chrome --profile-directory=Default --app=x"`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: `binds {
|
||||||
|
Mod+C { spawn "sh" "-c" "chrome --profile-directory=Default --app=x"; }
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "spawn env assignment stays arg",
|
||||||
|
binds: map[string]*overrideBind{
|
||||||
|
"Mod+E": {
|
||||||
|
Key: "Mod+E",
|
||||||
|
Action: `spawn env FOO=bar mycmd`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: `binds {
|
||||||
|
Mod+E { spawn "env" "FOO=bar" "mycmd"; }
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "niri action property remains property",
|
||||||
|
binds: map[string]*overrideBind{
|
||||||
|
"Print": {
|
||||||
|
Key: "Print",
|
||||||
|
Action: `screenshot show-pointer=false`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: `binds {
|
||||||
|
Print { screenshot show-pointer=false; }
|
||||||
|
}
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package providers
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// Scroll-wheel binds are captured by the shell as niri's keysym names
|
||||||
|
// (WheelScrollUp/Down/Left/Right) regardless of the active compositor. Niri
|
||||||
|
// consumes them natively; every other provider speaks a different dialect, so the
|
||||||
|
// raw niri token must be translated on write and back again on read. Without this
|
||||||
|
// the token is emitted verbatim and the compositor rejects the bind (issue #2683).
|
||||||
|
|
||||||
|
var canonicalScrollKeys = map[string]string{
|
||||||
|
"wheelscrollup": "WheelScrollUp",
|
||||||
|
"wheelscrolldown": "WheelScrollDown",
|
||||||
|
"wheelscrollleft": "WheelScrollLeft",
|
||||||
|
"wheelscrollright": "WheelScrollRight",
|
||||||
|
}
|
||||||
|
|
||||||
|
func isScrollKey(token string) bool {
|
||||||
|
_, ok := canonicalScrollKeys[strings.ToLower(token)]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hyprland binds the wheel inside a regular bind using mouse_up/down/left/right.
|
||||||
|
var hyprlandScrollNative = map[string]string{
|
||||||
|
"wheelscrollup": "mouse_up",
|
||||||
|
"wheelscrolldown": "mouse_down",
|
||||||
|
"wheelscrollleft": "mouse_left",
|
||||||
|
"wheelscrollright": "mouse_right",
|
||||||
|
}
|
||||||
|
|
||||||
|
var hyprlandScrollCanonical = map[string]string{
|
||||||
|
"mouse_up": "WheelScrollUp",
|
||||||
|
"mouse_down": "WheelScrollDown",
|
||||||
|
"mouse_left": "WheelScrollLeft",
|
||||||
|
"mouse_right": "WheelScrollRight",
|
||||||
|
}
|
||||||
|
|
||||||
|
func hyprlandScrollToNative(token string) (string, bool) {
|
||||||
|
v, ok := hyprlandScrollNative[strings.ToLower(token)]
|
||||||
|
return v, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func hyprlandScrollToCanonical(token string) (string, bool) {
|
||||||
|
v, ok := hyprlandScrollCanonical[strings.ToLower(token)]
|
||||||
|
return v, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// MangoWC binds the wheel through a dedicated axisbind directive whose key field
|
||||||
|
// is a direction (UP/DOWN/LEFT/RIGHT) rather than a keysym.
|
||||||
|
const mangowcAxisBindPrefix = "axisbind"
|
||||||
|
|
||||||
|
var mangowcScrollDirection = map[string]string{
|
||||||
|
"wheelscrollup": "UP",
|
||||||
|
"wheelscrolldown": "DOWN",
|
||||||
|
"wheelscrollleft": "LEFT",
|
||||||
|
"wheelscrollright": "RIGHT",
|
||||||
|
}
|
||||||
|
|
||||||
|
var mangowcScrollCanonical = map[string]string{
|
||||||
|
"up": "WheelScrollUp",
|
||||||
|
"down": "WheelScrollDown",
|
||||||
|
"left": "WheelScrollLeft",
|
||||||
|
"right": "WheelScrollRight",
|
||||||
|
}
|
||||||
|
|
||||||
|
func mangowcScrollToDirection(token string) (string, bool) {
|
||||||
|
v, ok := mangowcScrollDirection[strings.ToLower(token)]
|
||||||
|
return v, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func mangowcDirectionToScroll(direction string) (string, bool) {
|
||||||
|
v, ok := mangowcScrollCanonical[strings.ToLower(direction)]
|
||||||
|
return v, ok
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ type DMSBindsStatus struct {
|
|||||||
type CheatSheet struct {
|
type CheatSheet struct {
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"`
|
||||||
|
ModKey string `json:"modKey,omitempty"`
|
||||||
Binds map[string][]Keybind `json:"binds"`
|
Binds map[string][]Keybind `json:"binds"`
|
||||||
DMSBindsIncluded bool `json:"dmsBindsIncluded"`
|
DMSBindsIncluded bool `json:"dmsBindsIncluded"`
|
||||||
DMSStatus *DMSBindsStatus `json:"dmsStatus,omitempty"`
|
DMSStatus *DMSBindsStatus `json:"dmsStatus,omitempty"`
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ func GetQtLoggingRules() string {
|
|||||||
level = "info"
|
level = "info"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// scene carries QML engine warnings (e.g. QQuickImage "Cannot open" cache
|
||||||
|
// probes); suppressed except at debug level
|
||||||
var rules []string
|
var rules []string
|
||||||
switch strings.ToLower(level) {
|
switch strings.ToLower(level) {
|
||||||
case "fatal":
|
case "fatal":
|
||||||
@@ -75,13 +77,13 @@ func GetQtLoggingRules() string {
|
|||||||
case "error":
|
case "error":
|
||||||
rules = []string{"*.debug=false", "*.info=false", "*.warning=false"}
|
rules = []string{"*.debug=false", "*.info=false", "*.warning=false"}
|
||||||
case "warn", "warning":
|
case "warn", "warning":
|
||||||
rules = []string{"*.debug=false", "*.info=false"}
|
rules = []string{"*.debug=false", "*.info=false", "scene.warning=false"}
|
||||||
case "info":
|
case "info":
|
||||||
rules = []string{"*.debug=false"}
|
rules = []string{"*.debug=false", "scene.warning=false"}
|
||||||
case "debug":
|
case "debug":
|
||||||
return ""
|
return ""
|
||||||
default:
|
default:
|
||||||
rules = []string{"*.debug=false"}
|
rules = []string{"*.debug=false", "scene.warning=false"}
|
||||||
}
|
}
|
||||||
|
|
||||||
return strings.Join(rules, ";")
|
return strings.Join(rules, ";")
|
||||||
@@ -122,7 +124,9 @@ func GetLogger() *Logger {
|
|||||||
logger = &Logger{base}
|
logger = &Logger{base}
|
||||||
|
|
||||||
if path := os.Getenv("DMS_LOG_FILE"); path != "" {
|
if path := os.Getenv("DMS_LOG_FILE"); path != "" {
|
||||||
_ = SetLogFile(path)
|
logMu.Lock()
|
||||||
|
_ = setLogFile(logger, path)
|
||||||
|
logMu.Unlock()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return logger
|
return logger
|
||||||
@@ -143,15 +147,18 @@ func SetLevel(level string) {
|
|||||||
// profile when stderr is a TTY and route the file through ansiStripWriter so
|
// profile when stderr is a TTY and route the file through ansiStripWriter so
|
||||||
// the file stays plain while stderr keeps its colors.
|
// the file stays plain while stderr keeps its colors.
|
||||||
func SetLogFile(path string) error {
|
func SetLogFile(path string) error {
|
||||||
|
l := GetLogger()
|
||||||
logMu.Lock()
|
logMu.Lock()
|
||||||
defer logMu.Unlock()
|
defer logMu.Unlock()
|
||||||
|
return setLogFile(l, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setLogFile(l *Logger, path string) error {
|
||||||
if logFile != nil {
|
if logFile != nil {
|
||||||
logFile.Close()
|
logFile.Close()
|
||||||
logFile = nil
|
logFile = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
l := GetLogger()
|
|
||||||
if path == "" {
|
if path == "" {
|
||||||
l.SetOutput(logStderr)
|
l.SetOutput(logStderr)
|
||||||
applyColorProfile(l, logStderr)
|
applyColorProfile(l, logStderr)
|
||||||
|
|||||||
@@ -44,24 +44,26 @@ type TemplateDef struct {
|
|||||||
ID string
|
ID string
|
||||||
Commands []string
|
Commands []string
|
||||||
Flatpaks []string
|
Flatpaks []string
|
||||||
|
ConfigDirs []string
|
||||||
ConfigFile string
|
ConfigFile string
|
||||||
Kind TemplateKind
|
Kind TemplateKind
|
||||||
RunUnconditionally bool
|
RunUnconditionally bool
|
||||||
|
RequiredEnv string
|
||||||
}
|
}
|
||||||
|
|
||||||
var templateRegistry = []TemplateDef{
|
var templateRegistry = []TemplateDef{
|
||||||
{ID: "gtk", Kind: TemplateKindGTK, RunUnconditionally: true},
|
{ID: "gtk", Kind: TemplateKindGTK, RunUnconditionally: true},
|
||||||
{ID: "niri", Commands: []string{"niri"}, ConfigFile: "niri.toml"},
|
{ID: "niri", Commands: []string{"niri"}, ConfigFile: "niri.toml"},
|
||||||
{ID: "hyprland", Commands: []string{"Hyprland"}, ConfigFile: "hyprland.toml"},
|
{ID: "hyprland", Commands: []string{"Hyprland"}, ConfigFile: "hyprland.toml"},
|
||||||
{ID: "mangowc", Commands: []string{"mango"}, ConfigFile: "mangowc.toml"},
|
{ID: "mangowc", Commands: []string{"mango"}, ConfigFile: "mangowc.toml", RequiredEnv: "MANGO_INSTANCE_SIGNATURE"},
|
||||||
{ID: "qt5ct", Commands: []string{"qt5ct"}, ConfigFile: "qt5ct.toml"},
|
{ID: "qt5ct", Commands: []string{"qt5ct"}, ConfigFile: "qt5ct.toml"},
|
||||||
{ID: "qt6ct", Commands: []string{"qt6ct"}, ConfigFile: "qt6ct.toml"},
|
{ID: "qt6ct", Commands: []string{"qt6ct"}, ConfigFile: "qt6ct.toml"},
|
||||||
{ID: "firefox", Commands: []string{"firefox"}, ConfigFile: "firefox.toml"},
|
{ID: "firefox", Commands: []string{"firefox"}, ConfigFile: "firefox.toml"},
|
||||||
{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"}, ConfigDirs: []string{"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: "vencord", Commands: []string{"discord", "Discord", "discord-canary", "DiscordCanary"}, Flatpaks: []string{"com.discordapp.Discord", "com.discordapp.DiscordCanary"}, ConfigDirs: []string{"Vencord"}, ConfigFile: "vencord.toml"},
|
||||||
{ID: "equibop", Commands: []string{"equibop"}, ConfigFile: "equibop.toml"},
|
{ID: "equibop", Commands: []string{"equibop"}, ConfigDirs: []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},
|
||||||
{ID: "foot", Commands: []string{"foot"}, ConfigFile: "foot.toml", Kind: TemplateKindTerminal},
|
{ID: "foot", Commands: []string{"foot"}, ConfigFile: "foot.toml", Kind: TemplateKindTerminal},
|
||||||
@@ -117,10 +119,58 @@ type ColorsOutput struct {
|
|||||||
} `json:"colors"`
|
} `json:"colors"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SchemePreview struct {
|
||||||
|
Dark string `json:"dark"`
|
||||||
|
Light string `json:"light"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var previewSchemeTypes = []string{
|
||||||
|
"scheme-tonal-spot",
|
||||||
|
"scheme-vibrant",
|
||||||
|
"scheme-content",
|
||||||
|
"scheme-expressive",
|
||||||
|
"scheme-fidelity",
|
||||||
|
"scheme-fruit-salad",
|
||||||
|
"scheme-monochrome",
|
||||||
|
"scheme-neutral",
|
||||||
|
"scheme-rainbow",
|
||||||
|
}
|
||||||
|
|
||||||
|
func PreviewSchemes(sourceColor string, contrast float64) (map[string]SchemePreview, error) {
|
||||||
|
if sourceColor == "" {
|
||||||
|
return nil, fmt.Errorf("source color is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
previews := make(map[string]SchemePreview, len(previewSchemeTypes))
|
||||||
|
for _, schemeType := range previewSchemeTypes {
|
||||||
|
output, err := runMatugenDryRun(&Options{
|
||||||
|
Kind: "hex",
|
||||||
|
Value: sourceColor,
|
||||||
|
MatugenType: schemeType,
|
||||||
|
Contrast: contrast,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("preview %s: %w", schemeType, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dark := extractMatugenColor(output, "primary", "dark")
|
||||||
|
light := extractMatugenColor(output, "primary", "light")
|
||||||
|
if dark == "" || light == "" {
|
||||||
|
return nil, fmt.Errorf("preview %s: primary colors missing from matugen output", schemeType)
|
||||||
|
}
|
||||||
|
previews[schemeType] = SchemePreview{Dark: dark, Light: light}
|
||||||
|
}
|
||||||
|
return previews, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (o *Options) ColorsOutput() string {
|
func (o *Options) ColorsOutput() string {
|
||||||
return filepath.Join(o.StateDir, "dms-colors.json")
|
return filepath.Join(o.StateDir, "dms-colors.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (o *Options) colorsStaging() string {
|
||||||
|
return o.ColorsOutput() + ".tmp"
|
||||||
|
}
|
||||||
|
|
||||||
func (o *Options) ShouldSkipTemplate(name string) bool {
|
func (o *Options) ShouldSkipTemplate(name string) bool {
|
||||||
if o.SkipTemplates == "" {
|
if o.SkipTemplates == "" {
|
||||||
return false
|
return false
|
||||||
@@ -133,6 +183,38 @@ func (o *Options) ShouldSkipTemplate(name string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func acquireMatugenLock(stateDir string) (*os.File, error) {
|
||||||
|
f, err := os.OpenFile(filepath.Join(stateDir, "matugen.lock"), os.O_CREATE|os.O_RDWR, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open matugen lock: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline := time.Now().Add(45 * time.Second)
|
||||||
|
for {
|
||||||
|
switch err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err {
|
||||||
|
case nil:
|
||||||
|
return f, nil
|
||||||
|
case syscall.EWOULDBLOCK:
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
f.Close()
|
||||||
|
return nil, fmt.Errorf("timed out waiting for matugen lock")
|
||||||
|
}
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
default:
|
||||||
|
f.Close()
|
||||||
|
return nil, fmt.Errorf("failed to lock matugen: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func releaseMatugenLock(f *os.File) {
|
||||||
|
if f == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
|
||||||
|
f.Close()
|
||||||
|
}
|
||||||
|
|
||||||
func Run(opts Options) error {
|
func Run(opts Options) error {
|
||||||
if opts.StateDir == "" {
|
if opts.StateDir == "" {
|
||||||
return fmt.Errorf("state-dir is required")
|
return fmt.Errorf("state-dir is required")
|
||||||
@@ -166,6 +248,12 @@ func Run(opts Options) error {
|
|||||||
return fmt.Errorf("failed to create state dir: %w", err)
|
return fmt.Errorf("failed to create state dir: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lock, err := acquireMatugenLock(opts.StateDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer releaseMatugenLock(lock)
|
||||||
|
|
||||||
log.Infof("Building theme: %s %s (%s)", opts.Kind, opts.Value, opts.Mode)
|
log.Infof("Building theme: %s %s (%s)", opts.Kind, opts.Value, opts.Mode)
|
||||||
|
|
||||||
changed, buildErr := buildOnce(&opts)
|
changed, buildErr := buildOnce(&opts)
|
||||||
@@ -173,20 +261,22 @@ func Run(opts Options) error {
|
|||||||
return buildErr
|
return buildErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if opts.SyncModeWithPortal {
|
||||||
|
syncColorScheme(opts.Mode)
|
||||||
|
}
|
||||||
|
|
||||||
if !changed {
|
if !changed {
|
||||||
log.Info("No color changes detected, skipping refresh")
|
log.Info("No color changes detected, skipping refresh")
|
||||||
return ErrNoChanges
|
return ErrNoChanges
|
||||||
}
|
}
|
||||||
|
|
||||||
if opts.SyncModeWithPortal {
|
|
||||||
syncColorScheme(opts.Mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Info("Done")
|
log.Info("Done")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildOnce(opts *Options) (bool, error) {
|
func buildOnce(opts *Options) (bool, error) {
|
||||||
|
defer os.Remove(opts.colorsStaging())
|
||||||
|
|
||||||
cfgFile, err := os.CreateTemp("", "matugen-config-*.toml")
|
cfgFile, err := os.CreateTemp("", "matugen-config-*.toml")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("failed to create temp config: %w", err)
|
return false, fmt.Errorf("failed to create temp config: %w", err)
|
||||||
@@ -274,10 +364,16 @@ func buildOnce(opts *Options) (bool, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
newColors, _ := os.ReadFile(opts.ColorsOutput())
|
newColors, err := os.ReadFile(opts.colorsStaging())
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("matugen did not produce colors output: %w", err)
|
||||||
|
}
|
||||||
if bytes.Equal(oldColors, newColors) && len(oldColors) > 0 {
|
if bytes.Equal(oldColors, newColors) && len(oldColors) > 0 {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
if err := os.Rename(opts.colorsStaging(), opts.ColorsOutput()); err != nil {
|
||||||
|
return false, fmt.Errorf("failed to commit colors output: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
if opts.ColorsOnly {
|
if opts.ColorsOnly {
|
||||||
return true, nil
|
return true, nil
|
||||||
@@ -345,7 +441,7 @@ func buildMergedConfig(opts *Options, cfgFile *os.File, tmpDir string) error {
|
|||||||
input_path = '%s/matugen/templates/dank.json'
|
input_path = '%s/matugen/templates/dank.json'
|
||||||
output_path = '%s'
|
output_path = '%s'
|
||||||
|
|
||||||
`, opts.ShellDir, opts.ColorsOutput())
|
`, opts.ShellDir, opts.colorsStaging())
|
||||||
|
|
||||||
if opts.ColorsOnly {
|
if opts.ColorsOnly {
|
||||||
return nil
|
return nil
|
||||||
@@ -356,14 +452,17 @@ output_path = '%s'
|
|||||||
if opts.ShouldSkipTemplate(tmpl.ID) {
|
if opts.ShouldSkipTemplate(tmpl.ID) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if !templateSessionActive(tmpl) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
switch tmpl.Kind {
|
switch tmpl.Kind {
|
||||||
case TemplateKindGTK:
|
case TemplateKindGTK:
|
||||||
switch opts.Mode {
|
switch opts.Mode {
|
||||||
case ColorModeLight:
|
case ColorModeLight:
|
||||||
appendConfig(opts, cfgFile, nil, nil, "gtk3-light.toml")
|
appendConfig(opts, cfgFile, nil, nil, nil, "gtk3-light.toml")
|
||||||
default:
|
default:
|
||||||
appendConfig(opts, cfgFile, nil, nil, "gtk3-dark.toml")
|
appendConfig(opts, cfgFile, nil, nil, nil, "gtk3-dark.toml")
|
||||||
}
|
}
|
||||||
case TemplateKindTerminal:
|
case TemplateKindTerminal:
|
||||||
appendTerminalConfig(opts, cfgFile, tmpDir, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigFile)
|
appendTerminalConfig(opts, cfgFile, tmpDir, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigFile)
|
||||||
@@ -376,10 +475,10 @@ output_path = '%s'
|
|||||||
appendVSCodeConfig(cfgFile, "vscode-insiders", filepath.Join(homeDir, ".vscode-insiders/extensions"), opts.ShellDir)
|
appendVSCodeConfig(cfgFile, "vscode-insiders", filepath.Join(homeDir, ".vscode-insiders/extensions"), opts.ShellDir)
|
||||||
case TemplateKindEmacs:
|
case TemplateKindEmacs:
|
||||||
if utils.EmacsConfigDir() != "" {
|
if utils.EmacsConfigDir() != "" {
|
||||||
appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigFile)
|
appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigDirs, tmpl.ConfigFile)
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigFile)
|
appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigDirs, tmpl.ConfigFile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,13 +513,14 @@ func appendConfig(
|
|||||||
cfgFile *os.File,
|
cfgFile *os.File,
|
||||||
checkCmd []string,
|
checkCmd []string,
|
||||||
checkFlatpaks []string,
|
checkFlatpaks []string,
|
||||||
|
checkConfigDirs []string,
|
||||||
fileName string,
|
fileName string,
|
||||||
) {
|
) {
|
||||||
configPath := filepath.Join(opts.ShellDir, "matugen", "configs", fileName)
|
configPath := filepath.Join(opts.ShellDir, "matugen", "configs", fileName)
|
||||||
if _, err := os.Stat(configPath); err != nil {
|
if _, err := os.Stat(configPath); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !appExists(opts.AppChecker, checkCmd, checkFlatpaks) {
|
if !appExists(opts.AppChecker, checkCmd, checkFlatpaks) && !configDirExists(checkConfigDirs) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data, err := os.ReadFile(configPath)
|
data, err := os.ReadFile(configPath)
|
||||||
@@ -489,6 +589,32 @@ func appendTerminalConfig(opts *Options, cfgFile *os.File, tmpDir string, checkC
|
|||||||
cfgFile.WriteString("\n")
|
cfgFile.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func templateSessionActive(tmpl TemplateDef) bool {
|
||||||
|
if tmpl.RequiredEnv == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
socket := os.Getenv(tmpl.RequiredEnv)
|
||||||
|
if socket == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, err := os.Stat(socket)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func configDirExists(names []string) bool {
|
||||||
|
configHome := utils.XDGConfigHome()
|
||||||
|
if configHome == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, name := range names {
|
||||||
|
info, err := os.Stat(filepath.Join(configHome, name))
|
||||||
|
if err == nil && info.IsDir() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func appExists(checker utils.AppChecker, checkCmd []string, checkFlatpaks []string) bool {
|
func appExists(checker utils.AppChecker, checkCmd []string, checkFlatpaks []string) bool {
|
||||||
// Both nil is treated as "skip check" / unconditionally run
|
// Both nil is treated as "skip check" / unconditionally run
|
||||||
if checkCmd == nil && checkFlatpaks == nil {
|
if checkCmd == nil && checkFlatpaks == nil {
|
||||||
@@ -589,6 +715,7 @@ func redetectMatugenVersion(old matugenFlags) (matugenFlags, bool) {
|
|||||||
|
|
||||||
func detectMatugenVersionLocked() (matugenFlags, error) {
|
func detectMatugenVersionLocked() (matugenFlags, error) {
|
||||||
cmd := exec.Command("matugen", "--version")
|
cmd := exec.Command("matugen", "--version")
|
||||||
|
cmd.Env = utils.EnvWithUserBinPath(nil)
|
||||||
output, err := cmd.Output()
|
output, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return matugenFlags{}, fmt.Errorf("failed to get matugen version: %w", err)
|
return matugenFlags{}, fmt.Errorf("failed to get matugen version: %w", err)
|
||||||
@@ -645,6 +772,7 @@ func runMatugen(baseArgs []string) error {
|
|||||||
|
|
||||||
args := buildMatugenArgs(baseArgs, flags)
|
args := buildMatugenArgs(baseArgs, flags)
|
||||||
cmd := exec.Command("matugen", args...)
|
cmd := exec.Command("matugen", args...)
|
||||||
|
cmd.Env = utils.EnvWithUserBinPath(nil)
|
||||||
cmd.Stdout = os.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
runErr := cmd.Run()
|
runErr := cmd.Run()
|
||||||
@@ -662,6 +790,7 @@ func runMatugen(baseArgs []string) error {
|
|||||||
log.Warnf("Matugen version changed (v4: %v -> %v), retrying", flags.isV4, newFlags.isV4)
|
log.Warnf("Matugen version changed (v4: %v -> %v), retrying", flags.isV4, newFlags.isV4)
|
||||||
args = buildMatugenArgs(baseArgs, newFlags)
|
args = buildMatugenArgs(baseArgs, newFlags)
|
||||||
retryCmd := exec.Command("matugen", args...)
|
retryCmd := exec.Command("matugen", args...)
|
||||||
|
retryCmd.Env = utils.EnvWithUserBinPath(nil)
|
||||||
retryCmd.Stdout = os.Stdout
|
retryCmd.Stdout = os.Stdout
|
||||||
retryCmd.Stderr = os.Stderr
|
retryCmd.Stderr = os.Stderr
|
||||||
return retryCmd.Run()
|
return retryCmd.Run()
|
||||||
@@ -704,6 +833,7 @@ func execDryRun(opts *Options, flags matugenFlags) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.Command("matugen", baseArgs...)
|
cmd := exec.Command("matugen", baseArgs...)
|
||||||
|
cmd.Env = utils.EnvWithUserBinPath(nil)
|
||||||
var stderr strings.Builder
|
var stderr strings.Builder
|
||||||
cmd.Stderr = &stderr
|
cmd.Stderr = &stderr
|
||||||
output, err := cmd.Output()
|
output, err := cmd.Output()
|
||||||
@@ -802,7 +932,26 @@ func refreshGTK(mode ColorMode) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var colorSchemeEchoHook func(scheme string)
|
||||||
|
|
||||||
|
func SetColorSchemeEchoHook(hook func(scheme string)) {
|
||||||
|
colorSchemeEchoHook = hook
|
||||||
|
}
|
||||||
|
|
||||||
|
func expectColorSchemeEcho(scheme string) {
|
||||||
|
if colorSchemeEchoHook != nil {
|
||||||
|
colorSchemeEchoHook(scheme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The color-scheme round trip is the only mechanism that makes running GTK4
|
||||||
|
// apps reload ~/.config/gtk-4.0 CSS (a gtk-theme flip does not). But apps
|
||||||
|
// following the portal color-scheme (Chromium) can drop the restore signal
|
||||||
|
// mid-repaint and latch the wrong mode, so this is opt-in.
|
||||||
func refreshGTK4() {
|
func refreshGTK4() {
|
||||||
|
if os.Getenv("DMS_ENABLE_GTK4_REFRESH") != "1" {
|
||||||
|
return
|
||||||
|
}
|
||||||
output, err := utils.GsettingsGet("org.gnome.desktop.interface", "color-scheme")
|
output, err := utils.GsettingsGet("org.gnome.desktop.interface", "color-scheme")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
@@ -816,11 +965,13 @@ func refreshGTK4() {
|
|||||||
toggle = "prefer-dark"
|
toggle = "prefer-dark"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
expectColorSchemeEcho(toggle)
|
||||||
if err := utils.GsettingsSet("org.gnome.desktop.interface", "color-scheme", toggle); err != nil {
|
if err := utils.GsettingsSet("org.gnome.desktop.interface", "color-scheme", toggle); err != nil {
|
||||||
log.Warnf("Failed to toggle color-scheme for GTK4 refresh: %v", err)
|
log.Warnf("Failed to toggle color-scheme for GTK4 refresh: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
time.Sleep(50 * time.Millisecond)
|
time.Sleep(400 * time.Millisecond)
|
||||||
|
expectColorSchemeEcho(current)
|
||||||
if err := utils.GsettingsSet("org.gnome.desktop.interface", "color-scheme", current); err != nil {
|
if err := utils.GsettingsSet("org.gnome.desktop.interface", "color-scheme", current); err != nil {
|
||||||
log.Warnf("Failed to restore color-scheme for GTK4 refresh: %v", err)
|
log.Warnf("Failed to restore color-scheme for GTK4 refresh: %v", err)
|
||||||
}
|
}
|
||||||
@@ -871,6 +1022,13 @@ func syncColorScheme(mode ColorMode) {
|
|||||||
scheme = "default"
|
scheme = "default"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cur, err := utils.GsettingsGet("org.gnome.desktop.interface", "color-scheme"); err == nil {
|
||||||
|
cur = strings.Trim(cur, "'")
|
||||||
|
if cur == scheme || (mode == ColorModeLight && cur == "prefer-light") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := utils.GsettingsSet("org.gnome.desktop.interface", "color-scheme", scheme); err != nil {
|
if err := utils.GsettingsSet("org.gnome.desktop.interface", "color-scheme", scheme); err != nil {
|
||||||
log.Warnf("Failed to sync color-scheme: %v", err)
|
log.Warnf("Failed to sync color-scheme: %v", err)
|
||||||
}
|
}
|
||||||
@@ -921,6 +1079,9 @@ func closestAdwaitaAccent(primaryHex string) string {
|
|||||||
|
|
||||||
func syncAccentColor(primaryHex string) {
|
func syncAccentColor(primaryHex string) {
|
||||||
accent := closestAdwaitaAccent(primaryHex)
|
accent := closestAdwaitaAccent(primaryHex)
|
||||||
|
if cur, err := utils.GsettingsGet("org.gnome.desktop.interface", "accent-color"); err == nil && strings.Trim(cur, "'") == accent {
|
||||||
|
return
|
||||||
|
}
|
||||||
log.Infof("Setting GNOME accent color: %s", accent)
|
log.Infof("Setting GNOME accent color: %s", accent)
|
||||||
if err := utils.GsettingsSet("org.gnome.desktop.interface", "accent-color", accent); err != nil {
|
if err := utils.GsettingsSet("org.gnome.desktop.interface", "accent-color", accent); err != nil {
|
||||||
log.Warnf("Failed to set accent-color: %v", err)
|
log.Warnf("Failed to set accent-color: %v", err)
|
||||||
@@ -951,7 +1112,7 @@ func CheckTemplates(checker utils.AppChecker) []TemplateCheck {
|
|||||||
case tmpl.Kind == TemplateKindEmacs:
|
case tmpl.Kind == TemplateKindEmacs:
|
||||||
detected = appExists(checker, tmpl.Commands, tmpl.Flatpaks) && utils.EmacsConfigDir() != ""
|
detected = appExists(checker, tmpl.Commands, tmpl.Flatpaks) && utils.EmacsConfigDir() != ""
|
||||||
default:
|
default:
|
||||||
detected = appExists(checker, tmpl.Commands, tmpl.Flatpaks)
|
detected = (appExists(checker, tmpl.Commands, tmpl.Flatpaks) || configDirExists(tmpl.ConfigDirs)) && templateSessionActive(tmpl)
|
||||||
}
|
}
|
||||||
|
|
||||||
checks = append(checks, TemplateCheck{ID: tmpl.ID, Detected: detected})
|
checks = append(checks, TemplateCheck{ID: tmpl.ID, Detected: detected})
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ func TestAppendConfigBinaryExists(t *testing.T) {
|
|||||||
|
|
||||||
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
|
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
|
||||||
|
|
||||||
appendConfig(opts, cfgFile, []string{"sh"}, nil, "test.toml")
|
appendConfig(opts, cfgFile, []string{"sh"}, nil, nil, "test.toml")
|
||||||
|
|
||||||
cfgFile.Close()
|
cfgFile.Close()
|
||||||
output, err := os.ReadFile(outFile)
|
output, err := os.ReadFile(outFile)
|
||||||
@@ -82,7 +82,7 @@ func TestAppendConfigBinaryDoesNotExist(t *testing.T) {
|
|||||||
|
|
||||||
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
|
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
|
||||||
|
|
||||||
appendConfig(opts, cfgFile, []string{"nonexistent-binary-12345"}, []string{}, "test.toml")
|
appendConfig(opts, cfgFile, []string{"nonexistent-binary-12345"}, []string{}, nil, "test.toml")
|
||||||
|
|
||||||
cfgFile.Close()
|
cfgFile.Close()
|
||||||
output, err := os.ReadFile(outFile)
|
output, err := os.ReadFile(outFile)
|
||||||
@@ -122,7 +122,7 @@ func TestAppendConfigFlatpakExists(t *testing.T) {
|
|||||||
|
|
||||||
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
|
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
|
||||||
|
|
||||||
appendConfig(opts, cfgFile, nil, []string{"app.zen_browser.zen"}, "test.toml")
|
appendConfig(opts, cfgFile, nil, []string{"app.zen_browser.zen"}, nil, "test.toml")
|
||||||
|
|
||||||
cfgFile.Close()
|
cfgFile.Close()
|
||||||
output, err := os.ReadFile(outFile)
|
output, err := os.ReadFile(outFile)
|
||||||
@@ -163,7 +163,7 @@ func TestAppendConfigFlatpakDoesNotExist(t *testing.T) {
|
|||||||
|
|
||||||
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
|
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
|
||||||
|
|
||||||
appendConfig(opts, cfgFile, []string{}, []string{"com.nonexistent.flatpak"}, "test.toml")
|
appendConfig(opts, cfgFile, []string{}, []string{"com.nonexistent.flatpak"}, nil, "test.toml")
|
||||||
|
|
||||||
cfgFile.Close()
|
cfgFile.Close()
|
||||||
output, err := os.ReadFile(outFile)
|
output, err := os.ReadFile(outFile)
|
||||||
@@ -203,7 +203,7 @@ func TestAppendConfigBothExist(t *testing.T) {
|
|||||||
|
|
||||||
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
|
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
|
||||||
|
|
||||||
appendConfig(opts, cfgFile, []string{"sh"}, []string{"app.zen_browser.zen"}, "test.toml")
|
appendConfig(opts, cfgFile, []string{"sh"}, []string{"app.zen_browser.zen"}, nil, "test.toml")
|
||||||
|
|
||||||
cfgFile.Close()
|
cfgFile.Close()
|
||||||
output, err := os.ReadFile(outFile)
|
output, err := os.ReadFile(outFile)
|
||||||
@@ -244,7 +244,7 @@ func TestAppendConfigNeitherExists(t *testing.T) {
|
|||||||
|
|
||||||
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
|
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
|
||||||
|
|
||||||
appendConfig(opts, cfgFile, []string{"nonexistent-binary-12345"}, []string{"com.nonexistent.flatpak"}, "test.toml")
|
appendConfig(opts, cfgFile, []string{"nonexistent-binary-12345"}, []string{"com.nonexistent.flatpak"}, nil, "test.toml")
|
||||||
|
|
||||||
cfgFile.Close()
|
cfgFile.Close()
|
||||||
output, err := os.ReadFile(outFile)
|
output, err := os.ReadFile(outFile)
|
||||||
@@ -281,7 +281,7 @@ func TestAppendConfigNoChecks(t *testing.T) {
|
|||||||
|
|
||||||
opts := &Options{ShellDir: shellDir}
|
opts := &Options{ShellDir: shellDir}
|
||||||
|
|
||||||
appendConfig(opts, cfgFile, nil, nil, "test.toml")
|
appendConfig(opts, cfgFile, nil, nil, nil, "test.toml")
|
||||||
|
|
||||||
cfgFile.Close()
|
cfgFile.Close()
|
||||||
output, err := os.ReadFile(outFile)
|
output, err := os.ReadFile(outFile)
|
||||||
@@ -312,7 +312,7 @@ func TestAppendConfigFileDoesNotExist(t *testing.T) {
|
|||||||
|
|
||||||
opts := &Options{ShellDir: shellDir}
|
opts := &Options{ShellDir: shellDir}
|
||||||
|
|
||||||
appendConfig(opts, cfgFile, nil, nil, "nonexistent.toml")
|
appendConfig(opts, cfgFile, nil, nil, nil, "nonexistent.toml")
|
||||||
|
|
||||||
cfgFile.Close()
|
cfgFile.Close()
|
||||||
output, err := os.ReadFile(outFile)
|
output, err := os.ReadFile(outFile)
|
||||||
@@ -437,7 +437,140 @@ func TestBuildMergedConfigColorsOnly(t *testing.T) {
|
|||||||
|
|
||||||
content := string(output)
|
content := string(output)
|
||||||
assert.Contains(t, content, "[templates.dank]")
|
assert.Contains(t, content, "[templates.dank]")
|
||||||
assert.Contains(t, content, "output_path = '"+filepath.Join(opts.StateDir, "dms-colors.json")+"'")
|
assert.Contains(t, content, "output_path = '"+opts.colorsStaging()+"'")
|
||||||
assert.NotContains(t, content, "[templates.gtk]")
|
assert.NotContains(t, content, "[templates.gtk]")
|
||||||
assert.False(t, strings.Contains(content, "output_path = 'CONFIG_DIR/"), "colors-only config should not emit app template outputs")
|
assert.False(t, strings.Contains(content, "output_path = 'CONFIG_DIR/"), "colors-only config should not emit app template outputs")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildMergedConfigSkipsMangowcWithoutActiveSession(t *testing.T) {
|
||||||
|
t.Setenv("MANGO_INSTANCE_SIGNATURE", "")
|
||||||
|
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
shellDir := filepath.Join(tempDir, "shell")
|
||||||
|
configsDir := filepath.Join(shellDir, "matugen", "configs")
|
||||||
|
if err := os.MkdirAll(configsDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("failed to create configs dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(filepath.Join(configsDir, "base.toml"), []byte("[config]\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("failed to write base config: %v", err)
|
||||||
|
}
|
||||||
|
mangowcConfig := "[templates.dmsmango]\ninput_path = 'in'\noutput_path = 'out'\n"
|
||||||
|
if err := os.WriteFile(filepath.Join(configsDir, "mangowc.toml"), []byte(mangowcConfig), 0o644); err != nil {
|
||||||
|
t.Fatalf("failed to write mangowc config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfgFile, err := os.CreateTemp(tempDir, "merged-*.toml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create temp config: %v", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(cfgFile.Name())
|
||||||
|
defer cfgFile.Close()
|
||||||
|
|
||||||
|
opts := &Options{
|
||||||
|
ShellDir: shellDir,
|
||||||
|
ConfigDir: filepath.Join(tempDir, "config"),
|
||||||
|
StateDir: filepath.Join(tempDir, "state"),
|
||||||
|
SkipTemplates: "gtk,niri,hyprland,qt5ct,qt6ct,firefox,pywalfox,zenbrowser,vesktop,vencord,equibop,ghostty,kitty,foot,alacritty,wezterm,nvim,dgop,kcolorscheme,vscode,emacs,zed",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := buildMergedConfig(opts, cfgFile, filepath.Join(tempDir, "templates")); err != nil {
|
||||||
|
t.Fatalf("buildMergedConfig failed: %v", err)
|
||||||
|
}
|
||||||
|
if err := cfgFile.Close(); err != nil {
|
||||||
|
t.Fatalf("failed to close merged config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := os.ReadFile(cfgFile.Name())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read merged config: %v", err)
|
||||||
|
}
|
||||||
|
assert.NotContains(t, string(output), "[templates.dmsmango]")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppendConfigConfigDirExists(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
shellDir := filepath.Join(tempDir, "shell")
|
||||||
|
configsDir := filepath.Join(shellDir, "matugen", "configs")
|
||||||
|
if err := os.MkdirAll(configsDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("failed to create configs dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
testConfig := "vencord config content"
|
||||||
|
if err := os.WriteFile(filepath.Join(configsDir, "vencord.toml"), []byte(testConfig), 0o644); err != nil {
|
||||||
|
t.Fatalf("failed to write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
configHome := filepath.Join(tempDir, "config")
|
||||||
|
if err := os.MkdirAll(filepath.Join(configHome, "Vencord"), 0o755); err != nil {
|
||||||
|
t.Fatalf("failed to create Vencord config dir: %v", err)
|
||||||
|
}
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", configHome)
|
||||||
|
|
||||||
|
outFile := filepath.Join(tempDir, "output.toml")
|
||||||
|
cfgFile, err := os.Create(outFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create output file: %v", err)
|
||||||
|
}
|
||||||
|
defer cfgFile.Close()
|
||||||
|
|
||||||
|
mockChecker := mocks_utils.NewMockAppChecker(t)
|
||||||
|
mockChecker.EXPECT().AnyCommandExists("nonexistent-binary-12345").Return(false)
|
||||||
|
mockChecker.EXPECT().AnyFlatpakExists("com.nonexistent.flatpak").Return(false)
|
||||||
|
|
||||||
|
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
|
||||||
|
|
||||||
|
appendConfig(opts, cfgFile, []string{"nonexistent-binary-12345"}, []string{"com.nonexistent.flatpak"}, []string{"Vencord"}, "vencord.toml")
|
||||||
|
|
||||||
|
cfgFile.Close()
|
||||||
|
output, err := os.ReadFile(outFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read output: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, testConfig+"\n", string(output))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppendConfigConfigDirDoesNotExist(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
shellDir := filepath.Join(tempDir, "shell")
|
||||||
|
configsDir := filepath.Join(shellDir, "matugen", "configs")
|
||||||
|
if err := os.MkdirAll(configsDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("failed to create configs dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(filepath.Join(configsDir, "vencord.toml"), []byte("vencord config content"), 0o644); err != nil {
|
||||||
|
t.Fatalf("failed to write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
configHome := filepath.Join(tempDir, "config")
|
||||||
|
if err := os.MkdirAll(configHome, 0o755); err != nil {
|
||||||
|
t.Fatalf("failed to create config home: %v", err)
|
||||||
|
}
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", configHome)
|
||||||
|
|
||||||
|
outFile := filepath.Join(tempDir, "output.toml")
|
||||||
|
cfgFile, err := os.Create(outFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create output file: %v", err)
|
||||||
|
}
|
||||||
|
defer cfgFile.Close()
|
||||||
|
|
||||||
|
mockChecker := mocks_utils.NewMockAppChecker(t)
|
||||||
|
mockChecker.EXPECT().AnyCommandExists("nonexistent-binary-12345").Return(false)
|
||||||
|
mockChecker.EXPECT().AnyFlatpakExists("com.nonexistent.flatpak").Return(false)
|
||||||
|
|
||||||
|
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
|
||||||
|
|
||||||
|
appendConfig(opts, cfgFile, []string{"nonexistent-binary-12345"}, []string{"com.nonexistent.flatpak"}, []string{"Vencord"}, "vencord.toml")
|
||||||
|
|
||||||
|
cfgFile.Close()
|
||||||
|
output, err := os.ReadFile(outFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read output: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Empty(t, string(output))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,28 @@
|
|||||||
// Code generated by mockery v2.53.5. DO NOT EDIT.
|
// Code generated by mockery; DO NOT EDIT.
|
||||||
|
// github.com/vektra/mockery
|
||||||
|
// template: testify
|
||||||
|
|
||||||
package mocks_brightness
|
package mocks_brightness
|
||||||
|
|
||||||
import (
|
import (
|
||||||
dbus "github.com/godbus/dbus/v5"
|
"github.com/godbus/dbus/v5"
|
||||||
mock "github.com/stretchr/testify/mock"
|
mock "github.com/stretchr/testify/mock"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// NewMockDBusConn creates a new instance of MockDBusConn. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
||||||
|
// The first argument is typically a *testing.T value.
|
||||||
|
func NewMockDBusConn(t interface {
|
||||||
|
mock.TestingT
|
||||||
|
Cleanup(func())
|
||||||
|
}) *MockDBusConn {
|
||||||
|
mock := &MockDBusConn{}
|
||||||
|
mock.Mock.Test(t)
|
||||||
|
|
||||||
|
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||||
|
|
||||||
|
return mock
|
||||||
|
}
|
||||||
|
|
||||||
// MockDBusConn is an autogenerated mock type for the DBusConn type
|
// MockDBusConn is an autogenerated mock type for the DBusConn type
|
||||||
type MockDBusConn struct {
|
type MockDBusConn struct {
|
||||||
mock.Mock
|
mock.Mock
|
||||||
@@ -20,21 +36,20 @@ func (_m *MockDBusConn) EXPECT() *MockDBusConn_Expecter {
|
|||||||
return &MockDBusConn_Expecter{mock: &_m.Mock}
|
return &MockDBusConn_Expecter{mock: &_m.Mock}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close provides a mock function with no fields
|
// Close provides a mock function for the type MockDBusConn
|
||||||
func (_m *MockDBusConn) Close() error {
|
func (_mock *MockDBusConn) Close() error {
|
||||||
ret := _m.Called()
|
ret := _mock.Called()
|
||||||
|
|
||||||
if len(ret) == 0 {
|
if len(ret) == 0 {
|
||||||
panic("no return value specified for Close")
|
panic("no return value specified for Close")
|
||||||
}
|
}
|
||||||
|
|
||||||
var r0 error
|
var r0 error
|
||||||
if rf, ok := ret.Get(0).(func() error); ok {
|
if returnFunc, ok := ret.Get(0).(func() error); ok {
|
||||||
r0 = rf()
|
r0 = returnFunc()
|
||||||
} else {
|
} else {
|
||||||
r0 = ret.Error(0)
|
r0 = ret.Error(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
return r0
|
return r0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,8 +70,8 @@ func (_c *MockDBusConn_Close_Call) Run(run func()) *MockDBusConn_Close_Call {
|
|||||||
return _c
|
return _c
|
||||||
}
|
}
|
||||||
|
|
||||||
func (_c *MockDBusConn_Close_Call) Return(_a0 error) *MockDBusConn_Close_Call {
|
func (_c *MockDBusConn_Close_Call) Return(err error) *MockDBusConn_Close_Call {
|
||||||
_c.Call.Return(_a0)
|
_c.Call.Return(err)
|
||||||
return _c
|
return _c
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,23 +80,22 @@ func (_c *MockDBusConn_Close_Call) RunAndReturn(run func() error) *MockDBusConn_
|
|||||||
return _c
|
return _c
|
||||||
}
|
}
|
||||||
|
|
||||||
// Object provides a mock function with given fields: dest, path
|
// Object provides a mock function for the type MockDBusConn
|
||||||
func (_m *MockDBusConn) Object(dest string, path dbus.ObjectPath) dbus.BusObject {
|
func (_mock *MockDBusConn) Object(dest string, path dbus.ObjectPath) dbus.BusObject {
|
||||||
ret := _m.Called(dest, path)
|
ret := _mock.Called(dest, path)
|
||||||
|
|
||||||
if len(ret) == 0 {
|
if len(ret) == 0 {
|
||||||
panic("no return value specified for Object")
|
panic("no return value specified for Object")
|
||||||
}
|
}
|
||||||
|
|
||||||
var r0 dbus.BusObject
|
var r0 dbus.BusObject
|
||||||
if rf, ok := ret.Get(0).(func(string, dbus.ObjectPath) dbus.BusObject); ok {
|
if returnFunc, ok := ret.Get(0).(func(string, dbus.ObjectPath) dbus.BusObject); ok {
|
||||||
r0 = rf(dest, path)
|
r0 = returnFunc(dest, path)
|
||||||
} else {
|
} else {
|
||||||
if ret.Get(0) != nil {
|
if ret.Get(0) != nil {
|
||||||
r0 = ret.Get(0).(dbus.BusObject)
|
r0 = ret.Get(0).(dbus.BusObject)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return r0
|
return r0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,32 +113,28 @@ func (_e *MockDBusConn_Expecter) Object(dest any, path any) *MockDBusConn_Object
|
|||||||
|
|
||||||
func (_c *MockDBusConn_Object_Call) Run(run func(dest string, path dbus.ObjectPath)) *MockDBusConn_Object_Call {
|
func (_c *MockDBusConn_Object_Call) Run(run func(dest string, path dbus.ObjectPath)) *MockDBusConn_Object_Call {
|
||||||
_c.Call.Run(func(args mock.Arguments) {
|
_c.Call.Run(func(args mock.Arguments) {
|
||||||
run(args[0].(string), args[1].(dbus.ObjectPath))
|
var arg0 string
|
||||||
|
if args[0] != nil {
|
||||||
|
arg0 = args[0].(string)
|
||||||
|
}
|
||||||
|
var arg1 dbus.ObjectPath
|
||||||
|
if args[1] != nil {
|
||||||
|
arg1 = args[1].(dbus.ObjectPath)
|
||||||
|
}
|
||||||
|
run(
|
||||||
|
arg0,
|
||||||
|
arg1,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
return _c
|
return _c
|
||||||
}
|
}
|
||||||
|
|
||||||
func (_c *MockDBusConn_Object_Call) Return(_a0 dbus.BusObject) *MockDBusConn_Object_Call {
|
func (_c *MockDBusConn_Object_Call) Return(busObject dbus.BusObject) *MockDBusConn_Object_Call {
|
||||||
_c.Call.Return(_a0)
|
_c.Call.Return(busObject)
|
||||||
return _c
|
return _c
|
||||||
}
|
}
|
||||||
|
|
||||||
func (_c *MockDBusConn_Object_Call) RunAndReturn(run func(string, dbus.ObjectPath) dbus.BusObject) *MockDBusConn_Object_Call {
|
func (_c *MockDBusConn_Object_Call) RunAndReturn(run func(dest string, path dbus.ObjectPath) dbus.BusObject) *MockDBusConn_Object_Call {
|
||||||
_c.Call.Return(run)
|
_c.Call.Return(run)
|
||||||
return _c
|
return _c
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewMockDBusConn creates a new instance of MockDBusConn. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
|
||||||
// The first argument is typically a *testing.T value.
|
|
||||||
func NewMockDBusConn(t interface {
|
|
||||||
mock.TestingT
|
|
||||||
Cleanup(func())
|
|
||||||
},
|
|
||||||
) *MockDBusConn {
|
|
||||||
mock := &MockDBusConn{}
|
|
||||||
mock.Mock.Test(t)
|
|
||||||
|
|
||||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
|
||||||
|
|
||||||
return mock
|
|
||||||
}
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user